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/services.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Backing services (databases, caches) an app needs: containers for CI tests and for compose.yaml.
|
|
2
|
+
|
|
3
|
+
Languages detect which ones a project uses (see `detect_services`); this module knows how to run them and
|
|
4
|
+
which environment variables point an app at them. Credentials are throwaway: these containers only serve
|
|
5
|
+
tests and local development.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Callable, Dict, Iterable, List, Mapping, Union
|
|
11
|
+
|
|
12
|
+
DATABASE = "app"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class Service:
|
|
17
|
+
name: str
|
|
18
|
+
image: str
|
|
19
|
+
port: int
|
|
20
|
+
env: Dict[str, str]
|
|
21
|
+
# Shell command that succeeds once the service accepts connections.
|
|
22
|
+
health: str
|
|
23
|
+
# Where the container keeps its data (a named volume in compose.yaml).
|
|
24
|
+
data_dir: str
|
|
25
|
+
# Environment variables pointing an app at the service on `host`, using `database` (a name of its own).
|
|
26
|
+
app_env: Callable[[str, str], Dict[str, str]]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _postgres(host: str, database: str) -> Dict[str, str]:
|
|
30
|
+
url = f"postgresql://{DATABASE}:{DATABASE}@{host}:5432/{database}"
|
|
31
|
+
# The PG* variables are read by libpq-based clients (psycopg, pg, rails...) without any configuration.
|
|
32
|
+
return {
|
|
33
|
+
"DATABASE_URL": url,
|
|
34
|
+
"PGHOST": host,
|
|
35
|
+
"PGPORT": "5432",
|
|
36
|
+
"PGUSER": DATABASE,
|
|
37
|
+
"PGPASSWORD": DATABASE,
|
|
38
|
+
"PGDATABASE": database,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _mysql(host: str, database: str) -> Dict[str, str]:
|
|
43
|
+
# root, so test runners can create their test databases (Django, Rails...).
|
|
44
|
+
return {"DATABASE_URL": f"mysql://root:root@{host}:3306/{database}"}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
SERVICES: Dict[str, Service] = {
|
|
48
|
+
s.name: s
|
|
49
|
+
for s in (
|
|
50
|
+
Service(
|
|
51
|
+
name="postgres",
|
|
52
|
+
image="postgres:17-alpine",
|
|
53
|
+
port=5432,
|
|
54
|
+
env={"POSTGRES_USER": DATABASE, "POSTGRES_PASSWORD": DATABASE, "POSTGRES_DB": DATABASE},
|
|
55
|
+
health=f"pg_isready -U {DATABASE} -d {DATABASE}",
|
|
56
|
+
data_dir="/var/lib/postgresql/data",
|
|
57
|
+
app_env=_postgres,
|
|
58
|
+
),
|
|
59
|
+
Service(
|
|
60
|
+
name="mysql",
|
|
61
|
+
image="mysql:8.4",
|
|
62
|
+
port=3306,
|
|
63
|
+
env={"MYSQL_ROOT_PASSWORD": "root", "MYSQL_DATABASE": DATABASE},
|
|
64
|
+
health="mysqladmin ping -h 127.0.0.1 -uroot -proot",
|
|
65
|
+
data_dir="/var/lib/mysql",
|
|
66
|
+
app_env=_mysql,
|
|
67
|
+
),
|
|
68
|
+
Service(
|
|
69
|
+
name="redis",
|
|
70
|
+
image="redis:7-alpine",
|
|
71
|
+
port=6379,
|
|
72
|
+
env={},
|
|
73
|
+
health="redis-cli ping",
|
|
74
|
+
data_dir="/data",
|
|
75
|
+
app_env=lambda host, database: {"REDIS_URL": f"redis://{host}:6379"},
|
|
76
|
+
),
|
|
77
|
+
Service(
|
|
78
|
+
name="mongodb",
|
|
79
|
+
image="mongo:8",
|
|
80
|
+
port=27017,
|
|
81
|
+
env={},
|
|
82
|
+
health="mongosh --quiet --eval 'db.adminCommand({ping: 1})'",
|
|
83
|
+
data_dir="/data/db",
|
|
84
|
+
app_env=lambda host, database: {"MONGODB_URI": f"mongodb://{host}:27017/{database}"},
|
|
85
|
+
),
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
# Signature of the language/framework hooks adding their own variables (Laravel's DB_*, Spring's datasource...):
|
|
90
|
+
# (service, host, database) -> variables.
|
|
91
|
+
ServiceEnv = Callable[[str, str, str], Dict[str, str]]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def no_service_env(service: str, host: str, database: str) -> Dict[str, str]:
|
|
95
|
+
return {}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def detect_services(dependencies: Iterable[str], packages: Mapping[str, Iterable[str]]) -> List[str]:
|
|
99
|
+
"""Services whose client packages appear in `dependencies`, in catalog order."""
|
|
100
|
+
names = {d.lower() for d in dependencies}
|
|
101
|
+
return [service for service in SERVICES if any(p.lower() in names for p in packages.get(service, ()))]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def environment(
|
|
105
|
+
services: Iterable[str], host: Union[str, Callable[[str], str]], *hooks: ServiceEnv, database: str = DATABASE
|
|
106
|
+
) -> Dict[str, str]:
|
|
107
|
+
"""Variables pointing an app at `services`, reachable on `host` (or `host(service)`), using its `database`.
|
|
108
|
+
|
|
109
|
+
Later hooks override earlier values.
|
|
110
|
+
"""
|
|
111
|
+
env: Dict[str, str] = {}
|
|
112
|
+
for name in services:
|
|
113
|
+
address = host(name) if callable(host) else host
|
|
114
|
+
env.update(SERVICES[name].app_env(address, database))
|
|
115
|
+
for hook in hooks:
|
|
116
|
+
env.update(hook(name, address, database))
|
|
117
|
+
return env
|
quoll/starters.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Templates and generators (`ng new`, `npm install`...) behind the starters of `quoll new`.
|
|
2
|
+
|
|
3
|
+
A generator runs with the local tool when it is installed and no runtime version is asked for; otherwise in a
|
|
4
|
+
container of that runtime's image, so that the project and its lockfile match the version it will run on.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import List, Optional, Sequence
|
|
13
|
+
|
|
14
|
+
from quoll.languages.base import StarterOptions
|
|
15
|
+
|
|
16
|
+
# Runtimes' container images, by runtime and version.
|
|
17
|
+
IMAGES = {"node": "node:{version}"}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class StarterError(Exception):
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def run(command: List[str], cwd: Path, runtime: str = "", version: str = "") -> None:
|
|
25
|
+
"""Run `command` in `cwd`: in the `runtime:version` image when a version is asked for or the tool is missing."""
|
|
26
|
+
image = IMAGES[runtime].format(version=version or "latest") if runtime else ""
|
|
27
|
+
if shutil.which(command[0]) and not version:
|
|
28
|
+
full = command
|
|
29
|
+
elif image and shutil.which("docker"):
|
|
30
|
+
# The project's files stay the user's; caches go to the container's /tmp.
|
|
31
|
+
user = ["--user", f"{os.getuid()}:{os.getgid()}"] if hasattr(os, "getuid") else []
|
|
32
|
+
full = ["docker", "run", "--rm", "-v", f"{cwd}:/work", "-w", "/work", *user, "-e", "HOME=/tmp", image, *command]
|
|
33
|
+
else:
|
|
34
|
+
needed = f"Docker (to run {image})" if version else f"`{command[0]}` (or Docker)"
|
|
35
|
+
raise StarterError(f"{needed} is needed to create this project")
|
|
36
|
+
result = subprocess.run(full, cwd=cwd, capture_output=True, text=True)
|
|
37
|
+
if result.returncode != 0:
|
|
38
|
+
output = (result.stdout + result.stderr).strip().splitlines()
|
|
39
|
+
raise StarterError(f"`{' '.join(command)}` failed:\n " + "\n ".join(output[-15:]))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def in_container(command: str, version: str) -> bool:
|
|
43
|
+
"""Whether `run` runs `command` in a container (a runtime version is asked for, or the tool is missing)."""
|
|
44
|
+
return bool(version) or not shutil.which(command)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def install_locally(directory: Path, version: str, command: Sequence[str], folder: str) -> List[str]:
|
|
48
|
+
"""Dependencies installed in a (Linux) container hold its native binaries: reinstall them for this machine
|
|
49
|
+
from the lock file, or leave `folder` out. Returns a note for the user, if any."""
|
|
50
|
+
if not in_container(command[0], version):
|
|
51
|
+
return []
|
|
52
|
+
shutil.rmtree(directory / folder, ignore_errors=True)
|
|
53
|
+
if shutil.which(command[0]):
|
|
54
|
+
result = subprocess.run(list(command), cwd=directory, capture_output=True, text=True)
|
|
55
|
+
if result.returncode == 0:
|
|
56
|
+
return []
|
|
57
|
+
return [f"{directory.name}: run `{' '.join(command)}` there before working locally."]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def write(directory: Path, files: dict) -> None:
|
|
61
|
+
for rel, content in files.items():
|
|
62
|
+
path = directory / rel
|
|
63
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
64
|
+
path.write_text(content)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def from_template(
|
|
68
|
+
template: Path,
|
|
69
|
+
commands: Sequence[Sequence[str]] = (),
|
|
70
|
+
*,
|
|
71
|
+
runtime: str = "",
|
|
72
|
+
runtime_default: str = "",
|
|
73
|
+
version_default: str = "latest",
|
|
74
|
+
local_install: Sequence[str] = (),
|
|
75
|
+
local_folder: str = "",
|
|
76
|
+
renamed: Optional[dict] = None,
|
|
77
|
+
):
|
|
78
|
+
"""A starter's `create` copying `template`, then running `commands` in the project.
|
|
79
|
+
|
|
80
|
+
In files and commands, {{name}}, {{version}} (the framework's) and {{runtime}} (the runtime's) are replaced.
|
|
81
|
+
When the commands ran in a container, `local_install` reinstalls `local_folder` for this machine.
|
|
82
|
+
Templates live next to their plugin, in templates/<starter>/. Dot files are stored without their dot (see
|
|
83
|
+
`renamed`), so they don't apply to quoll's own repository.
|
|
84
|
+
"""
|
|
85
|
+
renamed = {
|
|
86
|
+
"gitignore": ".gitignore",
|
|
87
|
+
"env.example": ".env.example",
|
|
88
|
+
"nvmrc": ".nvmrc",
|
|
89
|
+
"prettierignore": ".prettierignore",
|
|
90
|
+
**(renamed or {}),
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
def create(directory: Path, options: StarterOptions) -> List[str]:
|
|
94
|
+
values = {
|
|
95
|
+
"{{name}}": options.name,
|
|
96
|
+
"{{version}}": options.version or version_default,
|
|
97
|
+
"{{runtime}}": options.runtime or runtime_default,
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
def fill(text: str) -> str:
|
|
101
|
+
for key, value in values.items():
|
|
102
|
+
text = text.replace(key, value)
|
|
103
|
+
return text
|
|
104
|
+
|
|
105
|
+
files = {}
|
|
106
|
+
for path in sorted(template.rglob("*")):
|
|
107
|
+
if path.is_file():
|
|
108
|
+
rel = path.relative_to(template).as_posix()
|
|
109
|
+
files[renamed.get(rel, rel)] = fill(path.read_text())
|
|
110
|
+
write(directory, files)
|
|
111
|
+
for command in commands:
|
|
112
|
+
run([fill(part) for part in command], directory, runtime, options.runtime)
|
|
113
|
+
if local_install:
|
|
114
|
+
return install_locally(directory, options.runtime, local_install, local_folder)
|
|
115
|
+
return []
|
|
116
|
+
|
|
117
|
+
return create
|
quoll/workspace.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""Repositories holding several projects (a front-end and a back-end, services...), each with its own quoll.toml.
|
|
2
|
+
|
|
3
|
+
The root quoll.toml lists them and holds what the repository shares; every member keeps the format of a
|
|
4
|
+
single project, so a member taken out of the repository stays a valid quoll project:
|
|
5
|
+
|
|
6
|
+
# quoll.toml
|
|
7
|
+
[project]
|
|
8
|
+
platform = "github"
|
|
9
|
+
|
|
10
|
+
[workspace]
|
|
11
|
+
members = ["backend", "frontend"]
|
|
12
|
+
|
|
13
|
+
[ci] # the repository's: when pipelines run, repository-wide scans
|
|
14
|
+
branches = ["main"]
|
|
15
|
+
|
|
16
|
+
# backend/quoll.toml, frontend/quoll.toml: [project], [ci], [docker], [cd] as usual
|
|
17
|
+
|
|
18
|
+
A repository with a single project is the one-member case: everything below works for both.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import List, Optional
|
|
25
|
+
|
|
26
|
+
from pydantic import Field, model_validator
|
|
27
|
+
|
|
28
|
+
from quoll.cd.config import CDConfig
|
|
29
|
+
from quoll.cd.config import load_config as load_cd_config
|
|
30
|
+
from quoll.ci.config import CIConfig, ScheduleConfig, load_config
|
|
31
|
+
from quoll.config.quoll_toml import CONFIG_FILE, ConfigError, PlatformField, Section, load_section, read, save_section
|
|
32
|
+
from quoll.k8s.config import K8sConfig
|
|
33
|
+
from quoll.k8s.config import load_config as load_k8s_config
|
|
34
|
+
|
|
35
|
+
WORKSPACE_SECTION = "workspace"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class WorkspaceConfig(Section):
|
|
39
|
+
# Directories of the projects, relative to the repository root ("." for a project at the root).
|
|
40
|
+
members: List[str]
|
|
41
|
+
|
|
42
|
+
@model_validator(mode="after")
|
|
43
|
+
def _check(self) -> "WorkspaceConfig":
|
|
44
|
+
if not self.members:
|
|
45
|
+
raise ValueError("workspace.members must list at least one project directory")
|
|
46
|
+
names = [member_name(m) for m in self.members]
|
|
47
|
+
duplicates = sorted({n for n in names if names.count(n) > 1})
|
|
48
|
+
if duplicates:
|
|
49
|
+
raise ValueError(f"two members would be named '{duplicates[0]}': rename one of their directories")
|
|
50
|
+
return self
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class RepoSecurity(Section):
|
|
54
|
+
# Scans of the whole repository, run once whatever the members.
|
|
55
|
+
secrets: bool = True
|
|
56
|
+
sast: bool = True
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class RepoCIConfig(Section, PlatformField):
|
|
60
|
+
"""[ci] of a workspace root: what the members share."""
|
|
61
|
+
|
|
62
|
+
shared_fields = ("platform",)
|
|
63
|
+
|
|
64
|
+
branches: List[str] = Field(default_factory=lambda: ["main"])
|
|
65
|
+
dependency_updates: bool = True
|
|
66
|
+
schedule: ScheduleConfig = Field(default_factory=ScheduleConfig)
|
|
67
|
+
security: RepoSecurity = Field(default_factory=RepoSecurity)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class Member:
|
|
72
|
+
# Job prefix: the last part of its directory ("" for a single project).
|
|
73
|
+
name: str
|
|
74
|
+
cfg: CIConfig
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def directory(self) -> str:
|
|
78
|
+
return self.cfg.directory
|
|
79
|
+
|
|
80
|
+
def job(self, job: str) -> str:
|
|
81
|
+
return f"{self.name}-{job}" if self.name else job
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass
|
|
85
|
+
class Delivery:
|
|
86
|
+
"""The delivery of one project: [cd] of a single project ("" name), or of a workspace member."""
|
|
87
|
+
|
|
88
|
+
name: str
|
|
89
|
+
directory: str
|
|
90
|
+
cfg: CDConfig
|
|
91
|
+
# Its Helm chart, deployed to each environment once the image is published.
|
|
92
|
+
k8s: Optional[K8sConfig] = None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass
|
|
96
|
+
class Repo:
|
|
97
|
+
"""Everything one pipeline covers: the repository's settings and its projects."""
|
|
98
|
+
|
|
99
|
+
platform: str
|
|
100
|
+
branches: List[str]
|
|
101
|
+
dependency_updates: bool
|
|
102
|
+
schedule: ScheduleConfig
|
|
103
|
+
secrets: bool
|
|
104
|
+
sast: bool
|
|
105
|
+
members: List[Member]
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def workspace(self) -> bool:
|
|
109
|
+
return any(m.name for m in self.members)
|
|
110
|
+
|
|
111
|
+
@classmethod
|
|
112
|
+
def single(cls, cfg: CIConfig) -> "Repo":
|
|
113
|
+
return cls(
|
|
114
|
+
platform=cfg.platform,
|
|
115
|
+
branches=list(cfg.branches),
|
|
116
|
+
dependency_updates=cfg.dependency_updates,
|
|
117
|
+
schedule=cfg.schedule,
|
|
118
|
+
secrets=cfg.security.secrets,
|
|
119
|
+
sast=cfg.security.sast,
|
|
120
|
+
members=[Member("", cfg)],
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# Directories that hold no project of their own, even with a requirements.txt or a package.json (docs, examples).
|
|
125
|
+
_NOT_PROJECTS = {
|
|
126
|
+
"docs", "doc", "examples", "example", "samples", "test", "tests", "fixtures", "scripts", "node_modules",
|
|
127
|
+
"vendor", "dist", "build", "target", "venv", ".venv",
|
|
128
|
+
}
|
|
129
|
+
# Where monorepos usually keep their projects, one level deeper.
|
|
130
|
+
_CONTAINERS = ("apps", "packages", "services")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def discover(root: Path) -> List[str]:
|
|
134
|
+
"""Project directories of the repository: the root, its first-level directories and apps/*, packages/*,
|
|
135
|
+
services/* — each where quoll recognizes a language."""
|
|
136
|
+
from quoll import languages
|
|
137
|
+
from quoll.project import Project
|
|
138
|
+
|
|
139
|
+
candidates = [root]
|
|
140
|
+
for child in sorted(root.iterdir()):
|
|
141
|
+
if not child.is_dir() or child.name.startswith(".") or child.name in _NOT_PROJECTS:
|
|
142
|
+
continue
|
|
143
|
+
if child.name in _CONTAINERS:
|
|
144
|
+
candidates += sorted(c for c in child.iterdir() if c.is_dir() and not c.name.startswith("."))
|
|
145
|
+
else:
|
|
146
|
+
candidates.append(child)
|
|
147
|
+
found = []
|
|
148
|
+
for directory in candidates:
|
|
149
|
+
if languages.matching(Project(directory)):
|
|
150
|
+
found.append(directory.relative_to(root).as_posix() or ".")
|
|
151
|
+
return found
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def member_name(directory: str) -> str:
|
|
155
|
+
"""backend -> backend, apps/web -> web, . -> root."""
|
|
156
|
+
parts = [p for p in directory.strip("/").split("/") if p and p != "."]
|
|
157
|
+
return parts[-1] if parts else "root"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def find_root(start: Path) -> Path:
|
|
161
|
+
"""The workspace root `start` belongs to (itself or a parent listing it), else `start`."""
|
|
162
|
+
start = start.resolve()
|
|
163
|
+
for candidate in [start, *start.parents]:
|
|
164
|
+
try:
|
|
165
|
+
data = read(candidate)
|
|
166
|
+
except ConfigError:
|
|
167
|
+
continue
|
|
168
|
+
workspace = data.get(WORKSPACE_SECTION)
|
|
169
|
+
if workspace is None:
|
|
170
|
+
continue
|
|
171
|
+
members = [(candidate / m).resolve() for m in workspace.get("members", [])]
|
|
172
|
+
if candidate == start or start in members:
|
|
173
|
+
return candidate
|
|
174
|
+
return start
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def load_workspace(root: Path) -> Optional[WorkspaceConfig]:
|
|
178
|
+
data = read(root)
|
|
179
|
+
if WORKSPACE_SECTION not in data:
|
|
180
|
+
return None
|
|
181
|
+
return load_section(root, WORKSPACE_SECTION, WorkspaceConfig)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def load_repo(root: Path) -> Repo:
|
|
185
|
+
"""The repository's pipeline settings: a workspace's, or a single project's (its [ci])."""
|
|
186
|
+
workspace = load_workspace(root)
|
|
187
|
+
if workspace is None:
|
|
188
|
+
return Repo.single(load_config(root))
|
|
189
|
+
shared = load_section(root, "ci", RepoCIConfig)
|
|
190
|
+
members = []
|
|
191
|
+
for directory in workspace.members:
|
|
192
|
+
cfg = load_config(root / directory)
|
|
193
|
+
if cfg.platform != shared.platform:
|
|
194
|
+
raise ConfigError(
|
|
195
|
+
f"{directory}/{CONFIG_FILE} targets {cfg.platform}, the workspace {shared.platform}: "
|
|
196
|
+
"set the same [project] platform"
|
|
197
|
+
)
|
|
198
|
+
members.append(Member(member_name(directory), cfg.in_directory(directory)))
|
|
199
|
+
return Repo(
|
|
200
|
+
platform=shared.platform,
|
|
201
|
+
branches=list(shared.branches),
|
|
202
|
+
dependency_updates=shared.dependency_updates,
|
|
203
|
+
schedule=shared.schedule,
|
|
204
|
+
secrets=shared.security.secrets,
|
|
205
|
+
sast=shared.security.sast,
|
|
206
|
+
members=members,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def save_workspace(root: Path, members: List[str], shared: RepoCIConfig) -> None:
|
|
211
|
+
save_section(root, WORKSPACE_SECTION, WorkspaceConfig(members=members))
|
|
212
|
+
save_section(root, "ci", shared)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def load_deliveries(root: Path) -> List[Delivery]:
|
|
216
|
+
"""The [cd] of a single project, or of each workspace member that has one."""
|
|
217
|
+
workspace = load_workspace(root)
|
|
218
|
+
if workspace is None:
|
|
219
|
+
return [Delivery("", ".", load_cd_config(root), _chart(root))]
|
|
220
|
+
deliveries = []
|
|
221
|
+
for directory in workspace.members:
|
|
222
|
+
if "cd" in read(root / directory):
|
|
223
|
+
deliveries.append(
|
|
224
|
+
Delivery(member_name(directory), directory, load_cd_config(root / directory), _chart(root / directory))
|
|
225
|
+
)
|
|
226
|
+
if not deliveries:
|
|
227
|
+
raise ConfigError("no workspace member has a [cd] section. Run `quoll cd init` in one of them first.")
|
|
228
|
+
return deliveries
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _chart(directory: Path) -> Optional[K8sConfig]:
|
|
232
|
+
return load_k8s_config(directory) if "k8s" in read(directory) else None
|
quoll/yaml_dump.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Minimal, deterministic YAML emitter (GitHub workflows, GitLab pipelines, compose files).
|
|
2
|
+
|
|
3
|
+
quoll builds documents as plain dicts and emits them itself so the output is stable (byte-for-byte, which the
|
|
4
|
+
`check` commands rely on), readable and prettier-compatible.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import re
|
|
10
|
+
from typing import Any, Dict, Iterable, List
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Commented(str):
|
|
14
|
+
"""A scalar followed by a comment, e.g. an action pinned to a commit, with its version."""
|
|
15
|
+
|
|
16
|
+
comment: str
|
|
17
|
+
|
|
18
|
+
def __new__(cls, value: str, comment: str) -> "Commented":
|
|
19
|
+
obj = super().__new__(cls, value)
|
|
20
|
+
obj.comment = comment
|
|
21
|
+
return obj
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
_PLAIN_RE = re.compile(r"^[A-Za-z_][\w ./@()=,+-]*$")
|
|
25
|
+
_YAML_KEYWORDS = {"true", "false", "yes", "no", "on", "off", "null", "y", "n", "~"}
|
|
26
|
+
_RUN_UNSAFE_START = set("-?:,[]{}#&*!|>'\"%@`")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def dump(doc: Dict[str, Any], spaced: Iterable[str] = (), scripts: Iterable[str] = ("run",)) -> str:
|
|
30
|
+
"""YAML for `doc`; children of the `spaced` mappings are separated by a blank line, for readability.
|
|
31
|
+
|
|
32
|
+
Values of the `scripts` keys are shell commands (a string or a list of them): emitted unquoted when YAML allows,
|
|
33
|
+
as literal blocks otherwise.
|
|
34
|
+
"""
|
|
35
|
+
lines: List[str] = []
|
|
36
|
+
top_keys = list(doc)
|
|
37
|
+
for i, key in enumerate(top_keys):
|
|
38
|
+
if i:
|
|
39
|
+
lines.append("")
|
|
40
|
+
lines.extend(_emit_entry(key, doc[key], 0, frozenset(spaced), frozenset(scripts)))
|
|
41
|
+
return "\n".join(lines) + "\n"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _emit_entry(key: str, value: Any, indent: int, spaced: frozenset, scripts: frozenset) -> List[str]:
|
|
45
|
+
pad = " " * indent
|
|
46
|
+
if isinstance(value, Commented):
|
|
47
|
+
return [f"{pad}{key}: {_scalar(str(value))} # {value.comment}"]
|
|
48
|
+
if key in scripts and isinstance(value, list):
|
|
49
|
+
out = [f"{pad}{key}:"]
|
|
50
|
+
for command in value:
|
|
51
|
+
out.extend(_emit_command(f"{pad} - ", command, indent + 4))
|
|
52
|
+
return out
|
|
53
|
+
if key in scripts and isinstance(value, str):
|
|
54
|
+
return _emit_command(f"{pad}{key}: ", value, indent + 2)
|
|
55
|
+
if isinstance(value, dict):
|
|
56
|
+
if not value:
|
|
57
|
+
return [f"{pad}{key}: {{}}"]
|
|
58
|
+
out = [f"{pad}{key}:"]
|
|
59
|
+
for i, (k, v) in enumerate(value.items()):
|
|
60
|
+
if i and key in spaced:
|
|
61
|
+
out.append("")
|
|
62
|
+
out.extend(_emit_entry(k, v, indent + 2, spaced, scripts))
|
|
63
|
+
return out
|
|
64
|
+
if isinstance(value, list):
|
|
65
|
+
if all(not isinstance(v, (dict, list)) for v in value):
|
|
66
|
+
return [f"{pad}{key}: [{', '.join(_scalar(v) for v in value)}]"]
|
|
67
|
+
out = [f"{pad}{key}:"]
|
|
68
|
+
for i, item in enumerate(value):
|
|
69
|
+
if i and key in spaced:
|
|
70
|
+
out.append("")
|
|
71
|
+
out.extend(_emit_list_item(item, indent + 2, spaced, scripts))
|
|
72
|
+
return out
|
|
73
|
+
if value is None:
|
|
74
|
+
return [f"{pad}{key}:"]
|
|
75
|
+
if isinstance(value, str) and "\n" in value:
|
|
76
|
+
return _block(f"{pad}{key}: ", value, indent + 2)
|
|
77
|
+
return [f"{pad}{key}: {_scalar(value)}"]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _emit_command(prefix: str, command: str, indent: int) -> List[str]:
|
|
81
|
+
if "\n" in command or not _plain_run(command):
|
|
82
|
+
return _block(prefix, command, indent)
|
|
83
|
+
return [f"{prefix}{command}"]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _block(prefix: str, text: str, indent: int) -> List[str]:
|
|
87
|
+
pad = " " * indent
|
|
88
|
+
return [f"{prefix}|", *(f"{pad}{line}" if line else "" for line in text.split("\n"))]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _emit_list_item(item: Any, indent: int, spaced: frozenset, scripts: frozenset) -> List[str]:
|
|
92
|
+
pad = " " * indent
|
|
93
|
+
if not isinstance(item, dict):
|
|
94
|
+
return [f"{pad}- {_scalar(item)}"]
|
|
95
|
+
out: List[str] = []
|
|
96
|
+
for i, (k, v) in enumerate(item.items()):
|
|
97
|
+
entry = _emit_entry(k, v, indent + 2, spaced, scripts)
|
|
98
|
+
if i == 0:
|
|
99
|
+
entry[0] = f"{pad}- {entry[0].lstrip()}"
|
|
100
|
+
out.extend(entry)
|
|
101
|
+
return out
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _scalar(value: Any) -> str:
|
|
105
|
+
if isinstance(value, bool):
|
|
106
|
+
return "true" if value else "false"
|
|
107
|
+
if isinstance(value, (int, float)):
|
|
108
|
+
return str(value)
|
|
109
|
+
if value is None:
|
|
110
|
+
return "null"
|
|
111
|
+
text = str(value)
|
|
112
|
+
if text.startswith("${{") and ": " not in text and " #" not in text:
|
|
113
|
+
return text
|
|
114
|
+
if _plain_scalar(text):
|
|
115
|
+
return text
|
|
116
|
+
return json.dumps(text)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _plain_scalar(text: str) -> bool:
|
|
120
|
+
"""Strings that read the same unquoted: start with a letter, no YAML indicators inside."""
|
|
121
|
+
return (
|
|
122
|
+
bool(_PLAIN_RE.match(text))
|
|
123
|
+
and text.lower() not in _YAML_KEYWORDS
|
|
124
|
+
and ": " not in text
|
|
125
|
+
and " #" not in text
|
|
126
|
+
and not text.endswith((":", " "))
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _plain_run(cmd: str) -> bool:
|
|
131
|
+
return (
|
|
132
|
+
bool(cmd)
|
|
133
|
+
and cmd[0] not in _RUN_UNSAFE_START
|
|
134
|
+
and ": " not in cmd
|
|
135
|
+
and " #" not in cmd
|
|
136
|
+
and not cmd.endswith(":")
|
|
137
|
+
)
|