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.
Files changed (49) hide show
  1. plinth/__init__.py +1 -0
  2. plinth/__main__.py +5 -0
  3. plinth/add.py +97 -0
  4. plinth/blueprints/ci/github/dot-github/workflows/ci.yaml.j2 +38 -0
  5. plinth/blueprints/ci/gitlab/dot-gitlab-ci.yml.j2 +36 -0
  6. plinth/blueprints/gitops/applicationset.yaml.j2 +46 -0
  7. plinth/blueprints/gitops/values.yaml.j2 +2 -0
  8. plinth/blueprints/repo/app/README.md.j2 +63 -0
  9. plinth/blueprints/repo/app/dagger.json.j2 +8 -0
  10. plinth/blueprints/repo/app/dot-dagger/dot-gitignore +3 -0
  11. plinth/blueprints/repo/app/dot-dagger/pyproject.toml +12 -0
  12. plinth/blueprints/repo/app/dot-dagger/src/ci/__init__.py +3 -0
  13. plinth/blueprints/repo/app/dot-dagger/src/ci/main.py.j2 +133 -0
  14. plinth/blueprints/repo/app/dot-gitignore +4 -0
  15. plinth/blueprints/repo/gitops/README.md.j2 +53 -0
  16. plinth/blueprints/repo/gitops/apps/project.yaml.j2 +15 -0
  17. plinth/blueprints/repo/gitops/bootstrap/root.yaml.j2 +20 -0
  18. plinth/blueprints/repo/gitops/charts/workload/Chart.yaml +5 -0
  19. plinth/blueprints/repo/gitops/charts/workload/templates/_helpers.tpl +13 -0
  20. plinth/blueprints/repo/gitops/charts/workload/templates/deployment.yaml +61 -0
  21. plinth/blueprints/repo/gitops/charts/workload/templates/service.yaml +20 -0
  22. plinth/blueprints/repo/gitops/charts/workload/values.yaml +9 -0
  23. plinth/blueprints/repo/gitops/scripts/bootstrap-local.sh.j2 +23 -0
  24. plinth/blueprints/workload/common/Dockerfile.j2 +14 -0
  25. plinth/blueprints/workload/common/app/__init__.py +0 -0
  26. plinth/blueprints/workload/common/dot-dockerignore +4 -0
  27. plinth/blueprints/workload/common/score.yaml.j2 +33 -0
  28. plinth/blueprints/workload/common/tests/__init__.py +0 -0
  29. plinth/blueprints/workload/modular-monolith/app/main.py.j2 +63 -0
  30. plinth/blueprints/workload/modular-monolith/app/modules/__init__.py +5 -0
  31. plinth/blueprints/workload/modular-monolith/app/modules/example.py +12 -0
  32. plinth/blueprints/workload/modular-monolith/tests/test_main.py.j2 +42 -0
  33. plinth/blueprints/workload/service/app/main.py.j2 +54 -0
  34. plinth/blueprints/workload/service/tests/test_main.py.j2 +41 -0
  35. plinth/blueprints/workload/worker/app/main.py.j2 +44 -0
  36. plinth/blueprints/workload/worker/tests/test_main.py.j2 +26 -0
  37. plinth/cli.py +185 -0
  38. plinth/config.py +51 -0
  39. plinth/loader.py +49 -0
  40. plinth/plan.py +70 -0
  41. plinth/render.py +119 -0
  42. plinth/schema.py +158 -0
  43. plinth/wizard.py +129 -0
  44. plinth/write.py +72 -0
  45. plinth_cli-0.2.0.dist-info/METADATA +174 -0
  46. plinth_cli-0.2.0.dist-info/RECORD +49 -0
  47. plinth_cli-0.2.0.dist-info/WHEEL +4 -0
  48. plinth_cli-0.2.0.dist-info/entry_points.txt +2 -0
  49. 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
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from plinth.cli import main
4
+
5
+ sys.exit(main())
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,2 @@
1
+ # {{ workload.name }} in {{ environment }}. Merged over the workload's score.yaml.
2
+ replicas: {{ defaults.REPLICAS.get(environment, defaults.DEFAULT_REPLICAS) }}
@@ -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,8 @@
1
+ {
2
+ "name": "ci",
3
+ "engineVersion": "v{{ defaults.DAGGER_VERSION }}",
4
+ "sdk": {
5
+ "source": "python"
6
+ },
7
+ "source": ".dagger"
8
+ }
@@ -0,0 +1,3 @@
1
+ /sdk
2
+ /.venv
3
+ __pycache__/
@@ -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,3 @@
1
+ """CI pipeline for this repository. Run `dagger functions` to list entry points."""
2
+
3
+ from .main import Ci as Ci
@@ -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,4 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ .env
@@ -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,5 @@
1
+ apiVersion: v2
2
+ name: workload
3
+ description: Deploys one workload described by a Score file (score.dev/v1b1).
4
+ type: application
5
+ version: 0.1.0
@@ -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