quoll-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 (191) hide show
  1. quoll/__init__.py +0 -0
  2. quoll/cd/__init__.py +1 -0
  3. quoll/cd/config.py +75 -0
  4. quoll/ci/__init__.py +0 -0
  5. quoll/ci/config.py +213 -0
  6. quoll/ci/custom.py +32 -0
  7. quoll/ci/detect.py +139 -0
  8. quoll/ci/steps.py +85 -0
  9. quoll/ci/tools.py +104 -0
  10. quoll/commands/__init__.py +0 -0
  11. quoll/commands/_common.py +145 -0
  12. quoll/commands/_workspace.py +83 -0
  13. quoll/commands/add.py +104 -0
  14. quoll/commands/add_secret.py +75 -0
  15. quoll/commands/cd.py +214 -0
  16. quoll/commands/ci.py +521 -0
  17. quoll/commands/docker.py +244 -0
  18. quoll/commands/k8s.py +211 -0
  19. quoll/commands/new.py +337 -0
  20. quoll/commands/remove.py +79 -0
  21. quoll/config/__init__.py +0 -0
  22. quoll/config/quoll_toml.py +196 -0
  23. quoll/config/settings.py +22 -0
  24. quoll/config/store.py +39 -0
  25. quoll/console.py +3 -0
  26. quoll/docker/__init__.py +1 -0
  27. quoll/docker/config.py +64 -0
  28. quoll/docker/detect.py +41 -0
  29. quoll/docker/render.py +162 -0
  30. quoll/files.py +64 -0
  31. quoll/k8s/__init__.py +0 -0
  32. quoll/k8s/chart/templates/_helpers.tpl +38 -0
  33. quoll/k8s/chart/templates/deployment.yaml +93 -0
  34. quoll/k8s/chart/templates/hpa.yaml +24 -0
  35. quoll/k8s/chart/templates/ingress.yaml +35 -0
  36. quoll/k8s/chart/templates/pdb.yaml +16 -0
  37. quoll/k8s/chart/templates/service.yaml +18 -0
  38. quoll/k8s/chart/templates/serviceaccount.yaml +10 -0
  39. quoll/k8s/ci.py +39 -0
  40. quoll/k8s/config.py +97 -0
  41. quoll/k8s/deploy.py +22 -0
  42. quoll/k8s/detect.py +32 -0
  43. quoll/k8s/render.py +94 -0
  44. quoll/languages/__init__.py +121 -0
  45. quoll/languages/base.py +350 -0
  46. quoll/languages/cpp/Dockerfile.j2 +37 -0
  47. quoll/languages/cpp/__init__.py +17 -0
  48. quoll/languages/cpp/ci.py +192 -0
  49. quoll/languages/cpp/detect.py +84 -0
  50. quoll/languages/cpp/docker.py +47 -0
  51. quoll/languages/dotnet/Dockerfile.j2 +21 -0
  52. quoll/languages/dotnet/__init__.py +17 -0
  53. quoll/languages/dotnet/ci.py +88 -0
  54. quoll/languages/dotnet/detect.py +53 -0
  55. quoll/languages/dotnet/docker.py +47 -0
  56. quoll/languages/go/Dockerfile.j2 +25 -0
  57. quoll/languages/go/__init__.py +17 -0
  58. quoll/languages/go/ci.py +126 -0
  59. quoll/languages/go/detect.py +30 -0
  60. quoll/languages/go/docker.py +40 -0
  61. quoll/languages/java/Dockerfile.j2 +28 -0
  62. quoll/languages/java/__init__.py +20 -0
  63. quoll/languages/java/ci.py +163 -0
  64. quoll/languages/java/detect.py +77 -0
  65. quoll/languages/java/docker.py +45 -0
  66. quoll/languages/java/frameworks/__init__.py +1 -0
  67. quoll/languages/java/frameworks/spring_boot/Dockerfile.j2 +18 -0
  68. quoll/languages/java/frameworks/spring_boot/__init__.py +10 -0
  69. quoll/languages/java/frameworks/spring_boot/detect.py +9 -0
  70. quoll/languages/java/frameworks/spring_boot/docker.py +19 -0
  71. quoll/languages/java/frameworks/spring_boot/services.py +24 -0
  72. quoll/languages/node/Dockerfile.j2 +98 -0
  73. quoll/languages/node/__init__.py +29 -0
  74. quoll/languages/node/ci.py +200 -0
  75. quoll/languages/node/detect.py +164 -0
  76. quoll/languages/node/docker.py +141 -0
  77. quoll/languages/node/frameworks/__init__.py +1 -0
  78. quoll/languages/node/frameworks/angular/__init__.py +18 -0
  79. quoll/languages/node/frameworks/angular/ci.py +34 -0
  80. quoll/languages/node/frameworks/angular/detect.py +20 -0
  81. quoll/languages/node/frameworks/angular/docker.py +31 -0
  82. quoll/languages/node/frameworks/angular/starter.py +74 -0
  83. quoll/languages/node/frameworks/astro/__init__.py +10 -0
  84. quoll/languages/node/frameworks/astro/ci.py +17 -0
  85. quoll/languages/node/frameworks/astro/detect.py +14 -0
  86. quoll/languages/node/frameworks/astro/docker.py +17 -0
  87. quoll/languages/node/frameworks/nestjs/__init__.py +7 -0
  88. quoll/languages/node/frameworks/nestjs/detect.py +9 -0
  89. quoll/languages/node/frameworks/nestjs/docker.py +10 -0
  90. quoll/languages/node/frameworks/nextjs/Dockerfile.j2 +14 -0
  91. quoll/languages/node/frameworks/nextjs/__init__.py +8 -0
  92. quoll/languages/node/frameworks/nextjs/ci.py +29 -0
  93. quoll/languages/node/frameworks/nextjs/detect.py +13 -0
  94. quoll/languages/node/frameworks/nextjs/docker.py +41 -0
  95. quoll/languages/node/frameworks/nuxt/__init__.py +10 -0
  96. quoll/languages/node/frameworks/nuxt/ci.py +17 -0
  97. quoll/languages/node/frameworks/nuxt/detect.py +17 -0
  98. quoll/languages/node/frameworks/nuxt/docker.py +24 -0
  99. quoll/languages/node/frameworks/sveltekit/__init__.py +15 -0
  100. quoll/languages/node/frameworks/sveltekit/ci.py +15 -0
  101. quoll/languages/node/frameworks/sveltekit/detect.py +18 -0
  102. quoll/languages/node/frameworks/sveltekit/docker.py +18 -0
  103. quoll/languages/node/starter.py +42 -0
  104. quoll/languages/node/templates/express/README.md +11 -0
  105. quoll/languages/node/templates/express/env.example +2 -0
  106. quoll/languages/node/templates/express/eslint.config.js +15 -0
  107. quoll/languages/node/templates/express/gitignore +4 -0
  108. quoll/languages/node/templates/express/nvmrc +1 -0
  109. quoll/languages/node/templates/express/package.json +16 -0
  110. quoll/languages/node/templates/express/prettierignore +6 -0
  111. quoll/languages/node/templates/express/src/app.ts +27 -0
  112. quoll/languages/node/templates/express/src/server.ts +11 -0
  113. quoll/languages/node/templates/express/test/app.test.ts +29 -0
  114. quoll/languages/node/templates/express/tsconfig.build.json +8 -0
  115. quoll/languages/node/templates/express/tsconfig.json +14 -0
  116. quoll/languages/node/templates/express/vitest.config.ts +12 -0
  117. quoll/languages/php/Dockerfile.j2 +40 -0
  118. quoll/languages/php/__init__.py +19 -0
  119. quoll/languages/php/ci.py +147 -0
  120. quoll/languages/php/detect.py +75 -0
  121. quoll/languages/php/docker.py +59 -0
  122. quoll/languages/php/frameworks/__init__.py +1 -0
  123. quoll/languages/php/frameworks/laravel/__init__.py +17 -0
  124. quoll/languages/php/frameworks/laravel/ci.py +24 -0
  125. quoll/languages/php/frameworks/laravel/detect.py +13 -0
  126. quoll/languages/php/frameworks/laravel/docker.py +29 -0
  127. quoll/languages/php/frameworks/laravel/services.py +25 -0
  128. quoll/languages/php/frameworks/symfony/__init__.py +15 -0
  129. quoll/languages/php/frameworks/symfony/ci.py +22 -0
  130. quoll/languages/php/frameworks/symfony/detect.py +17 -0
  131. quoll/languages/php/frameworks/symfony/docker.py +25 -0
  132. quoll/languages/python/Dockerfile.j2 +61 -0
  133. quoll/languages/python/__init__.py +19 -0
  134. quoll/languages/python/ci.py +157 -0
  135. quoll/languages/python/detect.py +175 -0
  136. quoll/languages/python/docker.py +80 -0
  137. quoll/languages/python/frameworks/__init__.py +1 -0
  138. quoll/languages/python/frameworks/django/__init__.py +16 -0
  139. quoll/languages/python/frameworks/django/ci.py +43 -0
  140. quoll/languages/python/frameworks/django/detect.py +17 -0
  141. quoll/languages/python/frameworks/django/docker.py +20 -0
  142. quoll/languages/python/frameworks/fastapi/__init__.py +7 -0
  143. quoll/languages/python/frameworks/fastapi/detect.py +9 -0
  144. quoll/languages/python/frameworks/fastapi/docker.py +21 -0
  145. quoll/languages/python/frameworks/flask/__init__.py +7 -0
  146. quoll/languages/python/frameworks/flask/detect.py +9 -0
  147. quoll/languages/python/frameworks/flask/docker.py +22 -0
  148. quoll/languages/ruby/Dockerfile.j2 +45 -0
  149. quoll/languages/ruby/__init__.py +19 -0
  150. quoll/languages/ruby/ci.py +110 -0
  151. quoll/languages/ruby/detect.py +72 -0
  152. quoll/languages/ruby/docker.py +63 -0
  153. quoll/languages/ruby/frameworks/__init__.py +1 -0
  154. quoll/languages/ruby/frameworks/rails/Dockerfile.j2 +16 -0
  155. quoll/languages/ruby/frameworks/rails/__init__.py +18 -0
  156. quoll/languages/ruby/frameworks/rails/ci.py +26 -0
  157. quoll/languages/ruby/frameworks/rails/detect.py +15 -0
  158. quoll/languages/ruby/frameworks/rails/docker.py +62 -0
  159. quoll/languages/ruby/frameworks/rails/services.py +10 -0
  160. quoll/languages/rust/Dockerfile.j2 +23 -0
  161. quoll/languages/rust/__init__.py +17 -0
  162. quoll/languages/rust/ci.py +125 -0
  163. quoll/languages/rust/detect.py +46 -0
  164. quoll/languages/rust/docker.py +38 -0
  165. quoll/main.py +50 -0
  166. quoll/platforms/__init__.py +62 -0
  167. quoll/platforms/base.py +103 -0
  168. quoll/platforms/github/__init__.py +4 -0
  169. quoll/platforms/github/actions.py +78 -0
  170. quoll/platforms/github/api.py +124 -0
  171. quoll/platforms/github/cd.py +265 -0
  172. quoll/platforms/github/ci.py +497 -0
  173. quoll/platforms/github/dependabot.yml.j2 +49 -0
  174. quoll/platforms/github/platform.py +52 -0
  175. quoll/platforms/github/workflow.py +74 -0
  176. quoll/platforms/gitlab/__init__.py +4 -0
  177. quoll/platforms/gitlab/api.py +158 -0
  178. quoll/platforms/gitlab/cd.py +226 -0
  179. quoll/platforms/gitlab/ci.py +400 -0
  180. quoll/platforms/gitlab/pipeline.py +136 -0
  181. quoll/platforms/gitlab/platform.py +54 -0
  182. quoll/project.py +60 -0
  183. quoll/services.py +117 -0
  184. quoll/starters.py +117 -0
  185. quoll/workspace.py +232 -0
  186. quoll/yaml_dump.py +137 -0
  187. quoll_cli-0.2.0.dist-info/METADATA +641 -0
  188. quoll_cli-0.2.0.dist-info/RECORD +191 -0
  189. quoll_cli-0.2.0.dist-info/WHEEL +4 -0
  190. quoll_cli-0.2.0.dist-info/entry_points.txt +2 -0
  191. quoll_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
quoll/console.py ADDED
@@ -0,0 +1,3 @@
1
+ from rich.console import Console
2
+
3
+ console = Console()
@@ -0,0 +1 @@
1
+ """Dockerfile generation. Images are provided by the language and framework plugins (see quoll/languages)."""
quoll/docker/config.py ADDED
@@ -0,0 +1,64 @@
1
+ """The [docker] section of quoll.toml — the single source of truth for the generated Dockerfile."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ from pathlib import Path
6
+ from typing import ClassVar, Dict, Tuple
7
+
8
+ from pydantic import Field, model_validator
9
+
10
+ from quoll import languages
11
+ from quoll.config.quoll_toml import CONFIG_FILE, ConfigError, ProjectConfig, load_section, save_section
12
+ from quoll.languages.base import DockerImage
13
+
14
+ __all__ = ["CONFIG_FILE", "ConfigError", "DockerConfig", "load_config", "save_config"]
15
+
16
+
17
+ class DockerConfig(ProjectConfig):
18
+ # Runtime version of the base image, e.g. "3.13", "24", "1.25", "21".
19
+ version: str
20
+ port: int = Field(default=8000, ge=1, le=65535)
21
+ # What the container runs. Not needed for static sites or images that ship their own server.
22
+ command: str = ""
23
+ # Run in the build stage, e.g. "npm run build". Empty = nothing to build.
24
+ build_command: str = ""
25
+ # Build output the image ships: the site when `static`, otherwise a self-contained server bundle.
26
+ output_dir: str = ""
27
+ # Serve `output_dir` with nginx instead of running a server.
28
+ static: bool = False
29
+ # Static sites: path prefixes nginx forwards to an API, each to the URL held by an environment variable, e.g.
30
+ # {"/api/" = "BACK_URL"} (a workspace's compose.yaml sets <NAME>_URL for each of its projects).
31
+ proxy: Dict[str, str] = {}
32
+
33
+ omit_when_empty: ClassVar[Tuple[str, ...]] = ("proxy",)
34
+
35
+ @model_validator(mode="after")
36
+ def _check_docker(self) -> "DockerConfig":
37
+ if self.static and not self.output_dir:
38
+ raise ValueError("output_dir is required when static is true")
39
+ if self.proxy and not self.static:
40
+ raise ValueError("proxy is for static sites (static = true), served by nginx")
41
+ for path, variable in self.proxy.items():
42
+ if not path.startswith("/") or not path.endswith("/"):
43
+ raise ValueError(f"proxy: {path!r} should start and end with /, e.g. /api/")
44
+ if not re.fullmatch(r"[A-Z_][A-Z0-9_]*", variable):
45
+ raise ValueError(f"proxy: {variable!r} should name an environment variable, e.g. BACK_URL")
46
+ if not self.static and not self.command and self.image.requires_command:
47
+ raise ValueError(f"command is required: what should the {self.target} container run?")
48
+ return self
49
+
50
+ @property
51
+ def image(self) -> DockerImage:
52
+ image = languages.docker_image(self.stack, self.framework)
53
+ if image is None:
54
+ raise ValueError(f"quoll has no Docker image for {self.target} yet")
55
+ return image
56
+
57
+
58
+ def load_config(project_dir: Path) -> DockerConfig:
59
+ return load_section(project_dir, "docker", DockerConfig)
60
+
61
+
62
+ def save_config(project_dir: Path, config: DockerConfig) -> Path:
63
+ """Write [project] and [docker], preserving any other section already in quoll.toml."""
64
+ return save_section(project_dir, "docker", config)
quoll/docker/detect.py ADDED
@@ -0,0 +1,41 @@
1
+ """Infer the [docker] section from the project, through the language or framework image."""
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+ from typing import List, Tuple
6
+
7
+ from quoll import languages
8
+ from quoll.ci.detect import Detection
9
+ from quoll.docker.config import DockerConfig
10
+ from quoll.languages.base import DockerDefaults
11
+ from quoll.project import Project
12
+
13
+
14
+ def image_defaults(project_dir: Path, det: Detection) -> DockerDefaults:
15
+ if det.stack is None:
16
+ raise ValueError("cannot build a Docker config without a detected stack")
17
+ image = languages.docker_image(det.stack, det.framework)
18
+ if image is None:
19
+ raise ValueError(f"quoll has no Docker image for {det.stack} yet")
20
+ return image.defaults(Project(project_dir), det)
21
+
22
+
23
+ def default_config(det: Detection, defaults: DockerDefaults) -> Tuple[DockerConfig, List[str]]:
24
+ """The [docker] config (validated) and the warnings to show."""
25
+ cfg = DockerConfig.model_validate(
26
+ {
27
+ "platform": det.platform,
28
+ "stack": det.stack,
29
+ "framework": det.framework,
30
+ "package_manager": det.package_manager,
31
+ "package_manager_version": det.package_manager_version,
32
+ "services": det.services,
33
+ "version": det.version,
34
+ "port": defaults.port,
35
+ "command": defaults.command,
36
+ "build_command": defaults.build_command,
37
+ "output_dir": defaults.output_dir,
38
+ "static": defaults.static,
39
+ }
40
+ )
41
+ return cfg, defaults.warnings
quoll/docker/render.py ADDED
@@ -0,0 +1,162 @@
1
+ """Turn a DockerConfig into a Dockerfile and a .dockerignore."""
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+ from typing import Any, Dict, List, Tuple
6
+
7
+ from quoll.docker.config import DockerConfig
8
+ from quoll.files import PlannedFile, header, planned
9
+ from quoll.project import Project
10
+ from quoll.services import DATABASE, SERVICES
11
+ from quoll.yaml_dump import dump
12
+
13
+ DOCKERFILE = Path("Dockerfile")
14
+ DOCKERIGNORE = Path(".dockerignore")
15
+ COMPOSE = Path("compose.yaml")
16
+ # A compose file under another name is the project's own: quoll doesn't add a second one.
17
+ _OTHER_COMPOSE_FILES = ("compose.yml", "docker-compose.yml", "docker-compose.yaml")
18
+
19
+ # BuildKit parser directive: must be the very first line.
20
+ _SYNTAX = "# syntax=docker/dockerfile:1\n"
21
+
22
+ # Kept out of every build context: VCS, CI, editors, the Docker files themselves.
23
+ _COMMON_IGNORE = [
24
+ ".git",
25
+ ".github",
26
+ ".gitignore",
27
+ ".dockerignore",
28
+ "Dockerfile",
29
+ "compose*.y*ml",
30
+ "docker-compose*.y*ml",
31
+ ".idea",
32
+ ".vscode",
33
+ "**/.DS_Store",
34
+ ]
35
+
36
+
37
+ def render_dockerfile(project_dir: Path, cfg: DockerConfig) -> str:
38
+ return _SYNTAX + header("docker") + "\n" + cfg.image.render(Project(project_dir), cfg)
39
+
40
+
41
+ def render_dockerignore(cfg: DockerConfig) -> str:
42
+ return header("docker") + "\n".join([*_COMMON_IGNORE, *cfg.image.dockerignore(cfg)]) + "\n"
43
+
44
+
45
+ def render_compose(cfg: DockerConfig) -> str:
46
+ """The app built from the Dockerfile, plus its services with healthchecks and persistent volumes."""
47
+ app: Dict[str, Any] = {"build": ".", "ports": [f"{cfg.port}:{cfg.port}"]}
48
+ # Secrets (APP_KEY, SECRET_KEY_BASE...) stay in an untracked .env.
49
+ app["env_file"] = [{"path": ".env", "required": False}]
50
+ env = cfg.service_environment(lambda service: service)
51
+ if env:
52
+ app["environment"] = env
53
+ if cfg.services:
54
+ app["depends_on"] = {name: {"condition": "service_healthy"} for name in cfg.services}
55
+
56
+ doc: Dict[str, Any] = {"services": {"app": app}}
57
+ for name in cfg.services:
58
+ service = SERVICES[name]
59
+ container: Dict[str, Any] = {"image": service.image}
60
+ if service.env:
61
+ container["environment"] = service.env
62
+ container["volumes"] = [f"{name}-data:{service.data_dir}"]
63
+ container["healthcheck"] = {"test": ["CMD-SHELL", service.health], "interval": "5s", "retries": 20}
64
+ doc["services"][name] = container
65
+ if cfg.services:
66
+ doc["volumes"] = {f"{name}-data": None for name in cfg.services}
67
+ return header("docker") + "\n" + dump(doc, spaced=("services",))
68
+
69
+
70
+ # Creates the missing databases of a workspace's projects in their shared instance, at every `up`: a project added
71
+ # later gets its own even though the volume already exists (the image's init scripts only run on an empty one).
72
+ _CREATE_DATABASES = {
73
+ "postgres": (
74
+ {"PGHOST": "postgres", "PGUSER": DATABASE, "PGPASSWORD": DATABASE, "PGDATABASE": DATABASE},
75
+ """psql -tAc "SELECT 1 FROM pg_database WHERE datname = '{db}'" | grep -q 1 """
76
+ """|| psql -c 'CREATE DATABASE {db}'""",
77
+ ),
78
+ "mysql": ({}, 'mysql -h mysql -uroot -proot -e "CREATE DATABASE IF NOT EXISTS {db}"'),
79
+ }
80
+ _CREATE_SCRIPT = "/quoll/create-databases.sh"
81
+
82
+
83
+ def database_name(member: str) -> str:
84
+ return member.replace("-", "_")
85
+
86
+
87
+ def render_workspace_compose(members: List[Tuple[str, str, DockerConfig]]) -> str:
88
+ """compose.yaml at a workspace's root: every project (name, directory, [docker]) on one network, each reaching
89
+ the others at http://<name>:<port> (<NAME>_URL), and one instance of each backing service with a database per
90
+ project."""
91
+ doc: Dict[str, Any] = {"services": {}}
92
+ used_ports: set = set()
93
+ backing = [s for s in SERVICES if any(s in cfg.services for _, _, cfg in members)]
94
+ for name, directory, cfg in members:
95
+ port = cfg.port
96
+ while port in used_ports: # two projects listening on 3000: the second gets 3001 on the host
97
+ port += 1
98
+ used_ports.add(port)
99
+ app: Dict[str, Any] = {"build": f"./{directory}", "ports": [f"{port}:{cfg.port}"]}
100
+ app["env_file"] = [{"path": f"./{directory}/.env", "required": False}]
101
+ env = cfg.service_environment(lambda service: service, database=database_name(name))
102
+ for other, _, other_cfg in members:
103
+ if other != name:
104
+ env[f"{other.upper().replace('-', '_')}_URL"] = f"http://{other}:{other_cfg.port}"
105
+ if env:
106
+ app["environment"] = env
107
+ depends_on: Dict[str, Any] = {s: {"condition": "service_healthy"} for s in cfg.services}
108
+ # nginx resolves the projects it forwards to when it starts.
109
+ for other, _, _ in members:
110
+ if f"{other.upper().replace('-', '_')}_URL" in cfg.proxy.values():
111
+ depends_on[other] = {"condition": "service_started"}
112
+ if depends_on:
113
+ app["depends_on"] = depends_on
114
+ doc["services"][name] = app
115
+
116
+ configs: Dict[str, Any] = {}
117
+ for service_name in backing:
118
+ service = SERVICES[service_name]
119
+ container: Dict[str, Any] = {"image": service.image}
120
+ if service.env:
121
+ container["environment"] = service.env
122
+ container["volumes"] = [f"{service_name}-data:{service.data_dir}"]
123
+ container["healthcheck"] = {"test": ["CMD-SHELL", service.health], "interval": "5s", "retries": 20}
124
+ doc["services"][service_name] = container
125
+ if service_name in _CREATE_DATABASES:
126
+ env, statement = _CREATE_DATABASES[service_name]
127
+ users = [n for n, _, cfg in members if service_name in cfg.services]
128
+ create: Dict[str, Any] = {"image": service.image}
129
+ if env:
130
+ create["environment"] = env
131
+ script = "\n".join(statement.format(db=database_name(n)) for n in users)
132
+ configs[f"{service_name}-databases"] = {"content": script + "\n"}
133
+ create["configs"] = [{"source": f"{service_name}-databases", "target": _CREATE_SCRIPT}]
134
+ create["command"] = ["sh", "-e", _CREATE_SCRIPT]
135
+ create["depends_on"] = {service_name: {"condition": "service_healthy"}}
136
+ doc["services"][f"{service_name}-databases"] = create
137
+ for n in users:
138
+ doc["services"][n]["depends_on"][f"{service_name}-databases"] = {
139
+ "condition": "service_completed_successfully"
140
+ }
141
+ if backing:
142
+ doc["volumes"] = {f"{name}-data": None for name in backing}
143
+ if configs:
144
+ doc["configs"] = configs
145
+ return header("docker") + "\n" + dump(doc, spaced=("services",))
146
+
147
+
148
+ def plan_workspace(root: Path, members: List[Tuple[str, str, DockerConfig]]) -> List[PlannedFile]:
149
+ """The workspace's compose.yaml (none when the root has a compose file of its own)."""
150
+ if any((root / name).exists() for name in _OTHER_COMPOSE_FILES):
151
+ return []
152
+ return [planned(root, COMPOSE, render_workspace_compose(members))]
153
+
154
+
155
+ def plan(project_dir: Path, cfg: DockerConfig) -> List[PlannedFile]:
156
+ files = [
157
+ planned(project_dir, DOCKERFILE, render_dockerfile(project_dir, cfg)),
158
+ planned(project_dir, DOCKERIGNORE, render_dockerignore(cfg)),
159
+ ]
160
+ if not any((project_dir / name).exists() for name in _OTHER_COMPOSE_FILES):
161
+ files.append(planned(project_dir, COMPOSE, render_compose(cfg)))
162
+ return files
quoll/files.py ADDED
@@ -0,0 +1,64 @@
1
+ """Files quoll generates: planned first (content + what is on disk), then written after confirmation."""
2
+ from __future__ import annotations
3
+
4
+ import difflib
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import List, Optional
8
+
9
+ # First comment of every generated file; a file without it was written by hand and is never overwritten silently.
10
+ MARKER = "# Generated by quoll"
11
+ HEADER = f"""{MARKER} — https://github.com/theo-mrn/Quoll
12
+ # Do not edit by hand: change quoll.toml, then run `quoll {{command}} update`.
13
+ """
14
+
15
+
16
+ @dataclass
17
+ class PlannedFile:
18
+ path: Path
19
+ content: str
20
+ existing: Optional[str]
21
+ # Written once for the project to own (e.g. Dependabot's config): `check` ignores it.
22
+ create_only: bool = False
23
+
24
+ @property
25
+ def changed(self) -> bool:
26
+ return self.existing != self.content
27
+
28
+ @property
29
+ def managed(self) -> bool:
30
+ """True when the file doesn't exist yet or was generated by quoll."""
31
+ return self.existing is None or MARKER in self.existing
32
+
33
+ def diff(self) -> str:
34
+ return "".join(
35
+ difflib.unified_diff(
36
+ (self.existing or "").splitlines(keepends=True),
37
+ self.content.splitlines(keepends=True),
38
+ fromfile=f"a/{self.path}",
39
+ tofile=f"b/{self.path}",
40
+ )
41
+ )
42
+
43
+
44
+ def header(command: str) -> str:
45
+ return HEADER.format(command=command)
46
+
47
+
48
+ def planned(project_dir: Path, rel: Path, content: str, create_only: bool = False) -> PlannedFile:
49
+ target = project_dir / rel
50
+ return PlannedFile(rel, content, target.read_text() if target.exists() else None, create_only)
51
+
52
+
53
+ def eject(text: str, command: str) -> str:
54
+ """`text` without quoll's header, so that quoll treats the file as hand-written from now on."""
55
+ lines = text.splitlines(keepends=True)
56
+ body = [line for line in lines if not line.startswith((MARKER, "# Do not edit by hand"))]
57
+ return f"# Maintained by hand (ejected from `quoll {command}`).\n" + "".join(body)
58
+
59
+
60
+ def write(project_dir: Path, files: List[PlannedFile]) -> None:
61
+ for f in files:
62
+ target = project_dir / f.path
63
+ target.parent.mkdir(parents=True, exist_ok=True)
64
+ target.write_text(f.content)
quoll/k8s/__init__.py ADDED
File without changes
@@ -0,0 +1,38 @@
1
+ {{/*
2
+ # Generated by quoll — https://github.com/theo-mrn/Quoll
3
+ # Do not edit by hand: change quoll.toml, then run `quoll k8s update`.
4
+ */}}
5
+
6
+ {{- define "app.labels" -}}
7
+ {{ include "app.selectorLabels" . }}
8
+ app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
9
+ app.kubernetes.io/managed-by: {{ .Release.Service }}
10
+ helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version }}
11
+ {{- end }}
12
+
13
+ {{- define "app.selectorLabels" -}}
14
+ app.kubernetes.io/name: {{ .Chart.Name }}
15
+ app.kubernetes.io/instance: {{ .Release.Name }}
16
+ {{- end }}
17
+
18
+ {{/* The image by digest when the deployment gives one (what was scanned and signed), else by tag. */}}
19
+ {{- define "app.image" -}}
20
+ {{- $repository := required "image.repository is required" .Values.image.repository -}}
21
+ {{- if .Values.image.digest -}}
22
+ {{ $repository }}@{{ .Values.image.digest }}
23
+ {{- else -}}
24
+ {{ $repository }}:{{ .Values.image.tag }}
25
+ {{- end -}}
26
+ {{- end }}
27
+
28
+ {{/* HTTP on healthPath when the app has one, a TCP connection to its port otherwise. */}}
29
+ {{- define "app.probe" -}}
30
+ {{- if .Values.healthPath -}}
31
+ httpGet:
32
+ path: {{ .Values.healthPath }}
33
+ port: http
34
+ {{- else -}}
35
+ tcpSocket:
36
+ port: http
37
+ {{- end -}}
38
+ {{- end }}
@@ -0,0 +1,93 @@
1
+ # Generated by quoll — https://github.com/theo-mrn/Quoll
2
+ # Do not edit by hand: change quoll.toml, then run `quoll k8s update`.
3
+ apiVersion: apps/v1
4
+ kind: Deployment
5
+ metadata:
6
+ name: {{ .Release.Name }}
7
+ labels:
8
+ {{- include "app.labels" . | nindent 4 }}
9
+ spec:
10
+ {{- if not .Values.autoscaling.enabled }}
11
+ replicas: {{ .Values.replicaCount }}
12
+ {{- end }}
13
+ revisionHistoryLimit: 5
14
+ # A new version takes traffic once ready; the old one stays until then.
15
+ strategy:
16
+ type: RollingUpdate
17
+ rollingUpdate:
18
+ maxUnavailable: 0
19
+ maxSurge: 1
20
+ selector:
21
+ matchLabels:
22
+ {{- include "app.selectorLabels" . | nindent 6 }}
23
+ template:
24
+ metadata:
25
+ labels:
26
+ {{- include "app.labels" . | nindent 8 }}
27
+ spec:
28
+ serviceAccountName: {{ .Release.Name }}
29
+ automountServiceAccountToken: false
30
+ {{- with .Values.imagePullSecrets }}
31
+ imagePullSecrets:
32
+ {{- range . }}
33
+ - name: {{ . }}
34
+ {{- end }}
35
+ {{- end }}
36
+ securityContext:
37
+ runAsNonRoot: true
38
+ seccompProfile:
39
+ type: RuntimeDefault
40
+ containers:
41
+ - name: app
42
+ image: {{ include "app.image" . }}
43
+ imagePullPolicy: {{ .Values.image.pullPolicy }}
44
+ ports:
45
+ - name: http
46
+ containerPort: {{ .Values.port }}
47
+ protocol: TCP
48
+ {{- with .Values.env }}
49
+ env:
50
+ {{- range $name, $value := . }}
51
+ - name: {{ $name }}
52
+ value: {{ $value | quote }}
53
+ {{- end }}
54
+ {{- end }}
55
+ {{- if .Values.envFromSecret }}
56
+ envFrom:
57
+ # The app's secrets (DATABASE_URL...): kubectl create secret generic {{ .Values.envFromSecret }} --from-env-file=...
58
+ - secretRef:
59
+ name: {{ .Values.envFromSecret }}
60
+ optional: true
61
+ {{- end }}
62
+ startupProbe:
63
+ {{- include "app.probe" . | nindent 12 }}
64
+ periodSeconds: 2
65
+ failureThreshold: 60
66
+ readinessProbe:
67
+ {{- include "app.probe" . | nindent 12 }}
68
+ periodSeconds: 5
69
+ livenessProbe:
70
+ {{- include "app.probe" . | nindent 12 }}
71
+ periodSeconds: 10
72
+ failureThreshold: 3
73
+ resources:
74
+ {{- toYaml .Values.resources | nindent 12 }}
75
+ securityContext:
76
+ allowPrivilegeEscalation: false
77
+ readOnlyRootFilesystem: {{ .Values.readOnlyRootFilesystem }}
78
+ capabilities:
79
+ drop: [ALL]
80
+ {{- if and .Values.readOnlyRootFilesystem .Values.writablePaths }}
81
+ volumeMounts:
82
+ {{- range $i, $path := .Values.writablePaths }}
83
+ - name: writable-{{ $i }}
84
+ mountPath: {{ $path }}
85
+ {{- end }}
86
+ {{- end }}
87
+ {{- if and .Values.readOnlyRootFilesystem .Values.writablePaths }}
88
+ volumes:
89
+ {{- range $i, $path := .Values.writablePaths }}
90
+ - name: writable-{{ $i }}
91
+ emptyDir: {}
92
+ {{- end }}
93
+ {{- end }}
@@ -0,0 +1,24 @@
1
+ # Generated by quoll — https://github.com/theo-mrn/Quoll
2
+ # Do not edit by hand: change quoll.toml, then run `quoll k8s update`.
3
+ {{- if .Values.autoscaling.enabled }}
4
+ apiVersion: autoscaling/v2
5
+ kind: HorizontalPodAutoscaler
6
+ metadata:
7
+ name: {{ .Release.Name }}
8
+ labels:
9
+ {{- include "app.labels" . | nindent 4 }}
10
+ spec:
11
+ scaleTargetRef:
12
+ apiVersion: apps/v1
13
+ kind: Deployment
14
+ name: {{ .Release.Name }}
15
+ minReplicas: {{ .Values.autoscaling.minReplicas }}
16
+ maxReplicas: {{ .Values.autoscaling.maxReplicas }}
17
+ metrics:
18
+ - type: Resource
19
+ resource:
20
+ name: cpu
21
+ target:
22
+ type: Utilization
23
+ averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}
24
+ {{- end }}
@@ -0,0 +1,35 @@
1
+ # Generated by quoll — https://github.com/theo-mrn/Quoll
2
+ # Do not edit by hand: change quoll.toml, then run `quoll k8s update`.
3
+ {{- if .Values.ingress.enabled }}
4
+ apiVersion: networking.k8s.io/v1
5
+ kind: Ingress
6
+ metadata:
7
+ name: {{ .Release.Name }}
8
+ labels:
9
+ {{- include "app.labels" . | nindent 4 }}
10
+ {{- with .Values.ingress.annotations }}
11
+ annotations:
12
+ {{- toYaml . | nindent 4 }}
13
+ {{- end }}
14
+ spec:
15
+ {{- with .Values.ingress.className }}
16
+ ingressClassName: {{ . }}
17
+ {{- end }}
18
+ {{- if .Values.ingress.tls }}
19
+ tls:
20
+ - hosts:
21
+ - {{ .Values.ingress.host }}
22
+ secretName: {{ .Release.Name }}-tls
23
+ {{- end }}
24
+ rules:
25
+ - host: {{ required "ingress.host is required" .Values.ingress.host }}
26
+ http:
27
+ paths:
28
+ - path: /
29
+ pathType: Prefix
30
+ backend:
31
+ service:
32
+ name: {{ .Release.Name }}
33
+ port:
34
+ name: http
35
+ {{- end }}
@@ -0,0 +1,16 @@
1
+ # Generated by quoll — https://github.com/theo-mrn/Quoll
2
+ # Do not edit by hand: change quoll.toml, then run `quoll k8s update`.
3
+ # With several replicas, node maintenance (drains) never takes them all down at once.
4
+ {{- if or .Values.autoscaling.enabled (gt (int .Values.replicaCount) 1) }}
5
+ apiVersion: policy/v1
6
+ kind: PodDisruptionBudget
7
+ metadata:
8
+ name: {{ .Release.Name }}
9
+ labels:
10
+ {{- include "app.labels" . | nindent 4 }}
11
+ spec:
12
+ minAvailable: 1
13
+ selector:
14
+ matchLabels:
15
+ {{- include "app.selectorLabels" . | nindent 6 }}
16
+ {{- end }}
@@ -0,0 +1,18 @@
1
+ # Generated by quoll — https://github.com/theo-mrn/Quoll
2
+ # Do not edit by hand: change quoll.toml, then run `quoll k8s update`.
3
+ # The other projects reach this one at http://{{ .Release.Name }}:{{ .Values.port }}, as in compose.yaml.
4
+ apiVersion: v1
5
+ kind: Service
6
+ metadata:
7
+ name: {{ .Release.Name }}
8
+ labels:
9
+ {{- include "app.labels" . | nindent 4 }}
10
+ spec:
11
+ type: ClusterIP
12
+ selector:
13
+ {{- include "app.selectorLabels" . | nindent 4 }}
14
+ ports:
15
+ - name: http
16
+ port: {{ .Values.port }}
17
+ targetPort: http
18
+ protocol: TCP
@@ -0,0 +1,10 @@
1
+ # Generated by quoll — https://github.com/theo-mrn/Quoll
2
+ # Do not edit by hand: change quoll.toml, then run `quoll k8s update`.
3
+ # An identity of its own, without access to the Kubernetes API (no token mounted).
4
+ apiVersion: v1
5
+ kind: ServiceAccount
6
+ metadata:
7
+ name: {{ .Release.Name }}
8
+ labels:
9
+ {{- include "app.labels" . | nindent 4 }}
10
+ automountServiceAccountToken: false
quoll/k8s/ci.py ADDED
@@ -0,0 +1,39 @@
1
+ """The CI's checks of a Helm chart, the same on both platforms: lint, Kubernetes schemas, misconfigurations."""
2
+ from __future__ import annotations
3
+
4
+ from typing import List
5
+
6
+ from quoll.ci.steps import Step, step
7
+ from quoll.ci.tools import TRIVY_VERSION
8
+
9
+ HELM_VERSION = "4.3.2"
10
+ HELM_IMAGE = f"alpine/helm:{HELM_VERSION}"
11
+ KUBECONFORM_VERSION = "0.8.0"
12
+ # Where the tools go: no root needed, the same path on GitHub runners and in GitLab's helm image.
13
+ _BIN = "/tmp/quoll-bin"
14
+
15
+ _KUBECONFORM = f"https://github.com/yannh/kubeconform/releases/download/v{KUBECONFORM_VERSION}"
16
+ _TRIVY = f"https://github.com/aquasecurity/trivy/releases/download/v{TRIVY_VERSION}"
17
+ _INSTALL = f"""mkdir -p {_BIN}
18
+ case "$(uname -m)" in x86_64) arch=amd64 trivy=64bit ;; *) arch=arm64 trivy=ARM64 ;; esac
19
+ wget -qO- "{_KUBECONFORM}/kubeconform-linux-$arch.tar.gz" | tar -xz -C {_BIN} kubeconform
20
+ wget -qO- "{_TRIVY}/trivy_{TRIVY_VERSION}_Linux-$trivy.tar.gz" | tar -xz -C {_BIN} trivy"""
21
+ # Every environment's values: the image is set by the deployment, any name renders the chart.
22
+ _LINT = """for values in {chart}/values-*.yaml; do
23
+ helm lint --strict {chart} -f "$values" --set image.repository=ci
24
+ done"""
25
+ _SCHEMAS = """for values in {chart}/values-*.yaml; do
26
+ echo "$values:"
27
+ helm template ci {chart} -f "$values" --set image.repository=ci | {bin}/kubeconform -strict -summary
28
+ done"""
29
+ _SCAN = "{bin}/trivy config --exit-code 1 --severity HIGH,CRITICAL --helm-set image.repository=ci {chart}"
30
+
31
+
32
+ def chart_checks(chart: str) -> List[Step]:
33
+ """Steps checking the chart in `chart` (helm installed)."""
34
+ return [
35
+ step("Install kubeconform and Trivy", run=_INSTALL),
36
+ step("Lint chart", run=_LINT.format(chart=chart)),
37
+ step("Kubernetes schemas", run=_SCHEMAS.format(chart=chart, bin=_BIN)),
38
+ step("Misconfigurations", run=_SCAN.format(chart=chart, bin=_BIN)),
39
+ ]