implemented gitops scaffolding and migrated docker compose to new folder
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
name: Deploy home services
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
schedule:
|
||||
# Gitea schedules are UTC. 09:00 UTC is 03:00 at the fixed UTC-6 offset.
|
||||
# The job runs each Friday, then gates itself to the first Friday.
|
||||
- cron: "0 9 * * 5"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy-nas1:
|
||||
runs-on: nas1
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Deploy media_nas1
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ "$GITHUB_EVENT_NAME" == "schedule" ]]; then
|
||||
day="$(date -u +%-d)"
|
||||
if (( day > 7 )); then
|
||||
echo "Not the first Friday of the month; skipping NAS1 deployment."
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
python3 composectl.py refresh --stack media_nas1 --runtime docker --prune
|
||||
|
||||
deploy-rhel1:
|
||||
runs-on: rhel1
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Deploy media_rhel1
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ "$GITHUB_EVENT_NAME" == "schedule" ]]; then
|
||||
day="$(date -u +%-d)"
|
||||
if (( day > 7 )); then
|
||||
echo "Not the first Friday of the month; skipping RHEL1 deployment."
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
python3 composectl.py refresh --stack media_rhel1 --runtime podman --prune
|
||||
+12
@@ -22,3 +22,15 @@ htmlcov/
|
||||
.DS_Store
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
# Terraform state and local plans
|
||||
**/.terraform/
|
||||
*.tfstate
|
||||
*.tfstate.*
|
||||
*.tfplan
|
||||
|
||||
# SOPS/age private material
|
||||
age-key.txt
|
||||
*.agekey
|
||||
secrets/**/*.yaml
|
||||
!secrets/**/*.sops.yaml
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
creation_rules:
|
||||
- path_regex: secrets/.*\.sops\.ya?ml$
|
||||
age: age1replace-with-a-cluster-recipient
|
||||
@@ -0,0 +1,30 @@
|
||||
# GitOps Runbook
|
||||
|
||||
## Boundaries
|
||||
|
||||
Terraform owns Talos machine configuration, cluster bootstrap, and the Argo CD Helm release. Argo CD owns resources under `infrastructure/` and `services/` after the ApplicationSets are registered. The existing Compose stacks remain independent under `compose-services/`.
|
||||
|
||||
## First bootstrap
|
||||
|
||||
1. Confirm Talos `v1.14.0`, a supported Kubernetes version, the control-plane endpoint, and both node addresses.
|
||||
2. Confirm the Gitea URL is reachable from the cluster and its certificate is trusted.
|
||||
3. Run Terraform from `terraform/` using a local variable file that is not committed.
|
||||
4. Verify the `argocd` namespace and Argo CD pods.
|
||||
5. Verify the `gitea-repository` Secret has the Argo CD repository label and does not expose credentials in Git.
|
||||
6. Apply `bootstrap/applicationsets/` once.
|
||||
|
||||
## Reconciliation order
|
||||
|
||||
Cilium must become healthy before workloads depending on its networking features. Longhorn must have a usable disk before PVC-backed workloads are considered ready. ApplicationSets use automated sync, pruning, self-healing, and namespace creation, so deleting a chart directory intentionally removes its generated Application resources.
|
||||
|
||||
## SOPS workflow
|
||||
|
||||
Use an age recipient in `.sops.yaml`, encrypt secret manifests as `*.sops.yaml`, and provision the matching private key out-of-band. Do not place age private keys, plaintext values, tokens, or passwords in this repository.
|
||||
|
||||
## Recovery
|
||||
|
||||
If Gitea is unavailable, existing resources remain at their last reconciled state but new changes cannot be applied. Restore repository access, inspect Argo CD Application health, and allow reconciliation to resume. For a workload rollback, revert the Git commit and let Argo CD reconcile the known-good revision.
|
||||
|
||||
## Two-node limitation
|
||||
|
||||
This is not a highly available control plane with one control-plane node. Storage replica counts are intentionally set to one until a second suitable storage node and disk policy are confirmed.
|
||||
@@ -1,20 +1,31 @@
|
||||
# Home Services
|
||||
|
||||
This repository manages home-lab application stacks using compose files and a small Python CLI that works with either Docker or Podman.
|
||||
This repository manages home-lab services through two delivery paths: existing host Compose stacks and a Talos Kubernetes cluster reconciled by Argo CD.
|
||||
|
||||
## Repository layout
|
||||
|
||||
```text
|
||||
services/
|
||||
media/
|
||||
compose-services/
|
||||
media_nas1/
|
||||
compose.yaml
|
||||
media_rhel1/
|
||||
compose.yaml
|
||||
terraform/
|
||||
bootstrap/applicationsets/
|
||||
infrastructure/
|
||||
services/
|
||||
vaultwarden/
|
||||
composectl.py
|
||||
update_containers.sh
|
||||
.gitea/
|
||||
workflows/
|
||||
deploy.yaml
|
||||
```
|
||||
|
||||
- `services/` contains one directory per deployed stack.
|
||||
- Each stack directory contains its own `compose.yaml` file.
|
||||
- `composectl.py` infers the stack name, compose file path, and project name from that directory structure.
|
||||
- `compose-services/` contains host-specific Compose stacks managed by Gitea Actions.
|
||||
- `infrastructure/` and `services/` contain Helm charts discovered by Argo CD ApplicationSets.
|
||||
- `terraform/` bootstraps Talos and installs the Argo CD engine; it does not manage day-two workloads.
|
||||
- `composectl.py` infers Compose stack names and paths from `compose-services/`.
|
||||
- `update_containers.sh` is a compatibility shim that delegates to the Python tool.
|
||||
|
||||
## Runtime support
|
||||
@@ -32,26 +43,61 @@ From the repository root:
|
||||
|
||||
```bash
|
||||
python3 composectl.py --help
|
||||
python3 composectl.py --dry-run --all refresh
|
||||
python3 composectl.py --stack media --dry-run up
|
||||
python3 composectl.py --stack media pull
|
||||
python3 composectl.py --stack media down
|
||||
python3 composectl.py --stack media status
|
||||
python3 composectl.py --dry-run --stack media_nas1 refresh
|
||||
python3 composectl.py --dry-run --stack media_rhel1 refresh
|
||||
python3 composectl.py --stack media_nas1 pull
|
||||
python3 composectl.py --stack media_rhel1 status
|
||||
```
|
||||
|
||||
The shell entrypoint delegates to the Python tool:
|
||||
|
||||
```bash
|
||||
bash update_containers.sh --dry-run --all refresh
|
||||
bash update_containers.sh --dry-run --stack media_rhel1 refresh
|
||||
```
|
||||
|
||||
## Refresh flow
|
||||
|
||||
The repo-wide refresh flow does the following for each configured stack:
|
||||
The refresh flow validates and updates one stack:
|
||||
|
||||
1. `pull`
|
||||
2. `down`
|
||||
3. `up -d`
|
||||
4. `prune`
|
||||
1. `compose config`
|
||||
2. `pull`
|
||||
3. `up -d --remove-orphans`
|
||||
4. optionally `image prune -f` when `--prune` is supplied
|
||||
|
||||
This preserves the same maintenance behavior as the old shell updater, but moves the logic into a portable Python implementation.
|
||||
The `up` operation avoids an unnecessary `down`, reducing downtime during normal updates.
|
||||
|
||||
## GitOps bootstrap
|
||||
|
||||
The Kubernetes path targets the two-node Talos cluster with control plane `10.0.0.99` and worker `10.0.0.246`. The endpoint, Kubernetes version, storage device, Cilium interface, and load-balancer address pool remain explicit inputs because they are site-specific.
|
||||
|
||||
1. Copy `terraform/terraform.tfvars.example` to a local `.tfvars` file and fill in the supported Kubernetes version and stable control-plane endpoint.
|
||||
2. Run `terraform init`, `terraform fmt`, and `terraform validate` from `terraform/`.
|
||||
3. Apply Terraform to generate Talos configuration, bootstrap the control plane, join the worker, and install Argo CD.
|
||||
4. Supply the Gitea credential through Terraform variables or create the Argo CD repository Secret out-of-band. Never commit tokens or private keys.
|
||||
5. Apply both manifests with `kubectl apply -f bootstrap/applicationsets/`.
|
||||
6. Confirm Argo CD discovers `infrastructure/cilium`, `infrastructure/longhorn`, and `services/vaultwarden`.
|
||||
|
||||
Talos uses its default CNI during bootstrap so Argo CD can start. Cilium is then reconciled as the first infrastructure application. Confirm the LAN interface and address pool before enabling production LoadBalancer services. Longhorn also requires a validated disk or mount on the storage node.
|
||||
|
||||
## Secrets
|
||||
|
||||
SOPS with age is the intended secret boundary. The repository policy is in `.sops.yaml`; age private keys and plaintext secret files are ignored. Provision the age private key to the cluster through a protected bootstrap process, then commit only files matching the repository's encrypted-file policy. Vaultwarden expects an existing Secret named `vaultwarden-secrets`.
|
||||
|
||||
## Gitea Actions deployment
|
||||
|
||||
The workflow in `.gitea/workflows/deploy.yaml` deploys each host-specific stack to its matching self-hosted runner:
|
||||
|
||||
- runner label `nas1` deploys `media_nas1` with Docker
|
||||
- runner label `rhel1` deploys `media_rhel1` with Podman
|
||||
|
||||
Install and register one persistent Gitea Actions runner on each server. The runner should use a dedicated service account and should only execute trusted changes from the protected `main` branch.
|
||||
|
||||
Deployments run on:
|
||||
|
||||
- pushes to `main`
|
||||
- manual workflow dispatch
|
||||
- the first Friday of each month at `03:00` using a fixed UTC-6 offset (`09:00 UTC`)
|
||||
|
||||
The scheduled trigger evaluates every Friday and skips dates after the seventh day, leaving only the first Friday eligible. The host jobs always target their own stack; they do not use `--all`.
|
||||
|
||||
To roll back, dispatch the workflow from a known-good commit or revert the change on `main` and redeploy.
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
apiVersion: argoproj.io/v1alpha1
|
||||
kind: ApplicationSet
|
||||
metadata:
|
||||
name: infrastructure
|
||||
namespace: argocd
|
||||
spec:
|
||||
goTemplate: true
|
||||
generators:
|
||||
- git:
|
||||
repoURL: https://git.sorells.me/whentimeslows/home-services.git
|
||||
revision: main
|
||||
directories:
|
||||
- path: infrastructure/*
|
||||
- path: infrastructure/*/templates
|
||||
exclude: true
|
||||
template:
|
||||
metadata:
|
||||
name: 'infrastructure-{{.path.basename}}'
|
||||
labels:
|
||||
app.kubernetes.io/part-of: home-services
|
||||
home-services.sorells.me/layer: infrastructure
|
||||
spec:
|
||||
project: default
|
||||
source:
|
||||
repoURL: https://git.sorells.me/whentimeslows/home-services.git
|
||||
targetRevision: main
|
||||
path: '{{.path.path}}'
|
||||
helm:
|
||||
releaseName: '{{.path.basename}}'
|
||||
destination:
|
||||
server: https://kubernetes.default.svc
|
||||
namespace: '{{.path.basename}}'
|
||||
syncPolicy:
|
||||
automated:
|
||||
prune: true
|
||||
selfHeal: true
|
||||
syncOptions:
|
||||
- CreateNamespace=true
|
||||
@@ -0,0 +1,38 @@
|
||||
apiVersion: argoproj.io/v1alpha1
|
||||
kind: ApplicationSet
|
||||
metadata:
|
||||
name: services
|
||||
namespace: argocd
|
||||
spec:
|
||||
goTemplate: true
|
||||
generators:
|
||||
- git:
|
||||
repoURL: https://git.sorells.me/whentimeslows/home-services.git
|
||||
revision: main
|
||||
directories:
|
||||
- path: services/*
|
||||
- path: services/*/templates
|
||||
exclude: true
|
||||
template:
|
||||
metadata:
|
||||
name: 'service-{{.path.basename}}'
|
||||
labels:
|
||||
app.kubernetes.io/part-of: home-services
|
||||
home-services.sorells.me/layer: service
|
||||
spec:
|
||||
project: default
|
||||
source:
|
||||
repoURL: https://git.sorells.me/whentimeslows/home-services.git
|
||||
targetRevision: main
|
||||
path: '{{.path.path}}'
|
||||
helm:
|
||||
releaseName: '{{.path.basename}}'
|
||||
destination:
|
||||
server: https://kubernetes.default.svc
|
||||
namespace: '{{.path.basename}}'
|
||||
syncPolicy:
|
||||
automated:
|
||||
prune: true
|
||||
selfHeal: true
|
||||
syncOptions:
|
||||
- CreateNamespace=true
|
||||
@@ -100,11 +100,11 @@ services:
|
||||
# IPTV
|
||||
dispatcharr:
|
||||
image: ghcr.io/dispatcharr/dispatcharr:latest
|
||||
container_name: dispatcharr
|
||||
container_name: dispatcharr_rhel1
|
||||
ports:
|
||||
- 9191:9191
|
||||
volumes:
|
||||
- /home/scott/docker/media_services/dispatcharr/config:/data:Z
|
||||
- /home/scott/docker/media_rhel1/dispatcharr/config:/data:Z
|
||||
environment:
|
||||
- PUID=1000
|
||||
- PGID=1000
|
||||
+39
-14
@@ -20,8 +20,10 @@ def detect_runtime(preferred: str | None = None) -> str:
|
||||
The preferred runtime is honored when available; otherwise the first
|
||||
installed runtime from the standard Docker/Podman lookup order is used.
|
||||
"""
|
||||
if preferred and shutil.which(preferred):
|
||||
if preferred:
|
||||
if shutil.which(preferred):
|
||||
return preferred
|
||||
raise RuntimeError(f"Requested runtime '{preferred}' is not installed or available on PATH")
|
||||
|
||||
for runtime in ("docker", "podman"):
|
||||
if shutil.which(runtime):
|
||||
@@ -41,20 +43,22 @@ def build_compose_command(
|
||||
base = [runtime, "compose", "-f", str(compose_file), "-p", project_name]
|
||||
|
||||
if action == "up":
|
||||
return [*base, "up", "-d"]
|
||||
return [*base, "up", "-d", "--remove-orphans"]
|
||||
if action == "pull":
|
||||
return [*base, "pull"]
|
||||
if action == "down":
|
||||
return [*base, "down"]
|
||||
if action == "status":
|
||||
return [*base, "ps"]
|
||||
if action == "config":
|
||||
return [*base, "config"]
|
||||
|
||||
raise ValueError(f"Unsupported action '{action}'")
|
||||
|
||||
|
||||
def discover_compose_files(root: Path) -> list[Path]:
|
||||
"""Return compose files under the services directory."""
|
||||
services_dir = root / "services"
|
||||
services_dir = root / "compose-services"
|
||||
if not services_dir.exists():
|
||||
return []
|
||||
|
||||
@@ -63,21 +67,28 @@ def discover_compose_files(root: Path) -> list[Path]:
|
||||
|
||||
def discover_stack_names(root: Path) -> list[str]:
|
||||
"""Infer stack names from the services directory layout."""
|
||||
services_dir = root / "services"
|
||||
services_dir = root / "compose-services"
|
||||
if not services_dir.exists():
|
||||
return []
|
||||
|
||||
return sorted(path.name for path in services_dir.iterdir() if path.is_dir())
|
||||
|
||||
|
||||
def build_update_commands(runtime: str, compose_file: Path, project_name: str) -> list[list[str]]:
|
||||
"""Return the maintenance sequence used for a full stack refresh."""
|
||||
return [
|
||||
def build_update_commands(
|
||||
runtime: str,
|
||||
compose_file: Path,
|
||||
project_name: str,
|
||||
prune: bool = False,
|
||||
) -> list[list[str]]:
|
||||
"""Return a validated, low-downtime maintenance sequence."""
|
||||
commands = [
|
||||
build_compose_command(runtime=runtime, compose_file=compose_file, project_name=project_name, action="config"),
|
||||
build_compose_command(runtime=runtime, compose_file=compose_file, project_name=project_name, action="pull"),
|
||||
build_compose_command(runtime=runtime, compose_file=compose_file, project_name=project_name, action="down"),
|
||||
build_compose_command(runtime=runtime, compose_file=compose_file, project_name=project_name, action="up"),
|
||||
[runtime, "image", "prune", "-f"],
|
||||
]
|
||||
if prune:
|
||||
commands.append([runtime, "image", "prune", "-f"])
|
||||
return commands
|
||||
|
||||
|
||||
def run_command(command: list[str]) -> subprocess.CompletedProcess[str]:
|
||||
@@ -86,17 +97,21 @@ def run_command(command: list[str]) -> subprocess.CompletedProcess[str]:
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Manage compose stacks with Docker or Podman")
|
||||
parser.add_argument("action", choices=["pull", "up", "down", "prune", "status", "refresh"])
|
||||
parser.add_argument("action", choices=["pull", "up", "down", "prune", "status", "config", "refresh"])
|
||||
parser.add_argument("--stack", default=None)
|
||||
parser.add_argument("--compose-file", type=Path, default=None)
|
||||
parser.add_argument("--project-name", default=None)
|
||||
parser.add_argument("--runtime", choices=["docker", "podman"], default=None)
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
parser.add_argument("--all", action="store_true", help="Run the requested action for every configured stack")
|
||||
parser.add_argument("--all", action="store_true", help="Run the requested action for every discovered stack")
|
||||
parser.add_argument("--prune", action="store_true", help="Remove unused images after refresh")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
runtime = detect_runtime(args.runtime)
|
||||
except RuntimeError as error:
|
||||
parser.error(str(error))
|
||||
|
||||
if args.action == "prune":
|
||||
command = [runtime, "image", "prune", "-f"]
|
||||
@@ -111,15 +126,25 @@ def main() -> int:
|
||||
print(result.stderr, end="", file=sys.stderr)
|
||||
return result.returncode
|
||||
|
||||
stack_names = discover_stack_names(Path(".")) if args.all else [args.stack or "media"]
|
||||
if args.all and args.stack:
|
||||
parser.error("--stack and --all cannot be used together")
|
||||
if not args.all and not args.stack and not args.compose_file:
|
||||
parser.error("--stack is required unless --all is used")
|
||||
|
||||
stack_names = discover_stack_names(Path(".")) if args.all else [args.stack or "custom"]
|
||||
failure_count = 0
|
||||
|
||||
for stack_name in stack_names:
|
||||
compose_file = args.compose_file or Path(f"services/{stack_name}/compose.yaml")
|
||||
compose_file = args.compose_file or Path(f"compose-services/{stack_name}/compose.yaml")
|
||||
project_name = args.project_name or f"{stack_name}_services"
|
||||
|
||||
if args.action == "refresh":
|
||||
commands = build_update_commands(runtime=runtime, compose_file=compose_file, project_name=project_name)
|
||||
commands = build_update_commands(
|
||||
runtime=runtime,
|
||||
compose_file=compose_file,
|
||||
project_name=project_name,
|
||||
prune=args.prune,
|
||||
)
|
||||
if args.dry_run:
|
||||
for command in commands:
|
||||
print(" ".join(command))
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
apiVersion: v2
|
||||
name: cilium
|
||||
description: Cluster-specific Cilium configuration for the home-services cluster.
|
||||
type: application
|
||||
version: 0.1.0
|
||||
appVersion: "1.17.6"
|
||||
dependencies:
|
||||
- name: cilium
|
||||
version: 1.17.6
|
||||
repository: https://helm.cilium.io/
|
||||
@@ -0,0 +1,24 @@
|
||||
# Confirm the interface and address pool before enabling L2 announcements.
|
||||
cilium:
|
||||
kubeProxyReplacement: true
|
||||
k8sServiceHost: 10.0.0.99
|
||||
k8sServicePort: 6443
|
||||
|
||||
ipam:
|
||||
mode: kubernetes
|
||||
|
||||
l2announcements:
|
||||
enabled: true
|
||||
|
||||
externalIPs:
|
||||
enabled: true
|
||||
|
||||
operator:
|
||||
replicas: 1
|
||||
|
||||
# Replace with the LAN interface on both Talos nodes.
|
||||
devices:
|
||||
- "<lan-interface>"
|
||||
|
||||
# Configure a CiliumLoadBalancerIPPool and L2AnnouncementPolicy after
|
||||
# confirming the unused address range in the cluster LAN.
|
||||
@@ -0,0 +1,10 @@
|
||||
apiVersion: v2
|
||||
name: longhorn
|
||||
description: Cluster-specific Longhorn configuration for the home-services cluster.
|
||||
type: application
|
||||
version: 0.1.0
|
||||
appVersion: "1.8.1"
|
||||
dependencies:
|
||||
- name: longhorn
|
||||
version: 1.8.1
|
||||
repository: https://charts.longhorn.io
|
||||
@@ -0,0 +1,13 @@
|
||||
# Longhorn requires a supported disk or mount on every storage node.
|
||||
longhorn:
|
||||
defaultSettings:
|
||||
defaultReplicaCount: 1
|
||||
createDefaultDiskLabeledNodes: false
|
||||
defaultDataPath: /var/lib/longhorn
|
||||
|
||||
persistence:
|
||||
defaultClass: true
|
||||
defaultClassReplicaCount: 1
|
||||
|
||||
longhornUI:
|
||||
replicas: 1
|
||||
@@ -0,0 +1,6 @@
|
||||
apiVersion: v2
|
||||
name: vaultwarden
|
||||
description: Small, persistent Vaultwarden deployment for the home-services cluster.
|
||||
type: application
|
||||
version: 0.1.0
|
||||
appVersion: "1.33.2"
|
||||
@@ -0,0 +1,7 @@
|
||||
{{- define "vaultwarden.name" -}}
|
||||
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "vaultwarden.fullname" -}}
|
||||
{{- printf "%s-%s" .Release.Name (include "vaultwarden.name" .) | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,44 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ include "vaultwarden.fullname" . }}
|
||||
labels:
|
||||
app.kubernetes.io/name: {{ include "vaultwarden.name" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
spec:
|
||||
replicas: {{ .Values.replicaCount }}
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: {{ include "vaultwarden.name" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: {{ include "vaultwarden.name" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
spec:
|
||||
containers:
|
||||
- name: vaultwarden
|
||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
|
||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 80
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: {{ .Values.existingSecret }}
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /data
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /alive
|
||||
port: http
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /alive
|
||||
port: http
|
||||
volumes:
|
||||
- name: data
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ include "vaultwarden.fullname" . }}
|
||||
@@ -0,0 +1,11 @@
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: {{ include "vaultwarden.fullname" . }}
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
storageClassName: {{ .Values.persistence.storageClass }}
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.persistence.size }}
|
||||
@@ -0,0 +1,16 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ include "vaultwarden.fullname" . }}
|
||||
labels:
|
||||
app.kubernetes.io/name: {{ include "vaultwarden.name" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
spec:
|
||||
type: {{ .Values.service.type }}
|
||||
selector:
|
||||
app.kubernetes.io/name: {{ include "vaultwarden.name" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
ports:
|
||||
- name: http
|
||||
port: {{ .Values.service.port }}
|
||||
targetPort: http
|
||||
@@ -0,0 +1,17 @@
|
||||
image:
|
||||
repository: vaultwarden/server
|
||||
tag: 1.33.2
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
replicaCount: 1
|
||||
|
||||
service:
|
||||
type: ClusterIP
|
||||
port: 80
|
||||
|
||||
persistence:
|
||||
size: 5Gi
|
||||
storageClass: longhorn
|
||||
|
||||
# Supply this Secret out-of-band through SOPS/age or External Secrets.
|
||||
existingSecret: vaultwarden-secrets
|
||||
@@ -0,0 +1,113 @@
|
||||
provider "talos" {}
|
||||
|
||||
provider "kubernetes" {
|
||||
host = data.talos_cluster_kubeconfig.cluster.kubernetes_client_configuration.host
|
||||
client_certificate = base64decode(data.talos_cluster_kubeconfig.cluster.kubernetes_client_configuration.client_certificate)
|
||||
client_key = base64decode(data.talos_cluster_kubeconfig.cluster.kubernetes_client_configuration.client_key)
|
||||
cluster_ca_certificate = base64decode(data.talos_cluster_kubeconfig.cluster.kubernetes_client_configuration.ca_certificate)
|
||||
}
|
||||
|
||||
provider "helm" {
|
||||
kubernetes {
|
||||
host = data.talos_cluster_kubeconfig.cluster.kubernetes_client_configuration.host
|
||||
client_certificate = base64decode(data.talos_cluster_kubeconfig.cluster.kubernetes_client_configuration.client_certificate)
|
||||
client_key = base64decode(data.talos_cluster_kubeconfig.cluster.kubernetes_client_configuration.client_key)
|
||||
cluster_ca_certificate = base64decode(data.talos_cluster_kubeconfig.cluster.kubernetes_client_configuration.ca_certificate)
|
||||
}
|
||||
}
|
||||
|
||||
resource "talos_machine_secrets" "cluster" {
|
||||
talos_version = var.talos_version
|
||||
}
|
||||
|
||||
data "talos_machine_configuration" "control_plane" {
|
||||
cluster_name = var.cluster_name
|
||||
cluster_endpoint = var.cluster_endpoint
|
||||
machine_type = "controlplane"
|
||||
talos_version = var.talos_version
|
||||
kubernetes_version = var.kubernetes_version
|
||||
machine_secrets = talos_machine_secrets.cluster.machine_secrets
|
||||
}
|
||||
|
||||
data "talos_machine_configuration" "worker" {
|
||||
cluster_name = var.cluster_name
|
||||
cluster_endpoint = var.cluster_endpoint
|
||||
machine_type = "worker"
|
||||
talos_version = var.talos_version
|
||||
kubernetes_version = var.kubernetes_version
|
||||
machine_secrets = talos_machine_secrets.cluster.machine_secrets
|
||||
}
|
||||
|
||||
resource "talos_machine_configuration_apply" "control_plane" {
|
||||
client_configuration = talos_machine_secrets.cluster.client_configuration
|
||||
machine_configuration_input = data.talos_machine_configuration.control_plane.machine_configuration
|
||||
node = var.control_plane_ip
|
||||
}
|
||||
|
||||
resource "talos_machine_configuration_apply" "worker" {
|
||||
client_configuration = talos_machine_secrets.cluster.client_configuration
|
||||
machine_configuration_input = data.talos_machine_configuration.worker.machine_configuration
|
||||
node = var.worker_ip
|
||||
}
|
||||
|
||||
resource "talos_machine_bootstrap" "cluster" {
|
||||
node = var.control_plane_ip
|
||||
client_configuration = talos_machine_secrets.cluster.client_configuration
|
||||
depends_on = [talos_machine_configuration_apply.control_plane]
|
||||
}
|
||||
|
||||
data "talos_cluster_kubeconfig" "cluster" {
|
||||
client_configuration = talos_machine_secrets.cluster.client_configuration
|
||||
node = var.control_plane_ip
|
||||
endpoints = [var.control_plane_ip]
|
||||
depends_on = [talos_machine_bootstrap.cluster, talos_machine_configuration_apply.worker]
|
||||
}
|
||||
|
||||
resource "helm_release" "argocd" {
|
||||
name = "argocd"
|
||||
namespace = "argocd"
|
||||
create_namespace = true
|
||||
repository = "https://argoproj.github.io/argo-helm"
|
||||
chart = "argo-cd"
|
||||
version = var.argocd_chart_version
|
||||
|
||||
values = [yamlencode({
|
||||
server = {
|
||||
service = {
|
||||
type = "ClusterIP"
|
||||
}
|
||||
}
|
||||
controller = {
|
||||
replicas = 1
|
||||
}
|
||||
repoServer = {
|
||||
replicas = 1
|
||||
}
|
||||
applicationSet = {
|
||||
replicas = 1
|
||||
}
|
||||
})]
|
||||
|
||||
depends_on = [data.talos_cluster_kubeconfig.cluster]
|
||||
}
|
||||
|
||||
resource "kubernetes_secret_v1" "gitea" {
|
||||
metadata {
|
||||
name = "gitea-repository"
|
||||
namespace = "argocd"
|
||||
labels = {
|
||||
"argocd.argoproj.io/secret-type" = "repository"
|
||||
}
|
||||
}
|
||||
|
||||
type = "Opaque"
|
||||
|
||||
data = {
|
||||
type = "git"
|
||||
url = var.gitea_repository_url
|
||||
username = var.gitea_username
|
||||
password = var.gitea_password
|
||||
}
|
||||
|
||||
depends_on = [helm_release.argocd]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
output "kubeconfig" {
|
||||
description = "Kubeconfig for the bootstrapped Talos cluster."
|
||||
value = data.talos_cluster_kubeconfig.cluster.kubeconfig_raw
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
output "argocd_repository_secret_name" {
|
||||
description = "Argo CD repository credential Secret name."
|
||||
value = kubernetes_secret_v1.gitea.metadata[0].name
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
cluster_name = "home-services"
|
||||
talos_version = "v1.14.0"
|
||||
kubernetes_version = "<supported-kubernetes-version>"
|
||||
control_plane_ip = "10.0.0.99"
|
||||
worker_ip = "10.0.0.246"
|
||||
cluster_endpoint = "https://<control-plane-vip>:6443"
|
||||
gitea_repository_url = "https://git.sorells.me/whentimeslows/home-services.git"
|
||||
gitea_username = "<gitea-user>"
|
||||
gitea_password = "<gitea-token>"
|
||||
@@ -0,0 +1,58 @@
|
||||
variable "cluster_name" {
|
||||
type = string
|
||||
description = "Talos cluster name."
|
||||
default = "home-services"
|
||||
}
|
||||
|
||||
variable "talos_version" {
|
||||
type = string
|
||||
description = "Talos version used to generate machine configuration."
|
||||
default = "v1.14.0"
|
||||
}
|
||||
|
||||
variable "kubernetes_version" {
|
||||
type = string
|
||||
description = "Kubernetes version supported by the selected Talos version."
|
||||
}
|
||||
|
||||
variable "control_plane_ip" {
|
||||
type = string
|
||||
description = "Control-plane node address."
|
||||
default = "10.0.0.99"
|
||||
}
|
||||
|
||||
variable "worker_ip" {
|
||||
type = string
|
||||
description = "Worker node address."
|
||||
default = "10.0.0.246"
|
||||
}
|
||||
|
||||
variable "cluster_endpoint" {
|
||||
type = string
|
||||
description = "Stable HTTPS endpoint for the Talos and Kubernetes APIs."
|
||||
}
|
||||
|
||||
variable "gitea_repository_url" {
|
||||
type = string
|
||||
description = "Git URL Argo CD uses for the GitOps repository."
|
||||
default = "https://git.sorells.me/whentimeslows/home-services.git"
|
||||
}
|
||||
|
||||
variable "gitea_username" {
|
||||
type = string
|
||||
description = "Username for HTTPS Gitea repository access."
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "gitea_password" {
|
||||
type = string
|
||||
description = "Token or password for HTTPS Gitea repository access."
|
||||
sensitive = true
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "argocd_chart_version" {
|
||||
type = string
|
||||
description = "Pinned Argo CD chart version."
|
||||
default = "7.7.16"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
terraform {
|
||||
required_version = ">= 1.6.0"
|
||||
|
||||
required_providers {
|
||||
talos = {
|
||||
source = "siderolabs/talos"
|
||||
version = "~> 0.7"
|
||||
}
|
||||
kubernetes = {
|
||||
source = "hashicorp/kubernetes"
|
||||
version = "~> 2.35"
|
||||
}
|
||||
helm = {
|
||||
source = "hashicorp/helm"
|
||||
version = "~> 2.16"
|
||||
}
|
||||
}
|
||||
}
|
||||
+45
-12
@@ -13,26 +13,42 @@ from composectl import (
|
||||
|
||||
class ComposeCtlTests(unittest.TestCase):
|
||||
def test_detect_runtime_returns_installed_backend(self):
|
||||
runtime = detect_runtime(preferred="docker")
|
||||
runtime = detect_runtime()
|
||||
expected = "docker" if shutil.which("docker") else "podman"
|
||||
self.assertEqual(runtime, expected)
|
||||
|
||||
def test_detect_runtime_rejects_unavailable_explicit_runtime(self):
|
||||
if shutil.which("docker"):
|
||||
self.skipTest("Docker is available on this host")
|
||||
with self.assertRaises(RuntimeError):
|
||||
detect_runtime(preferred="docker")
|
||||
|
||||
def test_build_compose_command_uses_selected_runtime(self):
|
||||
cmd = build_compose_command(
|
||||
runtime="podman",
|
||||
compose_file=Path("media/compose.yaml"),
|
||||
project_name="media_services",
|
||||
compose_file=Path("compose-services/media_rhel1/compose.yaml"),
|
||||
project_name="media_rhel1_services",
|
||||
action="up",
|
||||
)
|
||||
self.assertEqual(
|
||||
cmd,
|
||||
["podman", "compose", "-f", "media/compose.yaml", "-p", "media_services", "up", "-d"],
|
||||
[
|
||||
"podman",
|
||||
"compose",
|
||||
"-f",
|
||||
"compose-services/media_rhel1/compose.yaml",
|
||||
"-p",
|
||||
"media_rhel1_services",
|
||||
"up",
|
||||
"-d",
|
||||
"--remove-orphans",
|
||||
],
|
||||
)
|
||||
|
||||
def test_discover_compose_files_finds_existing_stacks(self):
|
||||
stack_files = discover_compose_files(Path("."))
|
||||
self.assertIn(Path("services/media_nas1/compose.yaml"), stack_files)
|
||||
self.assertIn(Path("services/media_rhel1/compose.yaml"), stack_files)
|
||||
self.assertIn(Path("compose-services/media_nas1/compose.yaml"), stack_files)
|
||||
self.assertIn(Path("compose-services/media_rhel1/compose.yaml"), stack_files)
|
||||
|
||||
def test_discover_stack_names_returns_inferred_stack_names(self):
|
||||
stack_names = discover_stack_names(Path("."))
|
||||
@@ -42,19 +58,36 @@ class ComposeCtlTests(unittest.TestCase):
|
||||
def test_build_update_commands_returns_full_stack_refresh_sequence(self):
|
||||
commands = build_update_commands(
|
||||
runtime="podman",
|
||||
compose_file=Path("services/media/compose.yaml"),
|
||||
project_name="media_services",
|
||||
compose_file=Path("compose-services/media_rhel1/compose.yaml"),
|
||||
project_name="media_rhel1_services",
|
||||
)
|
||||
self.assertEqual(
|
||||
commands,
|
||||
[
|
||||
["podman", "compose", "-f", "services/media/compose.yaml", "-p", "media_services", "pull"],
|
||||
["podman", "compose", "-f", "services/media/compose.yaml", "-p", "media_services", "down"],
|
||||
["podman", "compose", "-f", "services/media/compose.yaml", "-p", "media_services", "up", "-d"],
|
||||
["podman", "image", "prune", "-f"],
|
||||
["podman", "compose", "-f", "compose-services/media_rhel1/compose.yaml", "-p", "media_rhel1_services", "config"],
|
||||
["podman", "compose", "-f", "compose-services/media_rhel1/compose.yaml", "-p", "media_rhel1_services", "pull"],
|
||||
["podman", "compose", "-f", "compose-services/media_rhel1/compose.yaml", "-p", "media_rhel1_services", "up", "-d", "--remove-orphans"],
|
||||
],
|
||||
)
|
||||
|
||||
def test_build_update_commands_can_include_image_prune(self):
|
||||
commands = build_update_commands(
|
||||
runtime="docker",
|
||||
compose_file=Path("compose-services/media_nas1/compose.yaml"),
|
||||
project_name="media_nas1_services",
|
||||
prune=True,
|
||||
)
|
||||
self.assertEqual(commands[-1], ["docker", "image", "prune", "-f"])
|
||||
|
||||
def test_build_compose_command_can_validate_configuration(self):
|
||||
command = build_compose_command(
|
||||
runtime="docker",
|
||||
compose_file=Path("compose-services/media_nas1/compose.yaml"),
|
||||
project_name="media_nas1_services",
|
||||
action="config",
|
||||
)
|
||||
self.assertEqual(command[-1], "config")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -4,8 +4,9 @@ set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
if [ "$#" -gt 0 ]; then
|
||||
python3 composectl.py "$@"
|
||||
else
|
||||
python3 composectl.py refresh --all
|
||||
if [ "$#" -eq 0 ]; then
|
||||
printf '%s\n' 'Usage: update_containers.sh refresh --stack <stack-name> [options]' >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
exec python3 composectl.py "$@"
|
||||
|
||||
Reference in New Issue
Block a user