plinth-cli 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- plinth/__init__.py +1 -0
- plinth/__main__.py +5 -0
- plinth/add.py +97 -0
- plinth/blueprints/ci/github/dot-github/workflows/ci.yaml.j2 +38 -0
- plinth/blueprints/ci/gitlab/dot-gitlab-ci.yml.j2 +36 -0
- plinth/blueprints/gitops/applicationset.yaml.j2 +46 -0
- plinth/blueprints/gitops/values.yaml.j2 +2 -0
- plinth/blueprints/repo/app/README.md.j2 +63 -0
- plinth/blueprints/repo/app/dagger.json.j2 +8 -0
- plinth/blueprints/repo/app/dot-dagger/dot-gitignore +3 -0
- plinth/blueprints/repo/app/dot-dagger/pyproject.toml +12 -0
- plinth/blueprints/repo/app/dot-dagger/src/ci/__init__.py +3 -0
- plinth/blueprints/repo/app/dot-dagger/src/ci/main.py.j2 +133 -0
- plinth/blueprints/repo/app/dot-gitignore +4 -0
- plinth/blueprints/repo/gitops/README.md.j2 +53 -0
- plinth/blueprints/repo/gitops/apps/project.yaml.j2 +15 -0
- plinth/blueprints/repo/gitops/bootstrap/root.yaml.j2 +20 -0
- plinth/blueprints/repo/gitops/charts/workload/Chart.yaml +5 -0
- plinth/blueprints/repo/gitops/charts/workload/templates/_helpers.tpl +13 -0
- plinth/blueprints/repo/gitops/charts/workload/templates/deployment.yaml +61 -0
- plinth/blueprints/repo/gitops/charts/workload/templates/service.yaml +20 -0
- plinth/blueprints/repo/gitops/charts/workload/values.yaml +9 -0
- plinth/blueprints/repo/gitops/scripts/bootstrap-local.sh.j2 +23 -0
- plinth/blueprints/workload/common/Dockerfile.j2 +14 -0
- plinth/blueprints/workload/common/app/__init__.py +0 -0
- plinth/blueprints/workload/common/dot-dockerignore +4 -0
- plinth/blueprints/workload/common/score.yaml.j2 +33 -0
- plinth/blueprints/workload/common/tests/__init__.py +0 -0
- plinth/blueprints/workload/modular-monolith/app/main.py.j2 +63 -0
- plinth/blueprints/workload/modular-monolith/app/modules/__init__.py +5 -0
- plinth/blueprints/workload/modular-monolith/app/modules/example.py +12 -0
- plinth/blueprints/workload/modular-monolith/tests/test_main.py.j2 +42 -0
- plinth/blueprints/workload/service/app/main.py.j2 +54 -0
- plinth/blueprints/workload/service/tests/test_main.py.j2 +41 -0
- plinth/blueprints/workload/worker/app/main.py.j2 +44 -0
- plinth/blueprints/workload/worker/tests/test_main.py.j2 +26 -0
- plinth/cli.py +185 -0
- plinth/config.py +51 -0
- plinth/loader.py +49 -0
- plinth/plan.py +70 -0
- plinth/render.py +119 -0
- plinth/schema.py +158 -0
- plinth/wizard.py +129 -0
- plinth/write.py +72 -0
- plinth_cli-0.2.0.dist-info/METADATA +174 -0
- plinth_cli-0.2.0.dist-info/RECORD +49 -0
- plinth_cli-0.2.0.dist-info/WHEEL +4 -0
- plinth_cli-0.2.0.dist-info/entry_points.txt +2 -0
- plinth_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
plinth/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""plinth: generate ready-to-push application and GitOps repositories from one config."""
|
plinth/__main__.py
ADDED
plinth/add.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""`plinth add`: grow an existing project with a new service or worker.
|
|
2
|
+
|
|
3
|
+
Only files that belong to the new workload are created. Existing files are never
|
|
4
|
+
overwritten; ones that plinth would now generate differently are reported so the
|
|
5
|
+
user can review them.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from plinth import config as defaults
|
|
14
|
+
from plinth.loader import ConfigError, config_to_dict, dump_config, load_config, parse_config
|
|
15
|
+
from plinth.plan import build_plan
|
|
16
|
+
from plinth.render import render_plan
|
|
17
|
+
from plinth.schema import AppKind, Architecture, ProjectConfig
|
|
18
|
+
from plinth.write import init_repo, write_tree
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class AddResult:
|
|
23
|
+
config: ProjectConfig
|
|
24
|
+
created: list[str] = field(default_factory=list)
|
|
25
|
+
kept: list[str] = field(default_factory=list)
|
|
26
|
+
new_repos: list[str] = field(default_factory=list)
|
|
27
|
+
notes: list[str] = field(default_factory=list)
|
|
28
|
+
warnings: list[str] = field(default_factory=list)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def add_application(
|
|
32
|
+
project_dir: Path, kind: AppKind, name: str, port: int | None = None, init_git: bool = True
|
|
33
|
+
) -> AddResult:
|
|
34
|
+
config_path = project_dir / defaults.CONFIG_FILENAME
|
|
35
|
+
old = load_config(config_path)
|
|
36
|
+
data = config_to_dict(old)
|
|
37
|
+
notes = []
|
|
38
|
+
|
|
39
|
+
if kind is AppKind.MODULAR_MONOLITH:
|
|
40
|
+
raise ConfigError("a project has exactly one modular-monolith; add a service or worker")
|
|
41
|
+
if old.project.architecture is Architecture.MODULAR_MONOLITH:
|
|
42
|
+
data["project"]["architecture"] = Architecture.HYBRID.value
|
|
43
|
+
notes.append("architecture changed from modular-monolith to hybrid")
|
|
44
|
+
|
|
45
|
+
app = {"name": name, "kind": kind.value}
|
|
46
|
+
if port is not None:
|
|
47
|
+
app["port"] = port
|
|
48
|
+
data["applications"].append(app)
|
|
49
|
+
new = parse_config(data, source=str(config_path))
|
|
50
|
+
|
|
51
|
+
old_plan, new_plan = build_plan(old), build_plan(new)
|
|
52
|
+
old_folders = {r.folder for r in old_plan.repos}
|
|
53
|
+
files = render_plan(new_plan)
|
|
54
|
+
result = AddResult(config=new, notes=notes)
|
|
55
|
+
|
|
56
|
+
new_repos = [r for r in new_plan.repos if r.folder not in old_folders]
|
|
57
|
+
for repo in new_repos:
|
|
58
|
+
if (project_dir / repo.folder).exists():
|
|
59
|
+
raise ConfigError(f"cannot create repository folder, it already exists: {repo.folder}")
|
|
60
|
+
|
|
61
|
+
owned = _new_workload_prefixes(new_plan, name)
|
|
62
|
+
to_write = {}
|
|
63
|
+
for rel, content in sorted(files.items()):
|
|
64
|
+
folder = rel.split("/", 1)[0]
|
|
65
|
+
dest = project_dir / rel
|
|
66
|
+
if folder not in old_folders or rel.startswith(owned):
|
|
67
|
+
if dest.exists():
|
|
68
|
+
if dest.read_bytes() != content:
|
|
69
|
+
result.kept.append(rel)
|
|
70
|
+
continue
|
|
71
|
+
to_write[rel] = content
|
|
72
|
+
result.created.append(rel)
|
|
73
|
+
elif dest.exists() and dest.read_bytes() != content:
|
|
74
|
+
result.kept.append(rel)
|
|
75
|
+
|
|
76
|
+
write_tree(project_dir, to_write)
|
|
77
|
+
config_path.write_text(dump_config(new), encoding="utf-8")
|
|
78
|
+
|
|
79
|
+
for repo in new_repos:
|
|
80
|
+
result.new_repos.append(repo.folder)
|
|
81
|
+
if init_git:
|
|
82
|
+
warning = init_repo(project_dir / repo.folder, repo.url)
|
|
83
|
+
if warning:
|
|
84
|
+
result.warnings.append(warning)
|
|
85
|
+
return result
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _new_workload_prefixes(plan, name: str) -> tuple[str, ...]:
|
|
89
|
+
prefixes = [
|
|
90
|
+
f"{repo.folder}/{wl.source_dir}"
|
|
91
|
+
for repo, wl in plan.workloads()
|
|
92
|
+
if wl.name == name and wl.source_dir
|
|
93
|
+
]
|
|
94
|
+
prefixes += [
|
|
95
|
+
f"{plan.gitops.folder}/environments/{env}/{name}/" for env in plan.config.environments
|
|
96
|
+
]
|
|
97
|
+
return tuple(prefixes)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
name: ci
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
contents: read
|
|
10
|
+
packages: write
|
|
11
|
+
|
|
12
|
+
jobs:
|
|
13
|
+
ci:
|
|
14
|
+
runs-on: ubuntu-latest
|
|
15
|
+
steps:
|
|
16
|
+
- uses: {{ defaults.CHECKOUT_ACTION }}
|
|
17
|
+
|
|
18
|
+
- name: Test
|
|
19
|
+
if: github.event_name == 'pull_request'
|
|
20
|
+
uses: {{ defaults.DAGGER_GITHUB_ACTION }}
|
|
21
|
+
with:
|
|
22
|
+
version: "{{ defaults.DAGGER_VERSION }}"
|
|
23
|
+
call: test
|
|
24
|
+
|
|
25
|
+
- name: Ship
|
|
26
|
+
if: github.event_name == 'push'
|
|
27
|
+
uses: {{ defaults.DAGGER_GITHUB_ACTION }}
|
|
28
|
+
env:
|
|
29
|
+
REGISTRY_PASSWORD: {{ "${{ secrets.REGISTRY_PASSWORD || secrets.GITHUB_TOKEN }}" }}
|
|
30
|
+
GITOPS_TOKEN: {{ "${{ secrets.GITOPS_TOKEN }}" }}
|
|
31
|
+
with:
|
|
32
|
+
version: "{{ defaults.DAGGER_VERSION }}"
|
|
33
|
+
call: >-
|
|
34
|
+
ship
|
|
35
|
+
--commit={{ "${{ github.sha }}" }}
|
|
36
|
+
--registry-username={{ "${{ vars.REGISTRY_USERNAME || github.actor }}" }}
|
|
37
|
+
--registry-password=env://REGISTRY_PASSWORD
|
|
38
|
+
--git-token=env://GITOPS_TOKEN
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
stages: [test, ship]
|
|
2
|
+
|
|
3
|
+
.dagger:
|
|
4
|
+
image: {{ defaults.DOCKER_IMAGE }}
|
|
5
|
+
services:
|
|
6
|
+
- {{ defaults.DOCKER_IMAGE }}-dind
|
|
7
|
+
variables:
|
|
8
|
+
DOCKER_HOST: tcp://docker:2376
|
|
9
|
+
DOCKER_TLS_CERTDIR: /certs
|
|
10
|
+
DOCKER_TLS_VERIFY: "1"
|
|
11
|
+
DOCKER_CERT_PATH: /certs/client
|
|
12
|
+
DAGGER_VERSION: "{{ defaults.DAGGER_VERSION }}"
|
|
13
|
+
before_script:
|
|
14
|
+
- apk add --no-cache curl
|
|
15
|
+
- curl -fsSL https://dl.dagger.io/dagger/install.sh | BIN_DIR=/usr/local/bin sh
|
|
16
|
+
|
|
17
|
+
test:
|
|
18
|
+
extends: .dagger
|
|
19
|
+
stage: test
|
|
20
|
+
rules:
|
|
21
|
+
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
|
22
|
+
script:
|
|
23
|
+
- dagger call test
|
|
24
|
+
|
|
25
|
+
ship:
|
|
26
|
+
extends: .dagger
|
|
27
|
+
stage: ship
|
|
28
|
+
rules:
|
|
29
|
+
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
|
30
|
+
script:
|
|
31
|
+
- >-
|
|
32
|
+
dagger call ship
|
|
33
|
+
--commit=$CI_COMMIT_SHA
|
|
34
|
+
--registry-username=$REGISTRY_USERNAME
|
|
35
|
+
--registry-password=env://REGISTRY_PASSWORD
|
|
36
|
+
--git-token=env://GITOPS_TOKEN
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
apiVersion: argoproj.io/v1alpha1
|
|
2
|
+
kind: ApplicationSet
|
|
3
|
+
metadata:
|
|
4
|
+
name: {{ project.name }}-{{ environment }}
|
|
5
|
+
namespace: argocd
|
|
6
|
+
spec:
|
|
7
|
+
goTemplate: true
|
|
8
|
+
goTemplateOptions: ["missingkey=error"]
|
|
9
|
+
generators:
|
|
10
|
+
- git:
|
|
11
|
+
repoURL: {{ repo.url }}
|
|
12
|
+
revision: HEAD
|
|
13
|
+
files:
|
|
14
|
+
- path: environments/{{ environment }}/*/release.yaml
|
|
15
|
+
template:
|
|
16
|
+
metadata:
|
|
17
|
+
name: '{{ project.name }}-{% raw %}{{ .workload }}{% endraw %}-{{ environment }}'
|
|
18
|
+
spec:
|
|
19
|
+
project: {{ project.name }}
|
|
20
|
+
destination:
|
|
21
|
+
server: https://kubernetes.default.svc
|
|
22
|
+
namespace: {{ project.name }}-{{ environment }}
|
|
23
|
+
sources:
|
|
24
|
+
- repoURL: {{ repo.url }}
|
|
25
|
+
targetRevision: HEAD
|
|
26
|
+
path: charts/workload
|
|
27
|
+
helm:
|
|
28
|
+
releaseName: '{% raw %}{{ .workload }}{% endraw %}'
|
|
29
|
+
valueFiles:
|
|
30
|
+
- '$app/{% raw %}{{ .scorePath }}{% endraw %}'
|
|
31
|
+
- '$gitops/environments/{{ environment }}/{% raw %}{{ .workload }}{% endraw %}/values.yaml'
|
|
32
|
+
parameters:
|
|
33
|
+
- name: image
|
|
34
|
+
value: '{% raw %}{{ .image }}{% endraw %}'
|
|
35
|
+
- repoURL: '{% raw %}{{ .appRepoURL }}{% endraw %}'
|
|
36
|
+
targetRevision: '{% raw %}{{ .revision }}{% endraw %}'
|
|
37
|
+
ref: app
|
|
38
|
+
- repoURL: {{ repo.url }}
|
|
39
|
+
targetRevision: HEAD
|
|
40
|
+
ref: gitops
|
|
41
|
+
syncPolicy:
|
|
42
|
+
automated:
|
|
43
|
+
prune: false
|
|
44
|
+
selfHeal: true
|
|
45
|
+
syncOptions:
|
|
46
|
+
- CreateNamespace=true
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# {{ repo.folder }}
|
|
2
|
+
|
|
3
|
+
Application repository for **{{ project.name }}** ({{ project.architecture }}, {{ project.layout }}).
|
|
4
|
+
Generated by [plinth](https://github.com/dfoshidero/plinth).
|
|
5
|
+
|
|
6
|
+
## Workloads
|
|
7
|
+
|
|
8
|
+
{% for wl in repo.workloads %}
|
|
9
|
+
- `{{ wl.name }}` ({{ wl.kind }}): source in `{{ wl.source_dir or "./" }}`, workload spec in `{{ wl.score_path }}`
|
|
10
|
+
{% endfor %}
|
|
11
|
+
|
|
12
|
+
Each workload is described by a [Score](https://score.dev) file (`score.yaml`). The CI pipeline
|
|
13
|
+
finds workloads by looking for those files, and the GitOps repo's Helm chart deploys them.
|
|
14
|
+
|
|
15
|
+
## Run locally
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
{% for wl in repo.workloads %}
|
|
19
|
+
(cd {{ wl.source_dir or "." }} && python -m app.main) # {{ wl.name }}{% if wl.port %}, http://localhost:{{ wl.port }}{{ defaults.HEALTH_PATH }}{% endif %}
|
|
20
|
+
|
|
21
|
+
{% endfor %}
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Tests use the standard library:
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
{% for wl in repo.workloads %}
|
|
28
|
+
(cd {{ wl.source_dir or "." }} && python -m unittest discover -s tests -t .)
|
|
29
|
+
{% endfor %}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## CI with Dagger
|
|
33
|
+
|
|
34
|
+
The pipeline lives in `.dagger/` and runs the same way locally and in CI
|
|
35
|
+
(needs [Dagger](https://docs.dagger.io) {{ defaults.DAGGER_VERSION }} and Docker):
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
dagger call workloads # list discovered workloads
|
|
39
|
+
dagger call test # run every workload's tests
|
|
40
|
+
dagger call build --workload=NAME
|
|
41
|
+
dagger call ship --commit=$(git rev-parse HEAD) \
|
|
42
|
+
--registry-username=USER --registry-password=env://REGISTRY_PASSWORD \
|
|
43
|
+
--git-token=env://GITOPS_TOKEN
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`ship` tests, pushes `{{ project.registry }}/{{ project.name }}-<workload>:<commit>` for each workload,
|
|
47
|
+
then commits `environments/{{ defaults.RELEASE_ENVIRONMENT }}/<workload>/release.yaml` to
|
|
48
|
+
[{{ plan.gitops.folder }}]({{ plan.gitops.url }}). Argo CD deploys from there.
|
|
49
|
+
|
|
50
|
+
{% if project.git_provider.value == "github" %}
|
|
51
|
+
`.github/workflows/ci.yaml` runs `test` on pull requests and `ship` on pushes to `main`. Set these
|
|
52
|
+
repository secrets:
|
|
53
|
+
|
|
54
|
+
- `GITOPS_TOKEN`: a token that can push to `{{ plan.gitops.folder }}`
|
|
55
|
+
- `REGISTRY_PASSWORD`: only if the registry is not `ghcr.io` (otherwise `GITHUB_TOKEN` is used)
|
|
56
|
+
|
|
57
|
+
and, for registries other than `ghcr.io`, the repository variable `REGISTRY_USERNAME`.
|
|
58
|
+
{% elif project.git_provider.value == "gitlab" %}
|
|
59
|
+
`.gitlab-ci.yml` runs `test` on merge requests and `ship` on pushes to the default branch. Set these
|
|
60
|
+
CI/CD variables (masked): `GITOPS_TOKEN`, `REGISTRY_USERNAME`, `REGISTRY_PASSWORD`.
|
|
61
|
+
{% else %}
|
|
62
|
+
No CI provider file was generated. Run the `dagger call` commands above from your CI system.
|
|
63
|
+
{% endif %}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "ci"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
requires-python = ">=3.12"
|
|
5
|
+
dependencies = ["dagger-io", "pyyaml>=6,<7"]
|
|
6
|
+
|
|
7
|
+
[tool.uv.sources]
|
|
8
|
+
dagger-io = { path = "sdk", editable = true }
|
|
9
|
+
|
|
10
|
+
[build-system]
|
|
11
|
+
requires = ["hatchling"]
|
|
12
|
+
build-backend = "hatchling.build"
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Test, build, publish and release every workload in this repository.
|
|
2
|
+
|
|
3
|
+
Workloads are discovered from their score.yaml files, so adding one needs no change here.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import posixpath
|
|
9
|
+
from typing import Annotated
|
|
10
|
+
|
|
11
|
+
import dagger
|
|
12
|
+
import yaml
|
|
13
|
+
from dagger import DefaultPath, Doc, dag, function, object_type
|
|
14
|
+
|
|
15
|
+
PROJECT = "{{ project.name }}"
|
|
16
|
+
REGISTRY = "{{ project.registry }}"
|
|
17
|
+
APP_REPO_URL = "{{ repo.url }}"
|
|
18
|
+
GITOPS_REPO_URL = "{{ plan.gitops.url }}"
|
|
19
|
+
RELEASE_ENVIRONMENT = "{{ defaults.RELEASE_ENVIRONMENT }}"
|
|
20
|
+
GIT_TOKEN_USER = "{{ git_token_user }}"
|
|
21
|
+
PYTHON_IMAGE = "{{ defaults.PYTHON_IMAGE }}"
|
|
22
|
+
GIT_IMAGE = "{{ defaults.GIT_IMAGE }}"
|
|
23
|
+
|
|
24
|
+
Source = Annotated[dagger.Directory, DefaultPath("/"), Doc("repository root")]
|
|
25
|
+
|
|
26
|
+
RELEASE_SCRIPT = r"""
|
|
27
|
+
set -eu
|
|
28
|
+
git clone -q --depth 1 "https://${GIT_TOKEN_USER}:${GIT_TOKEN}@${GITOPS_REPO_URL#https://}" /gitops
|
|
29
|
+
cp -R /release/. /gitops/
|
|
30
|
+
cd /gitops
|
|
31
|
+
git config user.name "${PROJECT} CI"
|
|
32
|
+
git config user.email "ci@${PROJECT}.invalid"
|
|
33
|
+
git add -A
|
|
34
|
+
if git diff --cached --quiet; then echo "nothing to release"; exit 0; fi
|
|
35
|
+
git commit -q -m "release: ${RELEASE_SUMMARY}"
|
|
36
|
+
git push -q origin HEAD
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@object_type
|
|
41
|
+
class Ci:
|
|
42
|
+
@function
|
|
43
|
+
async def workloads(self, source: Source) -> list[str]:
|
|
44
|
+
"""List discovered workloads as name=directory."""
|
|
45
|
+
return [f"{name}={path}" for name, path in await _discover(source)]
|
|
46
|
+
|
|
47
|
+
@function
|
|
48
|
+
async def test(self, source: Source) -> str:
|
|
49
|
+
"""Run every workload's unit tests."""
|
|
50
|
+
reports = []
|
|
51
|
+
for name, path in await _discover(source):
|
|
52
|
+
report = await (
|
|
53
|
+
dag.container()
|
|
54
|
+
.from_(PYTHON_IMAGE)
|
|
55
|
+
.with_directory("/src", source.directory(path))
|
|
56
|
+
.with_workdir("/src")
|
|
57
|
+
.with_exec(["python", "-m", "unittest", "discover", "-s", "tests", "-t", ".", "-v"])
|
|
58
|
+
.stderr()
|
|
59
|
+
)
|
|
60
|
+
reports.append(f"== {name}\n{report}")
|
|
61
|
+
return "\n".join(reports)
|
|
62
|
+
|
|
63
|
+
@function
|
|
64
|
+
async def build(
|
|
65
|
+
self, workload: Annotated[str, Doc("workload name")], source: Source
|
|
66
|
+
) -> dagger.Container:
|
|
67
|
+
"""Build one workload's container image."""
|
|
68
|
+
paths = dict(await _discover(source))
|
|
69
|
+
if workload not in paths:
|
|
70
|
+
raise ValueError(f"unknown workload {workload!r}; known: {', '.join(paths)}")
|
|
71
|
+
return source.directory(paths[workload]).docker_build()
|
|
72
|
+
|
|
73
|
+
@function
|
|
74
|
+
async def ship(
|
|
75
|
+
self,
|
|
76
|
+
commit: Annotated[str, Doc("commit SHA being shipped; used as image tag and app revision")],
|
|
77
|
+
registry_username: str,
|
|
78
|
+
registry_password: dagger.Secret,
|
|
79
|
+
git_token: Annotated[dagger.Secret, Doc("token that can push to the GitOps repository")],
|
|
80
|
+
source: Source,
|
|
81
|
+
) -> str:
|
|
82
|
+
"""Test, publish every image, then record the release in the GitOps repository."""
|
|
83
|
+
await self.test(source)
|
|
84
|
+
registry_host = REGISTRY.split("/", 1)[0]
|
|
85
|
+
release = dag.directory()
|
|
86
|
+
published, names = [], []
|
|
87
|
+
for name, path in await _discover(source):
|
|
88
|
+
ref = await (
|
|
89
|
+
source.directory(path)
|
|
90
|
+
.docker_build()
|
|
91
|
+
.with_registry_auth(registry_host, registry_username, registry_password)
|
|
92
|
+
.publish(f"{REGISTRY}/{PROJECT}-{name}:{commit}")
|
|
93
|
+
)
|
|
94
|
+
record = {
|
|
95
|
+
"workload": name,
|
|
96
|
+
"image": ref,
|
|
97
|
+
"revision": commit,
|
|
98
|
+
"appRepoURL": APP_REPO_URL,
|
|
99
|
+
"scorePath": "score.yaml" if path == "." else posixpath.join(path, "score.yaml"),
|
|
100
|
+
}
|
|
101
|
+
release = release.with_new_file(
|
|
102
|
+
f"environments/{RELEASE_ENVIRONMENT}/{name}/release.yaml",
|
|
103
|
+
yaml.safe_dump(record, sort_keys=False),
|
|
104
|
+
)
|
|
105
|
+
published.append(ref)
|
|
106
|
+
names.append(name)
|
|
107
|
+
|
|
108
|
+
await (
|
|
109
|
+
dag.container()
|
|
110
|
+
.from_(GIT_IMAGE)
|
|
111
|
+
.with_exec(["apk", "add", "--no-cache", "git"])
|
|
112
|
+
.with_directory("/release", release)
|
|
113
|
+
.with_env_variable("GITOPS_REPO_URL", GITOPS_REPO_URL)
|
|
114
|
+
.with_env_variable("GIT_TOKEN_USER", GIT_TOKEN_USER)
|
|
115
|
+
.with_env_variable("PROJECT", PROJECT)
|
|
116
|
+
.with_env_variable("RELEASE_SUMMARY", f"{', '.join(names)} {commit[:12]}")
|
|
117
|
+
.with_secret_variable("GIT_TOKEN", git_token)
|
|
118
|
+
.with_exec(["sh", "-c", RELEASE_SCRIPT])
|
|
119
|
+
.sync()
|
|
120
|
+
)
|
|
121
|
+
return "\n".join(published)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
async def _discover(source: dagger.Directory) -> list[tuple[str, str]]:
|
|
125
|
+
found = []
|
|
126
|
+
for path in sorted(await source.glob("**/score.yaml")):
|
|
127
|
+
if path.startswith(".dagger/"):
|
|
128
|
+
continue
|
|
129
|
+
spec = yaml.safe_load(await source.file(path).contents())
|
|
130
|
+
found.append((spec["metadata"]["name"], posixpath.dirname(path) or "."))
|
|
131
|
+
if not found:
|
|
132
|
+
raise ValueError("no score.yaml found; nothing to do")
|
|
133
|
+
return found
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# {{ repo.folder }}
|
|
2
|
+
|
|
3
|
+
GitOps repository for **{{ project.name }}**. It holds desired deployed state only; Argo CD
|
|
4
|
+
syncs it into the cluster. Generated by [plinth](https://github.com/dfoshidero/plinth).
|
|
5
|
+
|
|
6
|
+
## Layout
|
|
7
|
+
|
|
8
|
+
```
|
|
9
|
+
bootstrap/root.yaml app-of-apps: point Argo CD here once
|
|
10
|
+
apps/project.yaml Argo CD project scoping repos and namespaces
|
|
11
|
+
apps/<env>.yaml one ApplicationSet per environment
|
|
12
|
+
charts/workload/ Helm chart that deploys a Score workload
|
|
13
|
+
environments/<env>/<workload>/
|
|
14
|
+
values.yaml per-environment overrides (replicas, ...)
|
|
15
|
+
release.yaml written by CI: image, app revision, score path
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Environments: {{ config.environments | join(", ") }}. Each deploys into namespace `{{ project.name }}-<env>`.
|
|
19
|
+
|
|
20
|
+
An ApplicationSet creates one Argo CD Application per `release.yaml` it finds. Each Application
|
|
21
|
+
renders `charts/workload` with the workload's `score.yaml` (read from its app repo at the released
|
|
22
|
+
commit) plus this repo's `values.yaml`, and the released image.
|
|
23
|
+
|
|
24
|
+
## Workloads
|
|
25
|
+
|
|
26
|
+
{% for app_repo, wl in plan.workloads() %}
|
|
27
|
+
- `{{ wl.name }}` ({{ wl.kind }}) from [{{ app_repo.folder }}]({{ app_repo.url }}), `{{ wl.score_path }}`
|
|
28
|
+
{% endfor %}
|
|
29
|
+
|
|
30
|
+
## Releasing and promoting
|
|
31
|
+
|
|
32
|
+
CI in each app repo writes `environments/{{ defaults.RELEASE_ENVIRONMENT }}/<workload>/release.yaml` on every
|
|
33
|
+
push to main, so `{{ defaults.RELEASE_ENVIRONMENT }}` always runs the latest build. To promote, copy that file to
|
|
34
|
+
another environment and merge it:
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
cp environments/{{ defaults.RELEASE_ENVIRONMENT }}/api/release.yaml environments/production/api/release.yaml
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Nothing deploys to an environment until its `release.yaml` exists.
|
|
41
|
+
|
|
42
|
+
## Run it locally
|
|
43
|
+
|
|
44
|
+
Needs Docker, [k3d](https://k3d.io), kubectl and Helm:
|
|
45
|
+
|
|
46
|
+
```sh
|
|
47
|
+
./scripts/bootstrap-local.sh
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
This creates a k3d cluster, installs Argo CD (chart {{ defaults.ARGO_CD_CHART_VERSION }}) and applies
|
|
51
|
+
`bootstrap/root.yaml`. Push this repo and the app repos first; Argo CD reads them from
|
|
52
|
+
`{{ project.git_base_url }}`. For private repositories, give Argo CD read access, e.g.
|
|
53
|
+
`argocd repocreds add {{ project.git_base_url }} --username USER --password TOKEN`.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
apiVersion: argoproj.io/v1alpha1
|
|
2
|
+
kind: AppProject
|
|
3
|
+
metadata:
|
|
4
|
+
name: {{ project.name }}
|
|
5
|
+
namespace: argocd
|
|
6
|
+
spec:
|
|
7
|
+
description: Workloads for {{ project.name }}
|
|
8
|
+
sourceRepos:
|
|
9
|
+
- "{{ project.git_base_url }}/{{ project.name }}-*"
|
|
10
|
+
destinations:
|
|
11
|
+
- server: https://kubernetes.default.svc
|
|
12
|
+
namespace: "{{ project.name }}-*"
|
|
13
|
+
clusterResourceWhitelist:
|
|
14
|
+
- group: ""
|
|
15
|
+
kind: Namespace
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Apply once: kubectl apply -n argocd -f bootstrap/root.yaml
|
|
2
|
+
# Argo CD then manages everything under apps/.
|
|
3
|
+
apiVersion: argoproj.io/v1alpha1
|
|
4
|
+
kind: Application
|
|
5
|
+
metadata:
|
|
6
|
+
name: {{ project.name }}-root
|
|
7
|
+
namespace: argocd
|
|
8
|
+
spec:
|
|
9
|
+
project: default
|
|
10
|
+
source:
|
|
11
|
+
repoURL: {{ repo.url }}
|
|
12
|
+
targetRevision: HEAD
|
|
13
|
+
path: apps
|
|
14
|
+
destination:
|
|
15
|
+
server: https://kubernetes.default.svc
|
|
16
|
+
namespace: argocd
|
|
17
|
+
syncPolicy:
|
|
18
|
+
automated:
|
|
19
|
+
prune: false
|
|
20
|
+
selfHeal: true
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{{- define "workload.name" -}}
|
|
2
|
+
{{- required "metadata.name is required: pass the workload's score.yaml as values" (.Values.metadata | default dict).name -}}
|
|
3
|
+
{{- end -}}
|
|
4
|
+
|
|
5
|
+
{{- define "workload.selector" -}}
|
|
6
|
+
app.kubernetes.io/name: {{ include "workload.name" . }}
|
|
7
|
+
app.kubernetes.io/instance: {{ .Release.Name }}
|
|
8
|
+
{{- end -}}
|
|
9
|
+
|
|
10
|
+
{{- define "workload.labels" -}}
|
|
11
|
+
{{ include "workload.selector" . }}
|
|
12
|
+
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
|
13
|
+
{{- end -}}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{{- $main := required "containers.main is required in score.yaml" (index (.Values.containers | default dict) "main") -}}
|
|
2
|
+
apiVersion: apps/v1
|
|
3
|
+
kind: Deployment
|
|
4
|
+
metadata:
|
|
5
|
+
name: {{ include "workload.name" . }}
|
|
6
|
+
labels:
|
|
7
|
+
{{- include "workload.labels" . | nindent 4 }}
|
|
8
|
+
spec:
|
|
9
|
+
replicas: {{ .Values.replicas }}
|
|
10
|
+
selector:
|
|
11
|
+
matchLabels:
|
|
12
|
+
{{- include "workload.selector" . | nindent 6 }}
|
|
13
|
+
template:
|
|
14
|
+
metadata:
|
|
15
|
+
labels:
|
|
16
|
+
{{- include "workload.selector" . | nindent 8 }}
|
|
17
|
+
spec:
|
|
18
|
+
securityContext:
|
|
19
|
+
runAsNonRoot: true
|
|
20
|
+
containers:
|
|
21
|
+
- name: main
|
|
22
|
+
image: {{ required "image is required" .Values.image | quote }}
|
|
23
|
+
{{- with $main.command }}
|
|
24
|
+
command:
|
|
25
|
+
{{- toYaml . | nindent 12 }}
|
|
26
|
+
{{- end }}
|
|
27
|
+
{{- with $main.args }}
|
|
28
|
+
args:
|
|
29
|
+
{{- toYaml . | nindent 12 }}
|
|
30
|
+
{{- end }}
|
|
31
|
+
{{- with $main.variables }}
|
|
32
|
+
env:
|
|
33
|
+
{{- range $name, $value := . }}
|
|
34
|
+
- name: {{ $name }}
|
|
35
|
+
value: {{ $value | quote }}
|
|
36
|
+
{{- end }}
|
|
37
|
+
{{- end }}
|
|
38
|
+
{{- with .Values.service }}
|
|
39
|
+
{{- with .ports }}
|
|
40
|
+
ports:
|
|
41
|
+
{{- range $name, $port := . }}
|
|
42
|
+
- name: {{ $name }}
|
|
43
|
+
containerPort: {{ $port.targetPort | default $port.port }}
|
|
44
|
+
protocol: {{ $port.protocol | default "TCP" }}
|
|
45
|
+
{{- end }}
|
|
46
|
+
{{- end }}
|
|
47
|
+
{{- end }}
|
|
48
|
+
{{- with $main.resources }}
|
|
49
|
+
resources:
|
|
50
|
+
{{- toYaml . | nindent 12 }}
|
|
51
|
+
{{- end }}
|
|
52
|
+
{{- with $main.livenessProbe }}
|
|
53
|
+
livenessProbe:
|
|
54
|
+
{{- toYaml . | nindent 12 }}
|
|
55
|
+
{{- end }}
|
|
56
|
+
{{- with $main.readinessProbe }}
|
|
57
|
+
readinessProbe:
|
|
58
|
+
{{- toYaml . | nindent 12 }}
|
|
59
|
+
{{- end }}
|
|
60
|
+
securityContext:
|
|
61
|
+
allowPrivilegeEscalation: false
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{{- with .Values.service }}
|
|
2
|
+
{{- with .ports }}
|
|
3
|
+
apiVersion: v1
|
|
4
|
+
kind: Service
|
|
5
|
+
metadata:
|
|
6
|
+
name: {{ include "workload.name" $ }}
|
|
7
|
+
labels:
|
|
8
|
+
{{- include "workload.labels" $ | nindent 4 }}
|
|
9
|
+
spec:
|
|
10
|
+
selector:
|
|
11
|
+
{{- include "workload.selector" $ | nindent 4 }}
|
|
12
|
+
ports:
|
|
13
|
+
{{- range $name, $port := . }}
|
|
14
|
+
- name: {{ $name }}
|
|
15
|
+
port: {{ $port.port }}
|
|
16
|
+
targetPort: {{ $port.targetPort | default $port.port }}
|
|
17
|
+
protocol: {{ $port.protocol | default "TCP" }}
|
|
18
|
+
{{- end }}
|
|
19
|
+
{{- end }}
|
|
20
|
+
{{- end }}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Each release passes, in order:
|
|
2
|
+
# 1. the workload's score.yaml (metadata, containers.main, service)
|
|
3
|
+
# 2. environments/<env>/<workload>/values.yaml (replicas, ...)
|
|
4
|
+
# 3. image, set from release.yaml
|
|
5
|
+
# Supported Score fields: metadata.name, containers.main (command, args, variables,
|
|
6
|
+
# resources, livenessProbe, readinessProbe) and service.ports. Score `resources`
|
|
7
|
+
# (databases, queues, ...) are not provisioned by this chart.
|
|
8
|
+
image: ""
|
|
9
|
+
replicas: 1
|