implemented gitops scaffolding and migrated docker compose to new folder
Deploy home services / deploy-nas1 (push) Has been cancelled
Deploy home services / deploy-rhel1 (push) Has been cancelled

This commit is contained in:
2026-09-20 19:52:32 -06:00
parent 954852a535
commit 2592c96890
27 changed files with 694 additions and 54 deletions
+48
View File
@@ -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
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
creation_rules:
- path_regex: secrets/.*\.sops\.ya?ml$
age: age1replace-with-a-cluster-recipient
+30
View File
@@ -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.
+64 -18
View File
@@ -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
View File
@@ -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))
+10
View File
@@ -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/
+24
View File
@@ -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.
+10
View File
@@ -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
+13
View File
@@ -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
+6
View File
@@ -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" . }}
+11
View File
@@ -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
+17
View File
@@ -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
+113
View File
@@ -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]
}
+10
View File
@@ -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
}
+9
View File
@@ -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>"
+58
View File
@@ -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"
}
+18
View File
@@ -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
View File
@@ -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()
+5 -4
View File
@@ -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 "$@"