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
plinth/plan.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Turn a validated config into a deterministic generation plan."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
from plinth.schema import AppKind, Layout, ProjectConfig
|
|
9
|
+
|
|
10
|
+
RepoRole = Literal["app", "gitops"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class WorkloadPlan:
|
|
15
|
+
name: str
|
|
16
|
+
kind: AppKind
|
|
17
|
+
port: int | None # None for workers
|
|
18
|
+
source_dir: str # "" at repo root, or "apps/<name>/" in a monorepo
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def score_path(self) -> str:
|
|
22
|
+
return f"{self.source_dir}score.yaml"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class RepoPlan:
|
|
27
|
+
folder: str
|
|
28
|
+
role: RepoRole
|
|
29
|
+
url: str
|
|
30
|
+
workloads: tuple[WorkloadPlan, ...]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class Plan:
|
|
35
|
+
config: ProjectConfig
|
|
36
|
+
repos: tuple[RepoPlan, ...]
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def gitops(self) -> RepoPlan:
|
|
40
|
+
return next(r for r in self.repos if r.role == "gitops")
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def app_repos(self) -> tuple[RepoPlan, ...]:
|
|
44
|
+
return tuple(r for r in self.repos if r.role == "app")
|
|
45
|
+
|
|
46
|
+
def workloads(self) -> list[tuple[RepoPlan, WorkloadPlan]]:
|
|
47
|
+
return [(r, w) for r in self.app_repos for w in r.workloads]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def build_plan(config: ProjectConfig) -> Plan:
|
|
51
|
+
project = config.project
|
|
52
|
+
base = project.git_base_url
|
|
53
|
+
|
|
54
|
+
def repo(folder: str, role: RepoRole, workloads: tuple[WorkloadPlan, ...]) -> RepoPlan:
|
|
55
|
+
return RepoPlan(folder, role, f"{base}/{folder}.git", workloads)
|
|
56
|
+
|
|
57
|
+
repos: list[RepoPlan] = []
|
|
58
|
+
if project.layout is Layout.MONOREPO:
|
|
59
|
+
workloads = tuple(
|
|
60
|
+
WorkloadPlan(a.name, a.kind, a.http_port, f"apps/{a.name}/")
|
|
61
|
+
for a in config.applications
|
|
62
|
+
)
|
|
63
|
+
repos.append(repo(f"{project.name}-app", "app", workloads))
|
|
64
|
+
else:
|
|
65
|
+
for a in config.applications:
|
|
66
|
+
workload = WorkloadPlan(a.name, a.kind, a.http_port, "")
|
|
67
|
+
repos.append(repo(f"{project.name}-{a.name}", "app", (workload,)))
|
|
68
|
+
|
|
69
|
+
repos.append(repo(f"{project.name}-gitops", "gitops", ()))
|
|
70
|
+
return Plan(config=config, repos=tuple(repos))
|
plinth/render.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Render blueprints into an in-memory file map."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from importlib.metadata import PackageNotFoundError
|
|
6
|
+
from importlib.metadata import version as dist_version
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import yaml
|
|
10
|
+
from jinja2 import Environment, FileSystemLoader, StrictUndefined
|
|
11
|
+
|
|
12
|
+
from plinth import config as defaults
|
|
13
|
+
from plinth.plan import Plan, RepoPlan
|
|
14
|
+
|
|
15
|
+
BLUEPRINTS_DIR = Path(__file__).resolve().parent / "blueprints"
|
|
16
|
+
TEMPLATE_SUFFIX = ".j2"
|
|
17
|
+
DOT_PREFIX = "dot-" # blueprint "dot-gitignore" renders as ".gitignore"
|
|
18
|
+
EXECUTABLE_SUFFIXES = (".sh",)
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
GENERATOR_VERSION = dist_version("plinth-cli")
|
|
22
|
+
except PackageNotFoundError: # running from a bare checkout without install
|
|
23
|
+
GENERATOR_VERSION = "0.0.0+uninstalled"
|
|
24
|
+
|
|
25
|
+
FileMap = dict[str, bytes]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def make_env(blueprints_root: Path) -> Environment:
|
|
29
|
+
return Environment(
|
|
30
|
+
loader=FileSystemLoader(str(blueprints_root)),
|
|
31
|
+
undefined=StrictUndefined,
|
|
32
|
+
autoescape=False,
|
|
33
|
+
keep_trailing_newline=True,
|
|
34
|
+
trim_blocks=True,
|
|
35
|
+
lstrip_blocks=True,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _output_path(rel: str) -> str:
|
|
40
|
+
parts = ["." + p[len(DOT_PREFIX) :] if p.startswith(DOT_PREFIX) else p for p in rel.split("/")]
|
|
41
|
+
out = "/".join(parts)
|
|
42
|
+
return out.removesuffix(TEMPLATE_SUFFIX)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def render_tree(
|
|
46
|
+
env: Environment, blueprints_root: Path, blueprint: str, context: dict, prefix: str
|
|
47
|
+
) -> FileMap:
|
|
48
|
+
base = blueprints_root / blueprint
|
|
49
|
+
if not base.is_dir():
|
|
50
|
+
return {}
|
|
51
|
+
out: FileMap = {}
|
|
52
|
+
for src in sorted(p for p in base.rglob("*") if p.is_file() and "__pycache__" not in p.parts):
|
|
53
|
+
rel = src.relative_to(base).as_posix()
|
|
54
|
+
if rel.endswith(TEMPLATE_SUFFIX):
|
|
55
|
+
content = env.get_template(f"{blueprint}/{rel}").render(**context).encode("utf-8")
|
|
56
|
+
else:
|
|
57
|
+
content = src.read_bytes()
|
|
58
|
+
out[prefix + _output_path(rel)] = content
|
|
59
|
+
return out
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _render_one(env: Environment, template: str, context: dict) -> bytes:
|
|
63
|
+
return env.get_template(template).render(**context).encode("utf-8")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _metadata(repo: RepoPlan, plan: Plan) -> bytes:
|
|
67
|
+
data = {
|
|
68
|
+
"generator": {"name": "plinth", "version": GENERATOR_VERSION},
|
|
69
|
+
"project": plan.config.project.name,
|
|
70
|
+
"repo": {"folder": repo.folder, "role": repo.role},
|
|
71
|
+
}
|
|
72
|
+
return yaml.safe_dump(data, sort_keys=True).encode("utf-8")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def render_plan(plan: Plan, blueprints_root: Path = BLUEPRINTS_DIR) -> FileMap:
|
|
76
|
+
env = make_env(blueprints_root)
|
|
77
|
+
project = plan.config.project
|
|
78
|
+
base_ctx = {
|
|
79
|
+
"config": plan.config,
|
|
80
|
+
"project": project,
|
|
81
|
+
"plan": plan,
|
|
82
|
+
"defaults": defaults,
|
|
83
|
+
"git_token_user": defaults.GIT_TOKEN_USER[project.git_provider.value],
|
|
84
|
+
}
|
|
85
|
+
files: FileMap = {}
|
|
86
|
+
|
|
87
|
+
for repo in plan.repos:
|
|
88
|
+
ctx = {**base_ctx, "repo": repo}
|
|
89
|
+
root = f"{repo.folder}/"
|
|
90
|
+
files.update(render_tree(env, blueprints_root, f"repo/{repo.role}", ctx, root))
|
|
91
|
+
files[root + defaults.REPO_METADATA_FILENAME] = _metadata(repo, plan)
|
|
92
|
+
|
|
93
|
+
if repo.role == "app":
|
|
94
|
+
files.update(
|
|
95
|
+
render_tree(env, blueprints_root, f"ci/{project.git_provider.value}", ctx, root)
|
|
96
|
+
)
|
|
97
|
+
for wl in repo.workloads:
|
|
98
|
+
wctx = {**ctx, "workload": wl}
|
|
99
|
+
for blueprint in ("workload/common", f"workload/{wl.kind.value}"):
|
|
100
|
+
files.update(
|
|
101
|
+
render_tree(env, blueprints_root, blueprint, wctx, root + wl.source_dir)
|
|
102
|
+
)
|
|
103
|
+
else:
|
|
104
|
+
for environment in plan.config.environments:
|
|
105
|
+
ectx = {**ctx, "environment": environment}
|
|
106
|
+
files[f"{root}apps/{environment}.yaml"] = _render_one(
|
|
107
|
+
env, "gitops/applicationset.yaml.j2", ectx
|
|
108
|
+
)
|
|
109
|
+
for app_repo, wl in plan.workloads():
|
|
110
|
+
wctx = {**ectx, "workload": wl, "app_repo": app_repo}
|
|
111
|
+
files[f"{root}environments/{environment}/{wl.name}/values.yaml"] = _render_one(
|
|
112
|
+
env, "gitops/values.yaml.j2", wctx
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
return files
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def is_executable(rel: str) -> bool:
|
|
119
|
+
return rel.endswith(EXECUTABLE_SUFFIXES)
|
plinth/schema.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Project configuration schema (plinth.yaml)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from enum import StrEnum
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
9
|
+
|
|
10
|
+
from plinth import config as defaults
|
|
11
|
+
|
|
12
|
+
NAME_RE = re.compile(r"^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$")
|
|
13
|
+
GIT_BASE_URL_RE = re.compile(r"^https://[^\s/]+(?:/[^\s/]+)+$")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def validate_name(value: str, what: str) -> str:
|
|
17
|
+
if not NAME_RE.fullmatch(value):
|
|
18
|
+
raise ValueError(
|
|
19
|
+
f"{what} '{value}' must be lowercase kebab-case "
|
|
20
|
+
"(start with a letter; only a-z, 0-9, and single hyphens)"
|
|
21
|
+
)
|
|
22
|
+
if not defaults.NAME_MIN_LEN <= len(value) <= defaults.NAME_MAX_LEN:
|
|
23
|
+
raise ValueError(
|
|
24
|
+
f"{what} '{value}' must be {defaults.NAME_MIN_LEN}-{defaults.NAME_MAX_LEN} characters"
|
|
25
|
+
)
|
|
26
|
+
return value
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Architecture(StrEnum):
|
|
30
|
+
MODULAR_MONOLITH = "modular-monolith"
|
|
31
|
+
MICROSERVICES = "microservices"
|
|
32
|
+
HYBRID = "hybrid"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Layout(StrEnum):
|
|
36
|
+
MONOREPO = "monorepo"
|
|
37
|
+
SPLIT_REPO = "split-repo"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class GitProvider(StrEnum):
|
|
41
|
+
GITHUB = "github"
|
|
42
|
+
GITLAB = "gitlab"
|
|
43
|
+
GENERIC = "generic"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class AppKind(StrEnum):
|
|
47
|
+
MODULAR_MONOLITH = "modular-monolith"
|
|
48
|
+
SERVICE = "service"
|
|
49
|
+
WORKER = "worker"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class StrictModel(BaseModel):
|
|
53
|
+
model_config = ConfigDict(extra="forbid", frozen=True, populate_by_name=True)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class ProjectSpec(StrictModel):
|
|
57
|
+
name: str
|
|
58
|
+
architecture: Architecture
|
|
59
|
+
layout: Layout
|
|
60
|
+
git_provider: GitProvider = Field(alias="gitProvider")
|
|
61
|
+
git_base_url: str = Field(alias="gitBaseUrl")
|
|
62
|
+
registry: str
|
|
63
|
+
|
|
64
|
+
@field_validator("name")
|
|
65
|
+
@classmethod
|
|
66
|
+
def _name(cls, v: str) -> str:
|
|
67
|
+
return validate_name(v, "project.name")
|
|
68
|
+
|
|
69
|
+
@field_validator("registry")
|
|
70
|
+
@classmethod
|
|
71
|
+
def _registry(cls, v: str) -> str:
|
|
72
|
+
if "://" in v or v.endswith("/") or not v:
|
|
73
|
+
raise ValueError("registry must be host[/path] without scheme or trailing slash")
|
|
74
|
+
return v
|
|
75
|
+
|
|
76
|
+
@field_validator("git_base_url")
|
|
77
|
+
@classmethod
|
|
78
|
+
def _git_base_url(cls, v: str) -> str:
|
|
79
|
+
if not GIT_BASE_URL_RE.fullmatch(v):
|
|
80
|
+
raise ValueError(
|
|
81
|
+
"gitBaseUrl must look like https://host/org (no trailing slash), "
|
|
82
|
+
"e.g. https://github.com/acme"
|
|
83
|
+
)
|
|
84
|
+
return v
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class ApplicationSpec(StrictModel):
|
|
88
|
+
name: str
|
|
89
|
+
kind: AppKind
|
|
90
|
+
port: int | None = Field(default=None, ge=1, le=65535)
|
|
91
|
+
|
|
92
|
+
@field_validator("name")
|
|
93
|
+
@classmethod
|
|
94
|
+
def _name(cls, v: str) -> str:
|
|
95
|
+
validate_name(v, "application name")
|
|
96
|
+
if v in defaults.RESERVED_APP_NAMES:
|
|
97
|
+
raise ValueError(f"application name '{v}' is reserved")
|
|
98
|
+
return v
|
|
99
|
+
|
|
100
|
+
@model_validator(mode="after")
|
|
101
|
+
def _worker_has_no_port(self) -> ApplicationSpec:
|
|
102
|
+
if self.kind is AppKind.WORKER and self.port is not None:
|
|
103
|
+
raise ValueError(f"application '{self.name}': workers have no HTTP port")
|
|
104
|
+
return self
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def http_port(self) -> int | None:
|
|
108
|
+
if self.kind is AppKind.WORKER:
|
|
109
|
+
return None
|
|
110
|
+
return self.port if self.port is not None else defaults.DEFAULT_PORT
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class ProjectConfig(StrictModel):
|
|
114
|
+
project: ProjectSpec
|
|
115
|
+
applications: list[ApplicationSpec] = Field(min_length=1)
|
|
116
|
+
environments: list[str] = Field(default_factory=lambda: list(defaults.DEFAULT_ENVIRONMENTS))
|
|
117
|
+
|
|
118
|
+
@field_validator("environments")
|
|
119
|
+
@classmethod
|
|
120
|
+
def _envs(cls, v: list[str]) -> list[str]:
|
|
121
|
+
if not v:
|
|
122
|
+
raise ValueError("at least one environment is required")
|
|
123
|
+
for e in v:
|
|
124
|
+
validate_name(e, "environment")
|
|
125
|
+
if len(set(v)) != len(v):
|
|
126
|
+
raise ValueError("environment names must be unique")
|
|
127
|
+
if defaults.RELEASE_ENVIRONMENT not in v:
|
|
128
|
+
raise ValueError(
|
|
129
|
+
f"an environment named '{defaults.RELEASE_ENVIRONMENT}' is required (CI releases to it)"
|
|
130
|
+
)
|
|
131
|
+
return v
|
|
132
|
+
|
|
133
|
+
@model_validator(mode="after")
|
|
134
|
+
def _architecture_rules(self) -> ProjectConfig:
|
|
135
|
+
names = [a.name for a in self.applications]
|
|
136
|
+
dupes = sorted({n for n in names if names.count(n) > 1})
|
|
137
|
+
if dupes:
|
|
138
|
+
raise ValueError(f"duplicate application names: {dupes}")
|
|
139
|
+
|
|
140
|
+
monoliths = [a for a in self.applications if a.kind is AppKind.MODULAR_MONOLITH]
|
|
141
|
+
others = [a for a in self.applications if a.kind is not AppKind.MODULAR_MONOLITH]
|
|
142
|
+
arch = self.project.architecture
|
|
143
|
+
|
|
144
|
+
if arch is Architecture.MODULAR_MONOLITH and (len(monoliths) != 1 or others):
|
|
145
|
+
raise ValueError(
|
|
146
|
+
"architecture 'modular-monolith' requires exactly one application "
|
|
147
|
+
"of kind 'modular-monolith' and no others"
|
|
148
|
+
)
|
|
149
|
+
if arch is Architecture.MICROSERVICES and monoliths:
|
|
150
|
+
raise ValueError(
|
|
151
|
+
"architecture 'microservices' must not contain kind 'modular-monolith'"
|
|
152
|
+
)
|
|
153
|
+
if arch is Architecture.HYBRID and (len(monoliths) != 1 or not others):
|
|
154
|
+
raise ValueError(
|
|
155
|
+
"architecture 'hybrid' requires exactly one 'modular-monolith' "
|
|
156
|
+
"plus at least one 'service' or 'worker'"
|
|
157
|
+
)
|
|
158
|
+
return self
|
plinth/wizard.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Interactive questions for `plinth project`. Produces a config dict only."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import questionary
|
|
6
|
+
from questionary import Choice
|
|
7
|
+
|
|
8
|
+
from plinth import config as defaults
|
|
9
|
+
from plinth.schema import NAME_RE, AppKind, Architecture, GitProvider, Layout
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _name_ok(value: str) -> bool | str:
|
|
13
|
+
if NAME_RE.fullmatch(value) and defaults.NAME_MIN_LEN <= len(value) <= defaults.NAME_MAX_LEN:
|
|
14
|
+
return True
|
|
15
|
+
return (
|
|
16
|
+
f"lowercase kebab-case, {defaults.NAME_MIN_LEN}-{defaults.NAME_MAX_LEN} chars, e.g. atlas"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _port_ok(value: str) -> bool | str:
|
|
21
|
+
return True if value.isdigit() and 0 < int(value) < 65536 else "port must be 1-65535"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _ask_port(question: str) -> int:
|
|
25
|
+
answer = questionary.text(question, default=str(defaults.DEFAULT_PORT), validate=_port_ok)
|
|
26
|
+
return int(answer.unsafe_ask())
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _ask_applications(architecture: str) -> list[dict]:
|
|
30
|
+
apps: list[dict] = []
|
|
31
|
+
if architecture in (Architecture.MODULAR_MONOLITH, Architecture.HYBRID):
|
|
32
|
+
name = questionary.text(
|
|
33
|
+
"Monolith application name:", default="api", validate=_name_ok
|
|
34
|
+
).unsafe_ask()
|
|
35
|
+
apps.append(
|
|
36
|
+
{
|
|
37
|
+
"name": name,
|
|
38
|
+
"kind": AppKind.MODULAR_MONOLITH.value,
|
|
39
|
+
"port": _ask_port("Monolith HTTP port:"),
|
|
40
|
+
}
|
|
41
|
+
)
|
|
42
|
+
if architecture == Architecture.MODULAR_MONOLITH:
|
|
43
|
+
return apps
|
|
44
|
+
|
|
45
|
+
while True:
|
|
46
|
+
name = questionary.text(
|
|
47
|
+
"Service or worker name (leave empty to finish):",
|
|
48
|
+
validate=lambda v: True if v == "" else _name_ok(v),
|
|
49
|
+
).unsafe_ask()
|
|
50
|
+
if name == "":
|
|
51
|
+
if any(a["kind"] != AppKind.MODULAR_MONOLITH for a in apps):
|
|
52
|
+
return apps
|
|
53
|
+
questionary.print("At least one service or worker is required.", style="bold")
|
|
54
|
+
continue
|
|
55
|
+
kind = questionary.select(
|
|
56
|
+
f"Kind of '{name}':",
|
|
57
|
+
choices=[
|
|
58
|
+
Choice("service (HTTP, gets a port and health check)", AppKind.SERVICE.value),
|
|
59
|
+
Choice("worker (no HTTP port)", AppKind.WORKER.value),
|
|
60
|
+
],
|
|
61
|
+
).unsafe_ask()
|
|
62
|
+
app = {"name": name, "kind": kind}
|
|
63
|
+
if kind == AppKind.SERVICE:
|
|
64
|
+
app["port"] = _ask_port("Port:")
|
|
65
|
+
apps.append(app)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def run_wizard() -> dict:
|
|
69
|
+
name = questionary.text("Project name:", validate=_name_ok).unsafe_ask()
|
|
70
|
+
|
|
71
|
+
architecture = questionary.select(
|
|
72
|
+
"Application architecture:",
|
|
73
|
+
choices=[
|
|
74
|
+
Choice(
|
|
75
|
+
"modular-monolith (one process, one image, internal modules)",
|
|
76
|
+
Architecture.MODULAR_MONOLITH.value,
|
|
77
|
+
),
|
|
78
|
+
Choice(
|
|
79
|
+
"microservices (several independently built apps)",
|
|
80
|
+
Architecture.MICROSERVICES.value,
|
|
81
|
+
),
|
|
82
|
+
Choice(
|
|
83
|
+
"hybrid (one monolith plus independent services/workers)",
|
|
84
|
+
Architecture.HYBRID.value,
|
|
85
|
+
),
|
|
86
|
+
],
|
|
87
|
+
).unsafe_ask()
|
|
88
|
+
|
|
89
|
+
layout = questionary.select(
|
|
90
|
+
"Repository layout:",
|
|
91
|
+
choices=[
|
|
92
|
+
Choice("monorepo (all apps in <project>-app)", Layout.MONOREPO.value),
|
|
93
|
+
Choice("split-repo (one repo per app)", Layout.SPLIT_REPO.value),
|
|
94
|
+
],
|
|
95
|
+
).unsafe_ask()
|
|
96
|
+
|
|
97
|
+
provider = questionary.select(
|
|
98
|
+
"Git provider (decides which CI file is generated):",
|
|
99
|
+
choices=[Choice(p.value, p.value) for p in GitProvider],
|
|
100
|
+
).unsafe_ask()
|
|
101
|
+
|
|
102
|
+
git_base_url = questionary.text(
|
|
103
|
+
"Git base URL the repos will live under (https://host/org):",
|
|
104
|
+
default=defaults.DEFAULT_GIT_BASE_URL[provider],
|
|
105
|
+
).unsafe_ask()
|
|
106
|
+
|
|
107
|
+
registry = questionary.text(
|
|
108
|
+
"Container registry (host/path, no scheme):", default=defaults.DEFAULT_REGISTRY
|
|
109
|
+
).unsafe_ask()
|
|
110
|
+
|
|
111
|
+
applications = _ask_applications(architecture)
|
|
112
|
+
|
|
113
|
+
envs = questionary.text(
|
|
114
|
+
f"Environments (comma separated, must include {defaults.RELEASE_ENVIRONMENT}):",
|
|
115
|
+
default=",".join(defaults.DEFAULT_ENVIRONMENTS),
|
|
116
|
+
).unsafe_ask()
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
"project": {
|
|
120
|
+
"name": name,
|
|
121
|
+
"architecture": architecture,
|
|
122
|
+
"layout": layout,
|
|
123
|
+
"gitProvider": provider,
|
|
124
|
+
"gitBaseUrl": git_base_url,
|
|
125
|
+
"registry": registry,
|
|
126
|
+
},
|
|
127
|
+
"applications": applications,
|
|
128
|
+
"environments": [e.strip() for e in envs.split(",") if e.strip()],
|
|
129
|
+
}
|
plinth/write.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Write rendered files to disk and initialise git repositories."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import tempfile
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from plinth.render import FileMap, is_executable
|
|
12
|
+
|
|
13
|
+
INITIAL_COMMIT_MESSAGE = "chore: initial commit"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class OutputExistsError(Exception):
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def write_tree(root: Path, files: FileMap) -> None:
|
|
21
|
+
for rel in sorted(files):
|
|
22
|
+
dest = root / rel
|
|
23
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
24
|
+
dest.write_bytes(files[rel])
|
|
25
|
+
if is_executable(rel):
|
|
26
|
+
dest.chmod(0o755)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def write_project(
|
|
30
|
+
target: Path, files: FileMap, repo_urls: dict[str, str], init_git: bool
|
|
31
|
+
) -> list[str]:
|
|
32
|
+
"""Write a whole project atomically: everything lands in a temp folder that is
|
|
33
|
+
renamed into place only once it is complete. Returns warnings."""
|
|
34
|
+
if target.exists():
|
|
35
|
+
raise OutputExistsError(f"refusing to write into existing path: {target}")
|
|
36
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
37
|
+
tmp = Path(tempfile.mkdtemp(prefix=f".{target.name}.", suffix=".plinth-tmp", dir=target.parent))
|
|
38
|
+
try:
|
|
39
|
+
write_tree(tmp, files)
|
|
40
|
+
warnings = init_repos(tmp, repo_urls) if init_git else []
|
|
41
|
+
if target.exists():
|
|
42
|
+
raise OutputExistsError(f"refusing to write into existing path: {target}")
|
|
43
|
+
os.rename(tmp, target)
|
|
44
|
+
except BaseException:
|
|
45
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
46
|
+
raise
|
|
47
|
+
return warnings
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def init_repos(root: Path, repo_urls: dict[str, str]) -> list[str]:
|
|
51
|
+
if shutil.which("git") is None:
|
|
52
|
+
return ["git not found; repositories were not initialised"]
|
|
53
|
+
warnings = []
|
|
54
|
+
for folder, url in repo_urls.items():
|
|
55
|
+
warning = init_repo(root / folder, url)
|
|
56
|
+
if warning:
|
|
57
|
+
warnings.append(warning)
|
|
58
|
+
return warnings
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def init_repo(path: Path, url: str) -> str | None:
|
|
62
|
+
def git(*args: str) -> subprocess.CompletedProcess:
|
|
63
|
+
return subprocess.run(["git", *args], cwd=path, capture_output=True, text=True, check=False)
|
|
64
|
+
|
|
65
|
+
for args in (("init", "-q", "-b", "main"), ("remote", "add", "origin", url), ("add", "-A")):
|
|
66
|
+
result = git(*args)
|
|
67
|
+
if result.returncode != 0:
|
|
68
|
+
return f"{path.name}: git {args[0]} failed: {result.stderr.strip()}"
|
|
69
|
+
result = git("commit", "-q", "-m", INITIAL_COMMIT_MESSAGE)
|
|
70
|
+
if result.returncode != 0:
|
|
71
|
+
return f"{path.name}: initialised but not committed: {result.stderr.strip()}"
|
|
72
|
+
return None
|