Initial Phoenix Sankofa Cloud setup

- Complete project structure with Next.js frontend
- GraphQL API backend with Apollo Server
- Portal application with NextAuth
- Crossplane Proxmox provider
- GitOps configurations
- CI/CD pipelines
- Testing infrastructure (Vitest, Jest, Go tests)
- Error handling and monitoring
- Security hardening
- UI component library
- Documentation
This commit is contained in:
defiQUG
2025-11-28 12:54:33 -08:00
commit 6f28146ac3
229 changed files with 43136 additions and 0 deletions

138
gitops/README.md Normal file
View File

@@ -0,0 +1,138 @@
# GitOps Repository
This repository contains all infrastructure and application definitions managed via ArgoCD GitOps.
## Structure
```
gitops/
├── base/ # Base Kubernetes resources
│ ├── namespaces/ # Namespace definitions
│ ├── rbac/ # RBAC roles and bindings
│ └── kustomization.yaml # Base kustomization
├── overlays/ # Environment-specific overlays
│ ├── dev/ # Development environment
│ ├── staging/ # Staging environment
│ └── prod/ # Production environment
├── apps/ # ArgoCD Application definitions
│ ├── rancher/ # Rancher installation
│ ├── crossplane/ # Crossplane installation
│ ├── argocd/ # ArgoCD self-config
│ ├── vault/ # Vault installation
│ ├── monitoring/ # Prometheus, Grafana, Loki
│ └── portal/ # Portal deployment
├── infrastructure/ # Crossplane infrastructure definitions
│ ├── xrds/ # Composite Resource Definitions
│ ├── compositions/ # Composition templates
│ └── claims/ # Example claims
└── templates/ # Reusable templates
├── vm/ # VM templates
├── cluster/ # K8s cluster templates
└── network/ # Network templates
```
## Usage
### Bootstrap ArgoCD
1. Install ArgoCD on your cluster:
```bash
kubectl create namespace argocd
kubectl apply -n argocd -f https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml
```
2. Apply the root ArgoCD Application:
```bash
kubectl apply -f apps/argocd/root-application.yaml
```
### Deploy to Specific Environment
```bash
# Development
kubectl apply -k overlays/dev/
# Production
kubectl apply -k overlays/prod/
```
## Environment Configuration
Each overlay directory contains:
- `kustomization.yaml` - Environment-specific patches
- `config/` - ConfigMaps and Secrets
- `patches/` - Strategic merge patches
## Infrastructure as Code
Crossplane XRDs and Compositions are defined in `infrastructure/`. These enable high-level resource provisioning through the portal.
### Example: Creating a VM
1. Create a claim:
```bash
kubectl apply -f infrastructure/claims/vm-claim-example.yaml
```
2. Monitor the resource:
```bash
kubectl get proxmoxvm web-server-01
kubectl describe proxmoxvm web-server-01
```
### Compositions
Compositions define reusable templates for common resources:
- `vm-ubuntu.yaml` - Ubuntu VM template
- Additional compositions can be added for other OS images
### Claims
Claims are user-facing resources that use compositions:
- `vm-claim-example.yaml` - Example VM claim
## GitOps Workflow
1. **Developer** creates/modifies resources in this repository
2. **Git** triggers ArgoCD sync (or manual sync)
3. **ArgoCD** applies changes to the cluster
4. **Crossplane** provisions infrastructure based on claims
5. **Monitoring** tracks resource status
## Best Practices
- Always use overlays for environment-specific configurations
- Keep base configurations generic and reusable
- Use Kustomize for configuration management
- Document all custom compositions
- Version control all infrastructure changes
## Troubleshooting
### ArgoCD Sync Issues
```bash
# Check ArgoCD application status
kubectl get applications -n argocd
# View sync logs
argocd app logs <app-name> --tail=100
```
### Crossplane Issues
```bash
# Check provider status
kubectl get providerconfig -n crossplane-system
# View resource events
kubectl describe proxmoxvm <vm-name>
```
## Related Documentation
- [ArgoCD Documentation](https://argo-cd.readthedocs.io/)
- [Crossplane Documentation](https://crossplane.io/docs/)
- [Kustomize Documentation](https://kustomize.io/)

View File

@@ -0,0 +1,26 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: root-apps
namespace: argocd
finalizers:
- resources-finalizer.argocd.argoproj.io
spec:
project: default
source:
repoURL: https://github.com/yourorg/hybrid-cloud-gitops
targetRevision: main
path: gitops/apps
destination:
server: https://kubernetes.default.svc
namespace: argocd
syncPolicy:
automated:
prune: true
selfHeal: true
allowEmpty: false
syncOptions:
- CreateNamespace=true
- PrunePropagationPolicy=foreground
- PruneLast=true

View File

@@ -0,0 +1,50 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: crossplane
namespace: argocd
finalizers:
- resources-finalizer.argocd.argoproj.io
spec:
project: default
source:
repoURL: https://charts.crossplane.io/stable
targetRevision: 1.14.0
chart: crossplane
helm:
releaseName: crossplane
values: |
args:
- --enable-usages
resourcesCrossplane:
limits:
cpu: 1000m
memory: 1Gi
requests:
cpu: 100m
memory: 128Mi
resourcesRBACManager:
limits:
cpu: 500m
memory: 512Mi
requests:
cpu: 50m
memory: 64Mi
provider:
packages:
- crossplane/provider-kubernetes:v0.12.0
- crossplane/provider-helm:v0.15.0
- crossplane/provider-azure:v0.20.0
- crossplane/provider-aws:v0.40.0
- crossplane/provider-gcp:v0.35.0
destination:
server: https://kubernetes.default.svc
namespace: crossplane-system
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
- PrunePropagationPolicy=foreground

View File

@@ -0,0 +1,75 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: monitoring
namespace: argocd
finalizers:
- resources-finalizer.argocd.argoproj.io
spec:
project: default
source:
repoURL: https://prometheus-community.github.io/helm-charts
targetRevision: 48.0.0
chart: kube-prometheus-stack
helm:
releaseName: monitoring
values: |
prometheus:
prometheusSpec:
retention: 30d
storageSpec:
volumeClaimTemplate:
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 500Gi
resources:
requests:
cpu: 1000m
memory: 4Gi
limits:
cpu: 2000m
memory: 8Gi
grafana:
enabled: true
adminPassword: changeme
ingress:
enabled: true
ingressClassName: nginx
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
hosts:
- grafana.yourdomain.com
persistence:
enabled: true
size: 10Gi
alertmanager:
alertmanagerSpec:
retention: 120h
storage:
volumeClaimTemplate:
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 50Gi
prometheusOperator:
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 200m
memory: 256Mi
destination:
server: https://kubernetes.default.svc
namespace: monitoring
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
- PrunePropagationPolicy=foreground

View File

@@ -0,0 +1,60 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: loki
namespace: argocd
finalizers:
- resources-finalizer.argocd.argoproj.io
spec:
project: default
source:
repoURL: https://grafana.github.io/helm-charts
targetRevision: 0.69.0
chart: loki-stack
helm:
releaseName: loki
values: |
loki:
enabled: true
persistence:
enabled: true
size: 100Gi
config:
schema_config:
configs:
- from: "2024-01-01"
store: boltdb-shipper
object_store: filesystem
schema: v11
index:
prefix: index_
period: 24h
storage_config:
boltdb_shipper:
active_index_directory: /loki/boltdb-shipper-active
cache_location: /loki/boltdb-shipper-cache
shared_store: filesystem
filesystem:
directory: /loki/chunks
limits_config:
enforce_metric_name: false
reject_old_samples: true
reject_old_samples_max_age: 168h
promtail:
enabled: true
config:
clients:
- url: http://loki:3100/loki/api/v1/push
grafana:
enabled: false
destination:
server: https://kubernetes.default.svc
namespace: monitoring
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
- PrunePropagationPolicy=foreground

View File

@@ -0,0 +1,24 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: portal
namespace: argocd
finalizers:
- resources-finalizer.argocd.argoproj.io
spec:
project: default
source:
repoURL: https://github.com/yourorg/hybrid-cloud-gitops
targetRevision: main
path: gitops/apps/portal/manifests
destination:
server: https://kubernetes.default.svc
namespace: portal
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
- PrunePropagationPolicy=foreground

View File

@@ -0,0 +1,113 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: portal
namespace: portal
labels:
app: portal
spec:
replicas: 3
selector:
matchLabels:
app: portal
template:
metadata:
labels:
app: portal
spec:
containers:
- name: portal
image: yourregistry/portal:latest
ports:
- containerPort: 3000
name: http
env:
- name: NODE_ENV
value: "production"
- name: KEYCLOAK_URL
valueFrom:
configMapKeyRef:
name: portal-config
key: keycloak-url
- name: CROSSPLANE_API_URL
valueFrom:
configMapKeyRef:
name: portal-config
key: crossplane-api-url
- name: ARGOCD_URL
valueFrom:
configMapKeyRef:
name: portal-config
key: argocd-url
resources:
requests:
cpu: 200m
memory: 512Mi
limits:
cpu: 1000m
memory: 2Gi
livenessProbe:
httpGet:
path: /api/health
port: 3000
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
httpGet:
path: /api/health
port: 3000
initialDelaySeconds: 10
periodSeconds: 5
---
apiVersion: v1
kind: Service
metadata:
name: portal
namespace: portal
spec:
selector:
app: portal
ports:
- port: 80
targetPort: 3000
name: http
type: ClusterIP
---
apiVersion: v1
kind: ConfigMap
metadata:
name: portal-config
namespace: portal
data:
keycloak-url: "https://keycloak.yourdomain.com"
crossplane-api-url: "https://crossplane-api.crossplane-system.svc.cluster.local"
argocd-url: "https://argocd.yourdomain.com"
grafana-url: "https://grafana.yourdomain.com"
loki-url: "https://loki.monitoring.svc.cluster.local:3100"
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: portal
namespace: portal
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
nginx.ingress.kubernetes.io/ssl-redirect: "true"
spec:
ingressClassName: nginx
tls:
- hosts:
- portal.yourdomain.com
secretName: portal-tls
rules:
- host: portal.yourdomain.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: portal
port:
number: 80

View File

@@ -0,0 +1,44 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: rancher
namespace: argocd
finalizers:
- resources-finalizer.argocd.argoproj.io
spec:
project: default
source:
repoURL: https://github.com/rancher/rancher
targetRevision: release/v2.8
path: charts/rancher
helm:
releaseName: rancher
values: |
hostname: rancher.yourdomain.com
replicas: 3
ingress:
enabled: true
ingressClassName: nginx
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
tls: external
rancherImage: rancher/rancher
rancherImageTag: v2.8.0
global:
cattle:
systemDefaultRegistry: ""
extraEnv:
- name: CATTLE_PROMETHEUS_METRICS
value: "true"
destination:
server: https://kubernetes.default.svc
namespace: rancher-system
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
- PrunePropagationPolicy=foreground
- PruneLast=true

View File

@@ -0,0 +1,54 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: vault
namespace: argocd
finalizers:
- resources-finalizer.argocd.argoproj.io
spec:
project: default
source:
repoURL: https://helm.releases.hashicorp.com
targetRevision: 0.24.0
chart: vault
helm:
releaseName: vault
values: |
server:
ha:
enabled: true
replicas: 3
raft:
enabled: true
setNodeId: true
image:
repository: hashicorp/vault
tag: "1.15.0"
service:
type: ClusterIP
ingress:
enabled: true
ingressClassName: nginx
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
hosts:
- host: vault.yourdomain.com
paths:
- /
ui:
enabled: true
injector:
enabled: true
csi:
enabled: true
destination:
server: https://kubernetes.default.svc
namespace: vault
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
- PrunePropagationPolicy=foreground

View File

@@ -0,0 +1,12 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: default
resources:
- namespaces/
- rbac/
commonLabels:
app.kubernetes.io/managed-by: argocd
app.kubernetes.io/part-of: phoenix-sankofa-cloud

View File

@@ -0,0 +1,56 @@
apiVersion: v1
kind: Namespace
metadata:
name: rancher-system
labels:
name: rancher-system
---
apiVersion: v1
kind: Namespace
metadata:
name: crossplane-system
labels:
name: crossplane-system
---
apiVersion: v1
kind: Namespace
metadata:
name: argocd
labels:
name: argocd
---
apiVersion: v1
kind: Namespace
metadata:
name: vault
labels:
name: vault
---
apiVersion: v1
kind: Namespace
metadata:
name: monitoring
labels:
name: monitoring
---
apiVersion: v1
kind: Namespace
metadata:
name: portal
labels:
name: portal
---
apiVersion: v1
kind: Namespace
metadata:
name: keycloak
labels:
name: keycloak
---
apiVersion: v1
kind: Namespace
metadata:
name: infrastructure
labels:
name: infrastructure

View File

@@ -0,0 +1,34 @@
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: hybrid-cloud-admin
rules:
- apiGroups: [""]
resources: ["*"]
verbs: ["*"]
- apiGroups: ["apps"]
resources: ["*"]
verbs: ["*"]
- apiGroups: ["extensions"]
resources: ["*"]
verbs: ["*"]
- apiGroups: ["networking.k8s.io"]
resources: ["*"]
verbs: ["*"]
- apiGroups: ["pkg.crossplane.io"]
resources: ["*"]
verbs: ["*"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: hybrid-cloud-admin-binding
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: hybrid-cloud-admin
subjects:
- kind: ServiceAccount
name: argocd-application-controller
namespace: argocd

View File

@@ -0,0 +1,26 @@
apiVersion: proxmox.yourorg.io/v1alpha1
kind: ProxmoxVM
metadata:
name: web-server-01
namespace: default
spec:
forProvider:
node: pve1
name: web-server-01
cpu: 4
memory: 8Gi
disk: 100Gi
storage: local-lvm
network: vmbr0
image: ubuntu-22.04-cloud
site: us-east-1
userData: |
#cloud-config
users:
- name: admin
ssh-authorized-keys:
- ssh-rsa AAAAB3NzaC1yc2E...
sshKeys:
- ssh-rsa AAAAB3NzaC1yc2E...
providerConfigRef:
name: proxmox-provider-config

View File

@@ -0,0 +1,44 @@
apiVersion: apiextensions.crossplane.io/v1
kind: Composition
metadata:
name: vm-ubuntu
labels:
provider: proxmox
spec:
writeConnectionSecretsToRef:
name: vm-connection-secret
namespace: crossplane-system
compositeTypeRef:
apiVersion: proxmox.yourorg.io/v1alpha1
kind: ProxmoxVM
resources:
- name: proxmox-vm
base:
apiVersion: proxmox.yourorg.io/v1alpha1
kind: ProxmoxVM
spec:
forProvider:
node: pve1
cpu: 2
memory: 4Gi
disk: 50Gi
storage: local-lvm
network: vmbr0
image: ubuntu-22.04-cloud
site: us-east-1
patches:
- type: FromCompositeFieldPath
fromFieldPath: spec.forProvider.name
toFieldPath: spec.forProvider.name
- type: FromCompositeFieldPath
fromFieldPath: spec.forProvider.cpu
toFieldPath: spec.forProvider.cpu
- type: FromCompositeFieldPath
fromFieldPath: spec.forProvider.memory
toFieldPath: spec.forProvider.memory
- type: FromCompositeFieldPath
fromFieldPath: spec.forProvider.disk
toFieldPath: spec.forProvider.disk
- type: FromCompositeFieldPath
fromFieldPath: spec.forProvider.site
toFieldPath: spec.forProvider.site

View File

@@ -0,0 +1,101 @@
apiVersion: apiextensions.crossplane.io/v1
kind: CompositeResourceDefinition
metadata:
name: virtualmachines.proxmox.yourorg.io
spec:
group: proxmox.yourorg.io
names:
kind: VirtualMachine
plural: virtualmachines
versions:
- name: v1alpha1
served: true
referenceable: true
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
properties:
parameters:
type: object
properties:
name:
type: string
description: Name of the virtual machine
node:
type: string
description: Proxmox node to deploy on
cpu:
type: integer
description: Number of CPU cores
default: 2
memory:
type: string
description: Memory in GB (e.g., "4Gi")
default: "4Gi"
disk:
type: string
description: Disk size (e.g., "50Gi")
default: "50Gi"
storage:
type: string
description: Storage pool name
default: "local-lvm"
network:
type: string
description: Network bridge
default: "vmbr0"
image:
type: string
description: OS image template
default: "ubuntu-22.04-cloud"
site:
type: string
description: Proxmox site identifier
required:
- name
- node
- site
required:
- parameters
status:
type: object
properties:
vmId:
type: integer
state:
type: string
ipAddress:
type: string
conditions:
type: array
items:
type: object
properties:
type:
type: string
status:
type: string
reason:
type: string
message:
type: string
additionalPrinterColumns:
- name: VMID
type: integer
jsonPath: .status.vmId
- name: STATE
type: string
jsonPath: .status.state
- name: IP
type: string
jsonPath: .status.ipAddress
- name: AGE
type: date
jsonPath: .metadata.creationTimestamp
claimNames:
kind: VirtualMachineClaim
plural: virtualmachineclaims

View File

@@ -0,0 +1,22 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: default
resources:
- ../../base
patchesStrategicMerge:
- patches/namespace-patch.yaml
- patches/resource-limits-patch.yaml
configMapGenerator:
- name: environment-config
literals:
- ENV=development
- LOG_LEVEL=debug
- REPLICAS=1
commonLabels:
environment: development

View File

@@ -0,0 +1,7 @@
apiVersion: v1
kind: Namespace
metadata:
name: portal
labels:
environment: development

View File

@@ -0,0 +1,19 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: portal
namespace: portal
spec:
replicas: 1
template:
spec:
containers:
- name: portal
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 500m
memory: 1Gi

View File

@@ -0,0 +1,23 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: default
resources:
- ../../base
patchesStrategicMerge:
- patches/namespace-patch.yaml
- patches/resource-limits-patch.yaml
- patches/high-availability-patch.yaml
configMapGenerator:
- name: environment-config
literals:
- ENV=production
- LOG_LEVEL=info
- REPLICAS=3
commonLabels:
environment: production

View File

@@ -0,0 +1,27 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: portal
namespace: portal
spec:
replicas: 3
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
template:
spec:
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
labelSelector:
matchExpressions:
- key: app
operator: In
values:
- portal
topologyKey: kubernetes.io/hostname

View File

@@ -0,0 +1,7 @@
apiVersion: v1
kind: Namespace
metadata:
name: portal
labels:
environment: production

View File

@@ -0,0 +1,19 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: portal
namespace: portal
spec:
replicas: 3
template:
spec:
containers:
- name: portal
resources:
requests:
cpu: 500m
memory: 1Gi
limits:
cpu: 2000m
memory: 4Gi

View File

@@ -0,0 +1,40 @@
apiVersion: v1
kind: Namespace
metadata:
name: "{{ .namespace }}"
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: k3s-cluster-sa
namespace: "{{ .namespace }}"
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: k3s-cluster-admin
rules:
- apiGroups: [""]
resources: ["*"]
verbs: ["*"]
- apiGroups: ["apps"]
resources: ["*"]
verbs: ["*"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: k3s-cluster-admin-binding
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: k3s-cluster-admin
subjects:
- kind: ServiceAccount
name: k3s-cluster-sa
namespace: "{{ .namespace }}"
---
# This is a template for creating a k3s cluster via Crossplane
# The actual implementation would use a Crossplane provider
# to provision VMs and install k3s on them

View File

@@ -0,0 +1,19 @@
apiVersion: proxmox.yourorg.io/v1alpha1
kind: VirtualMachineClaim
metadata:
name: "{{ .name }}"
namespace: "{{ .namespace | default "default" }}"
spec:
compositionRef:
name: virtualmachine.ubuntu.proxmox.yourorg.io
parameters:
name: "{{ .name }}"
node: "{{ .node }}"
cpu: {{ .cpu | default 2 }}
memory: "{{ .memory | default "4Gi" }}"
disk: "{{ .disk | default "50Gi" }}"
storage: "{{ .storage | default "local-lvm" }}"
network: "{{ .network | default "vmbr0" }}"
image: "debian-12-cloud"
site: "{{ .site }}"

View File

@@ -0,0 +1,19 @@
apiVersion: proxmox.yourorg.io/v1alpha1
kind: VirtualMachineClaim
metadata:
name: "{{ .name }}"
namespace: "{{ .namespace | default "default" }}"
spec:
compositionRef:
name: virtualmachine.ubuntu.proxmox.yourorg.io
parameters:
name: "{{ .name }}"
node: "{{ .node }}"
cpu: {{ .cpu | default 2 }}
memory: "{{ .memory | default "4Gi" }}"
disk: "{{ .disk | default "50Gi" }}"
storage: "{{ .storage | default "local-lvm" }}"
network: "{{ .network | default "vmbr0" }}"
image: "ubuntu-20.04-cloud"
site: "{{ .site }}"

View File

@@ -0,0 +1,19 @@
apiVersion: proxmox.yourorg.io/v1alpha1
kind: VirtualMachineClaim
metadata:
name: "{{ .name }}"
namespace: "{{ .namespace | default "default" }}"
spec:
compositionRef:
name: virtualmachine.ubuntu.proxmox.yourorg.io
parameters:
name: "{{ .name }}"
node: "{{ .node }}"
cpu: {{ .cpu | default 2 }}
memory: "{{ .memory | default "4Gi" }}"
disk: "{{ .disk | default "50Gi" }}"
storage: "{{ .storage | default "local-lvm" }}"
network: "{{ .network | default "vmbr0" }}"
image: "ubuntu-22.04-cloud"
site: "{{ .site }}"