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/k8s/config.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""The [k8s] section of quoll.toml — the single source of truth for the generated Helm chart and its deployment."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Dict, List
|
|
7
|
+
|
|
8
|
+
from pydantic import Field, model_validator
|
|
9
|
+
|
|
10
|
+
from quoll.config.quoll_toml import CONFIG_FILE, ConfigError, Section, load_section, save_section
|
|
11
|
+
|
|
12
|
+
__all__ = ["CONFIG_FILE", "ConfigError", "Environment", "K8sConfig", "load_config", "save_config"]
|
|
13
|
+
|
|
14
|
+
# What deploys an environment: every push to the [cd] branches, or the v* tags (after a manual approval).
|
|
15
|
+
TRIGGERS = ("branch", "tag")
|
|
16
|
+
_NAME = re.compile(r"^[a-z0-9]([-a-z0-9]*[a-z0-9])?$")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Environment(Section):
|
|
20
|
+
# Created when missing. Every project of a workspace deploys to the same one, so that they reach each other.
|
|
21
|
+
namespace: str
|
|
22
|
+
deploy_on: str = "branch"
|
|
23
|
+
# Public host of the Ingress, e.g. "api.example.com". Empty: reachable inside the cluster only.
|
|
24
|
+
host: str = ""
|
|
25
|
+
replicas: int = Field(default=1, ge=1)
|
|
26
|
+
|
|
27
|
+
@model_validator(mode="after")
|
|
28
|
+
def _check(self) -> "Environment":
|
|
29
|
+
if self.deploy_on not in TRIGGERS:
|
|
30
|
+
raise ValueError(f"deploy_on must be one of: {', '.join(TRIGGERS)}")
|
|
31
|
+
if not _NAME.match(self.namespace):
|
|
32
|
+
raise ValueError(f"namespace '{self.namespace}' isn't a valid Kubernetes name (lowercase, digits, -)")
|
|
33
|
+
return self
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class K8sConfig(Section):
|
|
37
|
+
# Helm release, Service and Deployment name: the other projects reach this one at http://<name>:<port>.
|
|
38
|
+
name: str
|
|
39
|
+
# Directory of the Helm chart, in the project.
|
|
40
|
+
chart: str = "chart"
|
|
41
|
+
# HTTP path answering 200 once the app is up, e.g. "/health". Empty: TCP checks on the port.
|
|
42
|
+
health_path: str = ""
|
|
43
|
+
cpu: str = "100m"
|
|
44
|
+
memory: str = "256Mi"
|
|
45
|
+
memory_limit: str = "512Mi"
|
|
46
|
+
# HorizontalPodAutoscaler between an environment's replicas and this (0: off).
|
|
47
|
+
max_replicas: int = Field(default=0, ge=0)
|
|
48
|
+
# Ingress: the cluster's default class when empty; TLS certificate from cert-manager when cluster_issuer is set.
|
|
49
|
+
ingress_class: str = ""
|
|
50
|
+
tls: bool = True
|
|
51
|
+
cluster_issuer: str = ""
|
|
52
|
+
# Plain environment variables (secrets go in the Secret `secret`, created in the cluster).
|
|
53
|
+
env: Dict[str, str] = Field(default_factory=dict)
|
|
54
|
+
# Secret holding the app's secrets as environment variables. Empty: "<name>-env".
|
|
55
|
+
secret: str = ""
|
|
56
|
+
# Secret to pull a private image with (a docker-registry Secret in the namespace).
|
|
57
|
+
pull_secret: str = ""
|
|
58
|
+
environments: Dict[str, Environment] = Field(default_factory=dict)
|
|
59
|
+
|
|
60
|
+
omit_when_empty = ("env",)
|
|
61
|
+
|
|
62
|
+
@model_validator(mode="after")
|
|
63
|
+
def _check(self) -> "K8sConfig":
|
|
64
|
+
if not _NAME.match(self.name):
|
|
65
|
+
raise ValueError(f"name '{self.name}' isn't a valid Kubernetes name (lowercase, digits, -)")
|
|
66
|
+
if self.health_path and not self.health_path.startswith("/"):
|
|
67
|
+
raise ValueError("health_path should start with /, e.g. /health")
|
|
68
|
+
return self
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def secret_name(self) -> str:
|
|
72
|
+
return self.secret or f"{self.name}-env"
|
|
73
|
+
|
|
74
|
+
def deployed_on(self, trigger: str) -> List[str]:
|
|
75
|
+
return [name for name, env in self.environments.items() if env.deploy_on == trigger]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def default_environments(project: str) -> Dict[str, Environment]:
|
|
79
|
+
"""Staging on every push to the delivered branches, production on v* tags."""
|
|
80
|
+
return {
|
|
81
|
+
"staging": Environment(namespace=f"{project}-staging"),
|
|
82
|
+
"production": Environment(namespace=project, deploy_on="tag", replicas=2),
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def kubernetes_name(value: str) -> str:
|
|
87
|
+
"""`value` as a Kubernetes name: lowercase letters, digits and dashes."""
|
|
88
|
+
name = re.sub(r"[^a-z0-9-]+", "-", value.lower()).strip("-")
|
|
89
|
+
return name[:53] or "app"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def load_config(project_dir: Path) -> K8sConfig:
|
|
93
|
+
return load_section(project_dir, "k8s", K8sConfig)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def save_config(project_dir: Path, config: K8sConfig) -> Path:
|
|
97
|
+
return save_section(project_dir, "k8s", config)
|
quoll/k8s/deploy.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Deploying a published image with its Helm chart: the commands both platforms' delivery jobs run."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from quoll.k8s.config import Environment, K8sConfig
|
|
5
|
+
|
|
6
|
+
# What the deploy jobs read: the image the delivery pushed, by digest (what was scanned, signed and attested).
|
|
7
|
+
IMAGE, DIGEST = "IMAGE", "DIGEST"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def helm_upgrade(k8s: K8sConfig, environment: str, settings: Environment) -> str:
|
|
11
|
+
"""Install or upgrade the release, wait until it is ready; roll it back otherwise."""
|
|
12
|
+
return (
|
|
13
|
+
f"helm upgrade --install {k8s.name} {k8s.chart} -f {k8s.chart}/values-{environment}.yaml \\\n"
|
|
14
|
+
f" --namespace {settings.namespace} --create-namespace \\\n"
|
|
15
|
+
f' --set image.repository="${IMAGE}" --set image.digest="${DIGEST}" \\\n'
|
|
16
|
+
" --rollback-on-failure --timeout 10m"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def url(settings: Environment, tls: bool) -> str:
|
|
21
|
+
"""The environment's address, shown by the platform next to the deployment."""
|
|
22
|
+
return f"{'https' if tls else 'http'}://{settings.host}" if settings.host else ""
|
quoll/k8s/detect.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""What quoll can tell about a project's deployment from its code."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
_SOURCES = (".ts", ".js", ".mjs", ".py", ".go", ".rb", ".php", ".java", ".kt", ".cs", ".rs")
|
|
9
|
+
_SKIPPED = {"node_modules", "vendor", ".venv", "venv", "dist", "build", "target", ".git", "bin", "obj", "test", "tests"}
|
|
10
|
+
# Routes health checks usually answer on, most specific first.
|
|
11
|
+
_HEALTH = re.compile(r"""["'](/(?:healthz|health|livez|readyz|ping|up))["']""")
|
|
12
|
+
_MAX_FILES = 500
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def health_path(project_dir: Path) -> str:
|
|
16
|
+
"""The health route the app's code declares, e.g. "/health"; empty when none is found."""
|
|
17
|
+
seen = 0
|
|
18
|
+
for directory, subdirectories, files in os.walk(project_dir):
|
|
19
|
+
subdirectories[:] = sorted(d for d in subdirectories if d not in _SKIPPED and not d.startswith("."))
|
|
20
|
+
for name in sorted(files):
|
|
21
|
+
if not name.endswith(_SOURCES) or ".test." in name or ".spec." in name:
|
|
22
|
+
continue
|
|
23
|
+
seen += 1
|
|
24
|
+
if seen > _MAX_FILES:
|
|
25
|
+
return ""
|
|
26
|
+
try:
|
|
27
|
+
match = _HEALTH.search((Path(directory) / name).read_text(errors="ignore"))
|
|
28
|
+
except OSError:
|
|
29
|
+
continue
|
|
30
|
+
if match:
|
|
31
|
+
return match.group(1)
|
|
32
|
+
return ""
|
quoll/k8s/render.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Turn a K8sConfig, with the project's [docker] (port, image) and [cd] (image name), into a Helm chart.
|
|
2
|
+
|
|
3
|
+
The templates are the same for every project (quoll/k8s/chart/templates); what differs is in the values files:
|
|
4
|
+
values.yaml for everything, values-<environment>.yaml for what an environment changes (replicas, host)."""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Dict, List, Optional
|
|
9
|
+
|
|
10
|
+
from quoll.docker.config import DockerConfig
|
|
11
|
+
from quoll.files import PlannedFile, header, planned
|
|
12
|
+
from quoll.k8s.config import Environment, K8sConfig
|
|
13
|
+
from quoll.yaml_dump import dump
|
|
14
|
+
|
|
15
|
+
TEMPLATES = Path(__file__).parent / "chart" / "templates"
|
|
16
|
+
_HELMIGNORE = "# Patterns kept out of the chart package.\n.DS_Store\n*.swp\n*.bak\n*.tmp\n"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def render_chart_yaml(k8s: K8sConfig) -> str:
|
|
20
|
+
doc = {
|
|
21
|
+
"apiVersion": "v2",
|
|
22
|
+
"name": k8s.name,
|
|
23
|
+
"description": f"{k8s.name}, generated by quoll",
|
|
24
|
+
"type": "application",
|
|
25
|
+
"version": "0.1.0",
|
|
26
|
+
"appVersion": "latest",
|
|
27
|
+
}
|
|
28
|
+
return header("k8s") + dump(doc)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def render_values(
|
|
32
|
+
k8s: K8sConfig, docker: DockerConfig, image: str = "", peers: Optional[Dict[str, str]] = None
|
|
33
|
+
) -> str:
|
|
34
|
+
"""values.yaml: `image` from [cd] (the deployment sets its digest), `peers` the URLs of the other projects."""
|
|
35
|
+
writable = docker.image.writable_paths(docker)
|
|
36
|
+
ingress: Dict[str, Any] = {"enabled": False, "className": k8s.ingress_class, "host": "", "tls": k8s.tls}
|
|
37
|
+
ingress["annotations"] = {"cert-manager.io/cluster-issuer": k8s.cluster_issuer} if k8s.cluster_issuer else {}
|
|
38
|
+
doc: Dict[str, Any] = {
|
|
39
|
+
# The deployment sets the digest of the image it published: what was scanned and signed runs.
|
|
40
|
+
"image": {"repository": image, "tag": "latest", "digest": "", "pullPolicy": "IfNotPresent"},
|
|
41
|
+
"imagePullSecrets": [k8s.pull_secret] if k8s.pull_secret else [],
|
|
42
|
+
"replicaCount": 1,
|
|
43
|
+
"port": docker.port,
|
|
44
|
+
"healthPath": k8s.health_path,
|
|
45
|
+
"env": {**(peers or {}), **k8s.env},
|
|
46
|
+
"envFromSecret": k8s.secret_name,
|
|
47
|
+
"resources": {
|
|
48
|
+
"requests": {"cpu": k8s.cpu, "memory": k8s.memory},
|
|
49
|
+
"limits": {"memory": k8s.memory_limit},
|
|
50
|
+
},
|
|
51
|
+
# The app only writes to these directories (emptyDir volumes); null: writes in its own tree.
|
|
52
|
+
"readOnlyRootFilesystem": writable is not None,
|
|
53
|
+
"writablePaths": writable or [],
|
|
54
|
+
"ingress": ingress,
|
|
55
|
+
"autoscaling": {
|
|
56
|
+
"enabled": k8s.max_replicas > 0,
|
|
57
|
+
"minReplicas": 1,
|
|
58
|
+
"maxReplicas": max(k8s.max_replicas, 1),
|
|
59
|
+
"targetCPUUtilizationPercentage": 80,
|
|
60
|
+
},
|
|
61
|
+
}
|
|
62
|
+
return header("k8s") + dump(doc)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def render_environment(k8s: K8sConfig, environment: Environment) -> str:
|
|
66
|
+
"""values-<environment>.yaml: what the environment changes."""
|
|
67
|
+
doc: Dict[str, Any] = {"replicaCount": environment.replicas}
|
|
68
|
+
if k8s.max_replicas:
|
|
69
|
+
doc["autoscaling"] = {"minReplicas": environment.replicas}
|
|
70
|
+
if environment.host:
|
|
71
|
+
doc["ingress"] = {"enabled": True, "host": environment.host}
|
|
72
|
+
return header("k8s") + dump(doc)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def plan(
|
|
76
|
+
project_dir: Path,
|
|
77
|
+
k8s: K8sConfig,
|
|
78
|
+
docker: DockerConfig,
|
|
79
|
+
image: str = "",
|
|
80
|
+
peers: Optional[Dict[str, str]] = None,
|
|
81
|
+
) -> List[PlannedFile]:
|
|
82
|
+
chart = Path(k8s.chart)
|
|
83
|
+
files = [
|
|
84
|
+
planned(project_dir, chart / "Chart.yaml", render_chart_yaml(k8s)),
|
|
85
|
+
planned(project_dir, chart / "values.yaml", render_values(k8s, docker, image, peers)),
|
|
86
|
+
*(
|
|
87
|
+
planned(project_dir, chart / f"values-{name}.yaml", render_environment(k8s, environment))
|
|
88
|
+
for name, environment in k8s.environments.items()
|
|
89
|
+
),
|
|
90
|
+
planned(project_dir, chart / ".helmignore", header("k8s") + _HELMIGNORE),
|
|
91
|
+
]
|
|
92
|
+
for template in sorted(TEMPLATES.iterdir()):
|
|
93
|
+
files.append(planned(project_dir, chart / "templates" / template.name, template.read_text()))
|
|
94
|
+
return files
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Language and framework plugins registry.
|
|
2
|
+
|
|
3
|
+
Built-in languages live in sub-packages of `quoll.languages`, each exposing a `language` object whose
|
|
4
|
+
`frameworks` live in its `frameworks/` sub-package. Third-party packages add languages or frameworks
|
|
5
|
+
(for any language) through entry points:
|
|
6
|
+
|
|
7
|
+
[project.entry-points."quoll.languages"]
|
|
8
|
+
elixir = "quoll_elixir:language"
|
|
9
|
+
|
|
10
|
+
[project.entry-points."quoll.frameworks"]
|
|
11
|
+
remix = "quoll_remix:framework"
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import importlib
|
|
17
|
+
from importlib.metadata import entry_points
|
|
18
|
+
from typing import Dict, List, Optional
|
|
19
|
+
|
|
20
|
+
from quoll.languages.base import DockerImage, Framework, Language, Starter
|
|
21
|
+
from quoll.project import Project
|
|
22
|
+
|
|
23
|
+
LANGUAGES_GROUP = "quoll.languages"
|
|
24
|
+
FRAMEWORKS_GROUP = "quoll.frameworks"
|
|
25
|
+
|
|
26
|
+
# Also the detection priority. Languages whose projects often carry a package.json for their front-end
|
|
27
|
+
# assets (Rails, Symfony, Laravel, Django...) come before node. C/C++ comes last: a CMakeLists.txt also builds the
|
|
28
|
+
# native parts of Python or Node packages.
|
|
29
|
+
_BUILTIN = ("go", "rust", "java", "dotnet", "php", "ruby", "python", "node", "cpp")
|
|
30
|
+
|
|
31
|
+
_languages: Dict[str, Language] = {}
|
|
32
|
+
_frameworks: Dict[str, List[Framework]] = {}
|
|
33
|
+
_loaded = False
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def register(language: Language) -> None:
|
|
37
|
+
if not isinstance(language, Language):
|
|
38
|
+
raise TypeError(f"expected a quoll.languages.base.Language, got {type(language).__name__}")
|
|
39
|
+
if language.name in _languages:
|
|
40
|
+
raise ValueError(f"language '{language.name}' is already registered")
|
|
41
|
+
_languages[language.name] = language
|
|
42
|
+
_frameworks[language.name] = []
|
|
43
|
+
for framework in language.frameworks:
|
|
44
|
+
register_framework(framework)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def register_framework(framework: Framework) -> None:
|
|
48
|
+
if not isinstance(framework, Framework):
|
|
49
|
+
raise TypeError(f"expected a quoll.languages.base.Framework, got {type(framework).__name__}")
|
|
50
|
+
if framework.language not in _languages:
|
|
51
|
+
raise ValueError(f"framework '{framework.name}' targets unknown language '{framework.language}'")
|
|
52
|
+
if any(f.name == framework.name for f in _frameworks[framework.language]):
|
|
53
|
+
raise ValueError(f"framework '{framework.name}' is already registered for {framework.language}")
|
|
54
|
+
_frameworks[framework.language].append(framework)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def available() -> List[Language]:
|
|
58
|
+
"""Registered languages, in detection priority order."""
|
|
59
|
+
_load()
|
|
60
|
+
return list(_languages.values())
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def names() -> List[str]:
|
|
64
|
+
return [lang.name for lang in available()]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def get(name: str) -> Language:
|
|
68
|
+
_load()
|
|
69
|
+
try:
|
|
70
|
+
return _languages[name]
|
|
71
|
+
except KeyError:
|
|
72
|
+
raise KeyError(f"unknown language '{name}' (available: {', '.join(names())})") from None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def starters(role: str = "") -> List[Starter]:
|
|
76
|
+
"""Every starter of the registered languages and frameworks (for `role` only, when given)."""
|
|
77
|
+
found = [s for lang in available() for owner in (lang, *frameworks(lang.name)) for s in owner.starters]
|
|
78
|
+
return [s for s in found if not role or s.role == role]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def frameworks(language: str) -> List[Framework]:
|
|
82
|
+
"""Frameworks of `language`, in detection priority order."""
|
|
83
|
+
_load()
|
|
84
|
+
return list(_frameworks.get(language, []))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def get_framework(language: str, name: str) -> Optional[Framework]:
|
|
88
|
+
return next((f for f in frameworks(language) if f.name == name), None)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def docker_image(language: str, framework: str = "") -> Optional[DockerImage]:
|
|
92
|
+
"""The framework's image when it has one, else the language's."""
|
|
93
|
+
fw = get_framework(language, framework) if framework else None
|
|
94
|
+
return (fw.docker if fw else None) or get(language).docker
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def matching(project: Project) -> List[Language]:
|
|
98
|
+
"""Languages whose marker files are present, highest priority first."""
|
|
99
|
+
return [lang for lang in available() if lang.matches(project)]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _load() -> None:
|
|
103
|
+
global _loaded
|
|
104
|
+
if _loaded:
|
|
105
|
+
return
|
|
106
|
+
_loaded = True
|
|
107
|
+
for name in _BUILTIN:
|
|
108
|
+
register(importlib.import_module(f"{__name__}.{name}").language)
|
|
109
|
+
for group, add in ((LANGUAGES_GROUP, register), (FRAMEWORKS_GROUP, register_framework)):
|
|
110
|
+
for ep in _entry_points(group):
|
|
111
|
+
try:
|
|
112
|
+
add(ep.load())
|
|
113
|
+
except Exception as e:
|
|
114
|
+
raise RuntimeError(f"quoll plugin '{ep.name}' ({ep.value}) failed to load: {e}") from e
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _entry_points(group: str) -> list:
|
|
118
|
+
try:
|
|
119
|
+
return list(entry_points(group=group))
|
|
120
|
+
except TypeError: # Python 3.9: entry_points() takes no arguments
|
|
121
|
+
return list(entry_points().get(group, []))
|