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.
- quoll/__init__.py +0 -0
- quoll/cd/__init__.py +1 -0
- quoll/cd/config.py +75 -0
- quoll/ci/__init__.py +0 -0
- quoll/ci/config.py +213 -0
- quoll/ci/custom.py +32 -0
- quoll/ci/detect.py +139 -0
- quoll/ci/steps.py +85 -0
- quoll/ci/tools.py +104 -0
- quoll/commands/__init__.py +0 -0
- quoll/commands/_common.py +145 -0
- quoll/commands/_workspace.py +83 -0
- quoll/commands/add.py +104 -0
- quoll/commands/add_secret.py +75 -0
- quoll/commands/cd.py +214 -0
- quoll/commands/ci.py +521 -0
- quoll/commands/docker.py +244 -0
- quoll/commands/k8s.py +211 -0
- quoll/commands/new.py +337 -0
- quoll/commands/remove.py +79 -0
- quoll/config/__init__.py +0 -0
- quoll/config/quoll_toml.py +196 -0
- quoll/config/settings.py +22 -0
- quoll/config/store.py +39 -0
- quoll/console.py +3 -0
- quoll/docker/__init__.py +1 -0
- quoll/docker/config.py +64 -0
- quoll/docker/detect.py +41 -0
- quoll/docker/render.py +162 -0
- quoll/files.py +64 -0
- quoll/k8s/__init__.py +0 -0
- quoll/k8s/chart/templates/_helpers.tpl +38 -0
- quoll/k8s/chart/templates/deployment.yaml +93 -0
- quoll/k8s/chart/templates/hpa.yaml +24 -0
- quoll/k8s/chart/templates/ingress.yaml +35 -0
- quoll/k8s/chart/templates/pdb.yaml +16 -0
- quoll/k8s/chart/templates/service.yaml +18 -0
- quoll/k8s/chart/templates/serviceaccount.yaml +10 -0
- quoll/k8s/ci.py +39 -0
- quoll/k8s/config.py +97 -0
- quoll/k8s/deploy.py +22 -0
- quoll/k8s/detect.py +32 -0
- quoll/k8s/render.py +94 -0
- quoll/languages/__init__.py +121 -0
- quoll/languages/base.py +350 -0
- quoll/languages/cpp/Dockerfile.j2 +37 -0
- quoll/languages/cpp/__init__.py +17 -0
- quoll/languages/cpp/ci.py +192 -0
- quoll/languages/cpp/detect.py +84 -0
- quoll/languages/cpp/docker.py +47 -0
- quoll/languages/dotnet/Dockerfile.j2 +21 -0
- quoll/languages/dotnet/__init__.py +17 -0
- quoll/languages/dotnet/ci.py +88 -0
- quoll/languages/dotnet/detect.py +53 -0
- quoll/languages/dotnet/docker.py +47 -0
- quoll/languages/go/Dockerfile.j2 +25 -0
- quoll/languages/go/__init__.py +17 -0
- quoll/languages/go/ci.py +126 -0
- quoll/languages/go/detect.py +30 -0
- quoll/languages/go/docker.py +40 -0
- quoll/languages/java/Dockerfile.j2 +28 -0
- quoll/languages/java/__init__.py +20 -0
- quoll/languages/java/ci.py +163 -0
- quoll/languages/java/detect.py +77 -0
- quoll/languages/java/docker.py +45 -0
- quoll/languages/java/frameworks/__init__.py +1 -0
- quoll/languages/java/frameworks/spring_boot/Dockerfile.j2 +18 -0
- quoll/languages/java/frameworks/spring_boot/__init__.py +10 -0
- quoll/languages/java/frameworks/spring_boot/detect.py +9 -0
- quoll/languages/java/frameworks/spring_boot/docker.py +19 -0
- quoll/languages/java/frameworks/spring_boot/services.py +24 -0
- quoll/languages/node/Dockerfile.j2 +98 -0
- quoll/languages/node/__init__.py +29 -0
- quoll/languages/node/ci.py +200 -0
- quoll/languages/node/detect.py +164 -0
- quoll/languages/node/docker.py +141 -0
- quoll/languages/node/frameworks/__init__.py +1 -0
- quoll/languages/node/frameworks/angular/__init__.py +18 -0
- quoll/languages/node/frameworks/angular/ci.py +34 -0
- quoll/languages/node/frameworks/angular/detect.py +20 -0
- quoll/languages/node/frameworks/angular/docker.py +31 -0
- quoll/languages/node/frameworks/angular/starter.py +74 -0
- quoll/languages/node/frameworks/astro/__init__.py +10 -0
- quoll/languages/node/frameworks/astro/ci.py +17 -0
- quoll/languages/node/frameworks/astro/detect.py +14 -0
- quoll/languages/node/frameworks/astro/docker.py +17 -0
- quoll/languages/node/frameworks/nestjs/__init__.py +7 -0
- quoll/languages/node/frameworks/nestjs/detect.py +9 -0
- quoll/languages/node/frameworks/nestjs/docker.py +10 -0
- quoll/languages/node/frameworks/nextjs/Dockerfile.j2 +14 -0
- quoll/languages/node/frameworks/nextjs/__init__.py +8 -0
- quoll/languages/node/frameworks/nextjs/ci.py +29 -0
- quoll/languages/node/frameworks/nextjs/detect.py +13 -0
- quoll/languages/node/frameworks/nextjs/docker.py +41 -0
- quoll/languages/node/frameworks/nuxt/__init__.py +10 -0
- quoll/languages/node/frameworks/nuxt/ci.py +17 -0
- quoll/languages/node/frameworks/nuxt/detect.py +17 -0
- quoll/languages/node/frameworks/nuxt/docker.py +24 -0
- quoll/languages/node/frameworks/sveltekit/__init__.py +15 -0
- quoll/languages/node/frameworks/sveltekit/ci.py +15 -0
- quoll/languages/node/frameworks/sveltekit/detect.py +18 -0
- quoll/languages/node/frameworks/sveltekit/docker.py +18 -0
- quoll/languages/node/starter.py +42 -0
- quoll/languages/node/templates/express/README.md +11 -0
- quoll/languages/node/templates/express/env.example +2 -0
- quoll/languages/node/templates/express/eslint.config.js +15 -0
- quoll/languages/node/templates/express/gitignore +4 -0
- quoll/languages/node/templates/express/nvmrc +1 -0
- quoll/languages/node/templates/express/package.json +16 -0
- quoll/languages/node/templates/express/prettierignore +6 -0
- quoll/languages/node/templates/express/src/app.ts +27 -0
- quoll/languages/node/templates/express/src/server.ts +11 -0
- quoll/languages/node/templates/express/test/app.test.ts +29 -0
- quoll/languages/node/templates/express/tsconfig.build.json +8 -0
- quoll/languages/node/templates/express/tsconfig.json +14 -0
- quoll/languages/node/templates/express/vitest.config.ts +12 -0
- quoll/languages/php/Dockerfile.j2 +40 -0
- quoll/languages/php/__init__.py +19 -0
- quoll/languages/php/ci.py +147 -0
- quoll/languages/php/detect.py +75 -0
- quoll/languages/php/docker.py +59 -0
- quoll/languages/php/frameworks/__init__.py +1 -0
- quoll/languages/php/frameworks/laravel/__init__.py +17 -0
- quoll/languages/php/frameworks/laravel/ci.py +24 -0
- quoll/languages/php/frameworks/laravel/detect.py +13 -0
- quoll/languages/php/frameworks/laravel/docker.py +29 -0
- quoll/languages/php/frameworks/laravel/services.py +25 -0
- quoll/languages/php/frameworks/symfony/__init__.py +15 -0
- quoll/languages/php/frameworks/symfony/ci.py +22 -0
- quoll/languages/php/frameworks/symfony/detect.py +17 -0
- quoll/languages/php/frameworks/symfony/docker.py +25 -0
- quoll/languages/python/Dockerfile.j2 +61 -0
- quoll/languages/python/__init__.py +19 -0
- quoll/languages/python/ci.py +157 -0
- quoll/languages/python/detect.py +175 -0
- quoll/languages/python/docker.py +80 -0
- quoll/languages/python/frameworks/__init__.py +1 -0
- quoll/languages/python/frameworks/django/__init__.py +16 -0
- quoll/languages/python/frameworks/django/ci.py +43 -0
- quoll/languages/python/frameworks/django/detect.py +17 -0
- quoll/languages/python/frameworks/django/docker.py +20 -0
- quoll/languages/python/frameworks/fastapi/__init__.py +7 -0
- quoll/languages/python/frameworks/fastapi/detect.py +9 -0
- quoll/languages/python/frameworks/fastapi/docker.py +21 -0
- quoll/languages/python/frameworks/flask/__init__.py +7 -0
- quoll/languages/python/frameworks/flask/detect.py +9 -0
- quoll/languages/python/frameworks/flask/docker.py +22 -0
- quoll/languages/ruby/Dockerfile.j2 +45 -0
- quoll/languages/ruby/__init__.py +19 -0
- quoll/languages/ruby/ci.py +110 -0
- quoll/languages/ruby/detect.py +72 -0
- quoll/languages/ruby/docker.py +63 -0
- quoll/languages/ruby/frameworks/__init__.py +1 -0
- quoll/languages/ruby/frameworks/rails/Dockerfile.j2 +16 -0
- quoll/languages/ruby/frameworks/rails/__init__.py +18 -0
- quoll/languages/ruby/frameworks/rails/ci.py +26 -0
- quoll/languages/ruby/frameworks/rails/detect.py +15 -0
- quoll/languages/ruby/frameworks/rails/docker.py +62 -0
- quoll/languages/ruby/frameworks/rails/services.py +10 -0
- quoll/languages/rust/Dockerfile.j2 +23 -0
- quoll/languages/rust/__init__.py +17 -0
- quoll/languages/rust/ci.py +125 -0
- quoll/languages/rust/detect.py +46 -0
- quoll/languages/rust/docker.py +38 -0
- quoll/main.py +50 -0
- quoll/platforms/__init__.py +62 -0
- quoll/platforms/base.py +103 -0
- quoll/platforms/github/__init__.py +4 -0
- quoll/platforms/github/actions.py +78 -0
- quoll/platforms/github/api.py +124 -0
- quoll/platforms/github/cd.py +265 -0
- quoll/platforms/github/ci.py +497 -0
- quoll/platforms/github/dependabot.yml.j2 +49 -0
- quoll/platforms/github/platform.py +52 -0
- quoll/platforms/github/workflow.py +74 -0
- quoll/platforms/gitlab/__init__.py +4 -0
- quoll/platforms/gitlab/api.py +158 -0
- quoll/platforms/gitlab/cd.py +226 -0
- quoll/platforms/gitlab/ci.py +400 -0
- quoll/platforms/gitlab/pipeline.py +136 -0
- quoll/platforms/gitlab/platform.py +54 -0
- quoll/project.py +60 -0
- quoll/services.py +117 -0
- quoll/starters.py +117 -0
- quoll/workspace.py +232 -0
- quoll/yaml_dump.py +137 -0
- quoll_cli-0.2.0.dist-info/METADATA +641 -0
- quoll_cli-0.2.0.dist-info/RECORD +191 -0
- quoll_cli-0.2.0.dist-info/WHEEL +4 -0
- quoll_cli-0.2.0.dist-info/entry_points.txt +2 -0
- quoll_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
quoll/console.py
ADDED
quoll/docker/__init__.py
ADDED
|
@@ -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
|
+
]
|