From 17d33833f115a552bc930808617d4477079dfef5 Mon Sep 17 00:00:00 2001 From: Marcos Blount Date: Wed, 23 Sep 2026 00:48:26 -0400 Subject: [PATCH 1/2] feat: platform infrastructure, developer tooling, and GitOps validation --- .fluxschema.yml | 13 ++ .github/PULL_REQUEST_TEMPLATE.md | 11 ++ .github/workflows/ci.yml | 24 +++ .gitignore | 3 + .shellcheckrc | 8 + .sops.yaml | 4 + .yamllint | 35 ++++ README.md | 29 ++- Taskfile.yml | 43 ----- bootstrap/ansible/ansible.cfg | 6 - bootstrap/ansible/inventory/homelab.yml | 11 -- bootstrap/ansible/playbooks/k3s.yml | 6 - bootstrap/ansible/playbooks/prep.yml | 6 - bootstrap/ansible/playbooks/site.yml | 3 - .../ansible/roles/base/defaults/main.yml | 15 -- bootstrap/ansible/roles/base/tasks/main.yml | 63 ------- .../roles/k3s_server/defaults/main.yml | 16 -- .../ansible/roles/k3s_server/tasks/main.yml | 72 -------- .../roles/k3s_server/templates/config.yaml.j2 | 9 - .../infrastructure-sources.yaml => apps.yaml} | 8 +- .../sources => apps}/kustomization.yaml | 3 +- clusters/tinycloud/apps/vllm.yaml | 93 ++++++++++ clusters/tinycloud/flux-system/gotk-sync.yaml | 2 +- ...etallb-config.yaml => infrastructure.yaml} | 12 +- .../configs/metallb/config.yaml | 17 -- .../configs/metallb/kustomization.yaml | 4 - .../controllers/metallb/helmrelease.yaml | 25 --- .../controllers/metallb/kustomization.yaml | 5 - .../controllers/metallb/namespace.yaml | 8 - .../infrastructure/envoy-gateway.yaml | 84 +++++++++ .../infrastructure/kustomization.yaml | 7 +- .../infrastructure/metallb-controller.yaml | 16 -- .../tinycloud/infrastructure/metallb.yaml | 63 +++++++ .../infrastructure/nvidia-device-plugin.yaml | 48 +++++ .../tinycloud/infrastructure/secrets.yaml | 24 +++ .../infrastructure/sources/metallb.yaml | 8 - clusters/tinycloud/kustomization.yaml | 6 +- machines/ansible.cfg | 17 ++ machines/inventory/server.yml | 9 + machines/inventory/workstation.yml | 20 +++ machines/playbooks/server.yml | 37 ++++ machines/playbooks/workstation.yml | 19 ++ machines/requirements.yml | 10 ++ machines/roles/base/defaults/main.yml | 68 +++++++ machines/roles/base/handlers/main.yml | 10 ++ machines/roles/base/meta/main.yml | 20 +++ machines/roles/base/tasks/main.yml | 97 ++++++++++ machines/roles/base/templates/sshd_config.j2 | 11 ++ .../roles/base/templates/timesyncd.conf.j2 | 7 + machines/roles/firewall/defaults/main.yml | 20 +++ machines/roles/firewall/handlers/main.yml | 5 + machines/roles/firewall/meta/main.yml | 19 ++ machines/roles/firewall/tasks/main.yml | 81 +++++++++ .../roles/firewall/templates/jail.local.j2 | 11 ++ machines/roles/gpu/defaults/main.yml | 10 ++ machines/roles/gpu/handlers/main.yml | 4 + machines/roles/gpu/meta/main.yml | 19 ++ machines/roles/gpu/tasks/main.yml | 65 +++++++ machines/roles/k3s_server/defaults/main.yml | 22 +++ .../roles/k3s_server/handlers/main.yml | 2 +- machines/roles/k3s_server/meta/main.yml | 21 +++ machines/roles/k3s_server/tasks/main.yml | 127 +++++++++++++ .../roles/k3s_server/templates/config.yaml.j2 | 8 + machines/roles/workstation/defaults/main.yml | 23 +++ machines/roles/workstation/meta/main.yml | 19 ++ machines/roles/workstation/tasks/main.yml | 167 ++++++++++++++++++ mise.toml | 68 +++++++ scripts/bootstrap.sh | 7 + scripts/cluster-test-config.sh | 17 ++ scripts/generate-tls.sh | 32 ++++ scripts/lint-taskfiles.rb | 145 --------------- taskfiles/bootstrap.yml | 46 ----- taskfiles/flux.yml | 47 ----- taskfiles/kubeconfig.yml | 29 --- taskfiles/lint.yml | 7 - taskfiles/tools.yml | 83 --------- 76 files changed, 1529 insertions(+), 710 deletions(-) create mode 100644 .fluxschema.yml create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/workflows/ci.yml create mode 100644 .shellcheckrc create mode 100644 .sops.yaml create mode 100644 .yamllint delete mode 100644 Taskfile.yml delete mode 100644 bootstrap/ansible/ansible.cfg delete mode 100644 bootstrap/ansible/inventory/homelab.yml delete mode 100644 bootstrap/ansible/playbooks/k3s.yml delete mode 100644 bootstrap/ansible/playbooks/prep.yml delete mode 100644 bootstrap/ansible/playbooks/site.yml delete mode 100644 bootstrap/ansible/roles/base/defaults/main.yml delete mode 100644 bootstrap/ansible/roles/base/tasks/main.yml delete mode 100644 bootstrap/ansible/roles/k3s_server/defaults/main.yml delete mode 100644 bootstrap/ansible/roles/k3s_server/tasks/main.yml delete mode 100644 bootstrap/ansible/roles/k3s_server/templates/config.yaml.j2 rename clusters/tinycloud/{infrastructure/infrastructure-sources.yaml => apps.yaml} (66%) rename clusters/tinycloud/{infrastructure/sources => apps}/kustomization.yaml (80%) create mode 100644 clusters/tinycloud/apps/vllm.yaml rename clusters/tinycloud/{infrastructure/metallb-config.yaml => infrastructure.yaml} (57%) delete mode 100644 clusters/tinycloud/infrastructure/configs/metallb/config.yaml delete mode 100644 clusters/tinycloud/infrastructure/configs/metallb/kustomization.yaml delete mode 100644 clusters/tinycloud/infrastructure/controllers/metallb/helmrelease.yaml delete mode 100644 clusters/tinycloud/infrastructure/controllers/metallb/kustomization.yaml delete mode 100644 clusters/tinycloud/infrastructure/controllers/metallb/namespace.yaml create mode 100644 clusters/tinycloud/infrastructure/envoy-gateway.yaml delete mode 100644 clusters/tinycloud/infrastructure/metallb-controller.yaml create mode 100644 clusters/tinycloud/infrastructure/metallb.yaml create mode 100644 clusters/tinycloud/infrastructure/nvidia-device-plugin.yaml create mode 100644 clusters/tinycloud/infrastructure/secrets.yaml delete mode 100644 clusters/tinycloud/infrastructure/sources/metallb.yaml create mode 100644 machines/ansible.cfg create mode 100644 machines/inventory/server.yml create mode 100644 machines/inventory/workstation.yml create mode 100644 machines/playbooks/server.yml create mode 100644 machines/playbooks/workstation.yml create mode 100644 machines/requirements.yml create mode 100644 machines/roles/base/defaults/main.yml create mode 100644 machines/roles/base/handlers/main.yml create mode 100644 machines/roles/base/meta/main.yml create mode 100644 machines/roles/base/tasks/main.yml create mode 100644 machines/roles/base/templates/sshd_config.j2 create mode 100644 machines/roles/base/templates/timesyncd.conf.j2 create mode 100644 machines/roles/firewall/defaults/main.yml create mode 100644 machines/roles/firewall/handlers/main.yml create mode 100644 machines/roles/firewall/meta/main.yml create mode 100644 machines/roles/firewall/tasks/main.yml create mode 100644 machines/roles/firewall/templates/jail.local.j2 create mode 100644 machines/roles/gpu/defaults/main.yml create mode 100644 machines/roles/gpu/handlers/main.yml create mode 100644 machines/roles/gpu/meta/main.yml create mode 100644 machines/roles/gpu/tasks/main.yml create mode 100644 machines/roles/k3s_server/defaults/main.yml rename {bootstrap/ansible => machines}/roles/k3s_server/handlers/main.yml (59%) create mode 100644 machines/roles/k3s_server/meta/main.yml create mode 100644 machines/roles/k3s_server/tasks/main.yml create mode 100644 machines/roles/k3s_server/templates/config.yaml.j2 create mode 100644 machines/roles/workstation/defaults/main.yml create mode 100644 machines/roles/workstation/meta/main.yml create mode 100644 machines/roles/workstation/tasks/main.yml create mode 100644 mise.toml create mode 100755 scripts/bootstrap.sh create mode 100755 scripts/cluster-test-config.sh create mode 100755 scripts/generate-tls.sh delete mode 100644 scripts/lint-taskfiles.rb delete mode 100644 taskfiles/bootstrap.yml delete mode 100644 taskfiles/flux.yml delete mode 100644 taskfiles/kubeconfig.yml delete mode 100644 taskfiles/lint.yml delete mode 100644 taskfiles/tools.yml diff --git a/.fluxschema.yml b/.fluxschema.yml new file mode 100644 index 0000000..a0fc62d --- /dev/null +++ b/.fluxschema.yml @@ -0,0 +1,13 @@ +apiVersion: schema.plugin.fluxcd.io/v1beta1 +kind: Config +validate: + schemaLocation: + - default + - ecosystem + - https://raw.githubusercontent.com/datreeio/CRDs-catalog/main + failFast: true + skipMissingSchemas: false + skipFile: + - '.*' + verbose: true + output: json diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..073607f --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,11 @@ +## What + + + +## Why + + + +## Checklist + +- [ ] `mise run check` passes diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ab5653f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,24 @@ +name: CI + +on: + pull_request: + branches: [main] + +jobs: + validate: + runs-on: ubuntu-latest + env: + ANSIBLE_COLLECTIONS_PATH: machines/collections + steps: + # Install tools (mise manages versions, pipx for ansible-lint) + - uses: actions/checkout@v4 + - uses: jdx/mise-action@v4 + with: + install: true + - run: pipx install ansible-lint + + - name: Decrypt SOPS secrets + run: mkdir -p ~/.sops && echo "${{ secrets.SOPS_AGE_KEY }}" > ~/.sops/age.agekey + + # Validate linting, syntax, and Flux schemas + - run: mise run check diff --git a/.gitignore b/.gitignore index 8afb8f6..a6dbb4b 100644 --- a/.gitignore +++ b/.gitignore @@ -22,8 +22,10 @@ venv/ .retry/ .ansible/ ansible.log +machines/collections/ # Local overrides/secrets +.certs/ *.local.yml *.local.yaml *.local @@ -43,6 +45,7 @@ id_rsa id_rsa.pub id_ed25519 id_ed25519.pub +*.agekey *_rsa *_rsa.pub *_ed25519 diff --git a/.shellcheckrc b/.shellcheckrc new file mode 100644 index 0000000..256dffb --- /dev/null +++ b/.shellcheckrc @@ -0,0 +1,8 @@ +# All repository scripts use Bash. +shell=bash + +# Follow sourced files when scripts add them later. +external-sources=true + +# Keep variable references explicit and catch uninitialized environment assumptions. +enable=check-unassigned-uppercase diff --git a/.sops.yaml b/.sops.yaml new file mode 100644 index 0000000..30bc756 --- /dev/null +++ b/.sops.yaml @@ -0,0 +1,4 @@ +creation_rules: + - path_regex: (^|/)secrets(\.ya?ml|/) + encrypted_regex: "^(data|stringData)$" + age: age1hvn6a2qlvc2wzzax29psfxxuwrc58xv0226ysuphpr3dnhr5k4ysmwv087 diff --git a/.yamllint b/.yamllint new file mode 100644 index 0000000..4eb9c28 --- /dev/null +++ b/.yamllint @@ -0,0 +1,35 @@ +--- +extends: default + +ignore: + - "*/flux-system/*" + - "**/secrets.yaml" + +rules: + # Ansible allows yes/no; Kubernetes prefers true/false + truthy: + allowed-values: ['true', 'false', 'yes', 'no'] + + # Both ecosystems use 120 + line-length: + max: 120 + allow-non-breakable-words: true + allow-non-breakable-inline-mappings: true + + # Ansible files don't always need --- + document-start: disable + + indentation: + spaces: 2 + indent-sequences: true + + comments: + require-starting-space: true + comments-indentation: enable + + trailing-spaces: enable + + empty-lines: + max: 2 + max-start: 0 + max-end: 0 diff --git a/README.md b/README.md index 7619952..7829c31 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,29 @@ -# Overview +# Homelab -A playground for experiments, new tools, and whatever I feel like deploying—all in the name of learning. +A personal homelab for learning, experimenting, and enjoying my passion for +tech outside of work. +## Architecture -## License +### Hardware -This project is licensed under the [MIT LICENSE](LICENSE). +| Component | Details | +|---|---| +| Host | ASUS ROG G700TF desktop server | +| Operating system | Ubuntu Linux | +| CPU | Intel Core Ultra 5 225F | +| Memory | 16 GB | +| Storage | 1 TB NVMe | +| GPU | NVIDIA GeForce RTX 5060 with 8 GB VRAM | +| Workstation | macOS system used for administration and development | + +### Software + +| Component | Responsibility | +|---|---| +| Linux server | Compute, local storage, and cluster host | +| K3s | Container orchestration | +| Flux | Keeps the cluster in sync with Git | +| Ansible | Machine provisioning and configuration | +| GPU runtime | Runs GPU-enabled workloads and AI experiments | +| SOPS and age | Keeps secrets encrypted | diff --git a/Taskfile.yml b/Taskfile.yml deleted file mode 100644 index acefc93..0000000 --- a/Taskfile.yml +++ /dev/null @@ -1,43 +0,0 @@ -version: "3" - -includes: - tools: - taskfile: ./taskfiles/tools.yml - flatten: true - bootstrap: - taskfile: ./taskfiles/bootstrap.yml - flatten: true - kubeconfig: - taskfile: ./taskfiles/kubeconfig.yml - flatten: true - flux: - taskfile: ./taskfiles/flux.yml - flatten: true - lint: - taskfile: ./taskfiles/lint.yml - flatten: true - -vars: - ROOT_DIR: - sh: git rev-parse --show-toplevel - - ANSIBLE_DIR: bootstrap/ansible - INVENTORY: inventory/homelab.yml - ANSIBLE_PLAYBOOK: ansible-playbook -i "{{.INVENTORY}}" - - TOOLS_DIR: "{{.ROOT_DIR}}/.tools" - TOOLS_BIN: "{{.TOOLS_DIR}}/bin" - FLUX_VERSION: v2.8.6 - GH_VERSION: v2.90.0 - - KUBECTL_VERSION: v1.35.3 - KUBECONFIG_DIR: - sh: printf '%s/.kube/tinycloud' "$HOME" - KUBECONFIG_LOCAL: "{{.KUBECONFIG_DIR}}/config" - KUBECONFIG_CLUSTER: default - K3S_SERVER_URL: https://tinycloud:6443 - - FLUX_GITHUB_OWNER: sojournerdev - FLUX_GITHUB_REPOSITORY: tinycloud - FLUX_GITHUB_BRANCH: main - FLUX_CLUSTER_PATH: clusters/tinycloud diff --git a/bootstrap/ansible/ansible.cfg b/bootstrap/ansible/ansible.cfg deleted file mode 100644 index a47331e..0000000 --- a/bootstrap/ansible/ansible.cfg +++ /dev/null @@ -1,6 +0,0 @@ -[defaults] -local_tmp = .ansible/tmp -roles_path = roles - -[ssh_connection] -control_path_dir = .ansible/cp diff --git a/bootstrap/ansible/inventory/homelab.yml b/bootstrap/ansible/inventory/homelab.yml deleted file mode 100644 index 70453dc..0000000 --- a/bootstrap/ansible/inventory/homelab.yml +++ /dev/null @@ -1,11 +0,0 @@ ---- -all: - children: - k3s_servers: - hosts: - homelab: - ansible_host: homelab - ansible_user: ansible - ansible_python_interpreter: /usr/bin/python3 - ansible_ssh_private_key_file: ~/.ssh/ansible - k3s_node_name: tinycloud diff --git a/bootstrap/ansible/playbooks/k3s.yml b/bootstrap/ansible/playbooks/k3s.yml deleted file mode 100644 index 597ddf4..0000000 --- a/bootstrap/ansible/playbooks/k3s.yml +++ /dev/null @@ -1,6 +0,0 @@ ---- -- name: Install K3s server nodes - hosts: k3s_servers - become: true - roles: - - role: k3s_server diff --git a/bootstrap/ansible/playbooks/prep.yml b/bootstrap/ansible/playbooks/prep.yml deleted file mode 100644 index 99baa76..0000000 --- a/bootstrap/ansible/playbooks/prep.yml +++ /dev/null @@ -1,6 +0,0 @@ ---- -- name: Prepare server nodes - hosts: k3s_servers - become: true - roles: - - role: base diff --git a/bootstrap/ansible/playbooks/site.yml b/bootstrap/ansible/playbooks/site.yml deleted file mode 100644 index c78dc03..0000000 --- a/bootstrap/ansible/playbooks/site.yml +++ /dev/null @@ -1,3 +0,0 @@ ---- -- import_playbook: prep.yml -- import_playbook: k3s.yml diff --git a/bootstrap/ansible/roles/base/defaults/main.yml b/bootstrap/ansible/roles/base/defaults/main.yml deleted file mode 100644 index d3eb5f4..0000000 --- a/bootstrap/ansible/roles/base/defaults/main.yml +++ /dev/null @@ -1,15 +0,0 @@ ---- -base_packages: - - ca-certificates - - curl - - python3 - -base_disable_ufw: true - -base_kernel_modules: - - overlay - - br_netfilter - -base_sysctl_settings: - net.bridge.bridge-nf-call-iptables: "1" - net.ipv4.ip_forward: "1" diff --git a/bootstrap/ansible/roles/base/tasks/main.yml b/bootstrap/ansible/roles/base/tasks/main.yml deleted file mode 100644 index 0fc4169..0000000 --- a/bootstrap/ansible/roles/base/tasks/main.yml +++ /dev/null @@ -1,63 +0,0 @@ ---- -- name: Install base packages - ansible.builtin.apt: - name: "{{ base_packages }}" - state: present - update_cache: true # Refresh package metadata before installing. - cache_valid_time: 3600 # Reuse apt metadata for one hour to keep reruns quick. - -- name: Check whether UFW is active - ansible.builtin.command: - cmd: ufw status - register: base_ufw_status - changed_when: false - failed_when: false - -- name: Disable active UFW for Kubernetes networking - ansible.builtin.command: - cmd: ufw disable - when: - - base_disable_ufw - - base_ufw_status.rc == 0 - - "'Status: active' in base_ufw_status.stdout" - -- name: Persist Kubernetes kernel modules across reboots - ansible.builtin.copy: - dest: /etc/modules-load.d/k3s.conf - owner: root - group: root - mode: "0644" - content: | - {% for module in base_kernel_modules %} - {{ module }} - {% endfor %} - -- name: Load Kubernetes kernel modules for current boot - ansible.builtin.command: - cmd: "modprobe {{ item }}" - loop: "{{ base_kernel_modules }}" - changed_when: false - -- name: Persist Kubernetes sysctl settings across reboots - ansible.builtin.copy: - dest: /etc/sysctl.d/99-k3s.conf - owner: root - group: root - mode: "0644" - content: | - {% for key, value in base_sysctl_settings.items() %} - {{ key }} = {{ value }} - {% endfor %} - -- name: Read current Kubernetes sysctl settings - ansible.builtin.command: - cmd: "sysctl -n {{ item.key }}" - loop: "{{ base_sysctl_settings | dict2items }}" - changed_when: false - register: base_current_sysctl_settings - -- name: Apply changed Kubernetes sysctl settings for current boot - ansible.builtin.command: - cmd: "sysctl -w {{ item.item.key }}={{ item.item.value }}" - loop: "{{ base_current_sysctl_settings.results }}" - when: item.stdout != item.item.value diff --git a/bootstrap/ansible/roles/k3s_server/defaults/main.yml b/bootstrap/ansible/roles/k3s_server/defaults/main.yml deleted file mode 100644 index 89ef76e..0000000 --- a/bootstrap/ansible/roles/k3s_server/defaults/main.yml +++ /dev/null @@ -1,16 +0,0 @@ ---- -k3s_version: v1.35.3+k3s1 -k3s_install_exec: server -k3s_install_script_url: https://get.k3s.io - -k3s_config_dir: /etc/rancher/k3s -k3s_config_file: "{{ k3s_config_dir }}/config.yaml" -k3s_install_script_path: "{{ k3s_config_dir }}/install.sh" - -k3s_binary_path: /usr/local/bin/k3s -k3s_status_command: "{{ k3s_binary_path }} kubectl get nodes -o wide" -k3s_node_name: "{{ inventory_hostname }}" -k3s_write_kubeconfig_mode: "0600" - -k3s_disable_components: - - servicelb diff --git a/bootstrap/ansible/roles/k3s_server/tasks/main.yml b/bootstrap/ansible/roles/k3s_server/tasks/main.yml deleted file mode 100644 index f346894..0000000 --- a/bootstrap/ansible/roles/k3s_server/tasks/main.yml +++ /dev/null @@ -1,72 +0,0 @@ ---- -- name: Ensure K3s config directory exists - ansible.builtin.file: - path: "{{ k3s_config_dir }}" - state: directory - owner: root - group: root - mode: "0700" - -- name: Check current K3s version - ansible.builtin.command: - cmd: "{{ k3s_binary_path }} --version" - register: k3s_version_result - changed_when: false - failed_when: false - -- name: Determine whether K3s install is needed - ansible.builtin.set_fact: - k3s_needs_install: "{{ k3s_detected_version != k3s_version }}" - vars: - k3s_detected_version: "{{ (k3s_version_result.stdout | default('')) | regex_search(k3s_version_regex) | default('', true) }}" - k3s_version_regex: "v[0-9]+\\.[0-9]+\\.[0-9]+\\+k3s[0-9]+" - -- name: Render K3s server config - ansible.builtin.template: - src: config.yaml.j2 - dest: "{{ k3s_config_file }}" - owner: root - group: root - mode: "0600" - notify: Restart K3s service - -- name: Download K3s installer - ansible.builtin.get_url: - url: "{{ k3s_install_script_url }}" - dest: "{{ k3s_install_script_path }}" - owner: root - group: root - mode: "0700" - when: k3s_needs_install | bool - -- name: Install requested K3s version - ansible.builtin.command: - cmd: "{{ k3s_install_script_path }}" - environment: - INSTALL_K3S_VERSION: "{{ k3s_version }}" - INSTALL_K3S_EXEC: "{{ k3s_install_exec }}" - when: k3s_needs_install | bool - -- name: Remove K3s installer - ansible.builtin.file: - path: "{{ k3s_install_script_path }}" - state: absent - when: k3s_needs_install | bool - -- name: Ensure K3s service is running - ansible.builtin.systemd: - name: k3s - enabled: true - state: started - -- name: Apply pending K3s restart - ansible.builtin.meta: flush_handlers - -- name: Verify K3s node is healthy - ansible.builtin.command: - cmd: "{{ k3s_status_command }}" - register: k3s_node_health - retries: 12 - delay: 10 - until: k3s_node_health.rc == 0 - changed_when: false diff --git a/bootstrap/ansible/roles/k3s_server/templates/config.yaml.j2 b/bootstrap/ansible/roles/k3s_server/templates/config.yaml.j2 deleted file mode 100644 index b1876f8..0000000 --- a/bootstrap/ansible/roles/k3s_server/templates/config.yaml.j2 +++ /dev/null @@ -1,9 +0,0 @@ ---- -node-name: "{{ k3s_node_name }}" -write-kubeconfig-mode: "{{ k3s_write_kubeconfig_mode }}" -{% if k3s_disable_components | length > 0 %} -disable: -{% for component in k3s_disable_components %} - - {{ component }} -{% endfor %} -{% endif %} diff --git a/clusters/tinycloud/infrastructure/infrastructure-sources.yaml b/clusters/tinycloud/apps.yaml similarity index 66% rename from clusters/tinycloud/infrastructure/infrastructure-sources.yaml rename to clusters/tinycloud/apps.yaml index 4597d16..9fee10f 100644 --- a/clusters/tinycloud/infrastructure/infrastructure-sources.yaml +++ b/clusters/tinycloud/apps.yaml @@ -1,14 +1,16 @@ apiVersion: kustomize.toolkit.fluxcd.io/v1 kind: Kustomization metadata: - name: infrastructure-sources + name: apps namespace: flux-system spec: interval: 30m - path: ./clusters/tinycloud/infrastructure/sources + path: ./clusters/tinycloud/apps prune: true sourceRef: kind: GitRepository name: flux-system + dependsOn: + - name: infrastructure wait: true - timeout: 5m + timeout: 15m diff --git a/clusters/tinycloud/infrastructure/sources/kustomization.yaml b/clusters/tinycloud/apps/kustomization.yaml similarity index 80% rename from clusters/tinycloud/infrastructure/sources/kustomization.yaml rename to clusters/tinycloud/apps/kustomization.yaml index c3b4249..80e160e 100644 --- a/clusters/tinycloud/infrastructure/sources/kustomization.yaml +++ b/clusters/tinycloud/apps/kustomization.yaml @@ -1,4 +1,5 @@ +--- apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization resources: - - metallb.yaml + - vllm.yaml diff --git a/clusters/tinycloud/apps/vllm.yaml b/clusters/tinycloud/apps/vllm.yaml new file mode 100644 index 0000000..286c8f0 --- /dev/null +++ b/clusters/tinycloud/apps/vllm.yaml @@ -0,0 +1,93 @@ +--- +apiVersion: source.toolkit.fluxcd.io/v1 +kind: HelmRepository +metadata: + name: vllm + namespace: flux-system +spec: + interval: 24h + url: https://vllm-project.github.io/production-stack +--- +apiVersion: v1 +kind: Namespace +metadata: + name: vllm +--- +apiVersion: helm.toolkit.fluxcd.io/v2 +kind: HelmRelease +metadata: + name: vllm + namespace: vllm +spec: + interval: 30m + timeout: 15m + chart: + spec: + chart: vllm-stack + version: "0.1.12" + sourceRef: + kind: HelmRepository + name: vllm + namespace: flux-system + interval: 12h + install: + crds: CreateReplace + remediation: + retries: 3 + upgrade: + crds: CreateReplace + remediation: + retries: 3 + values: + servingEngineSpec: + enableEngine: true + modelSpec: + - name: qwen-instruct + runtimeClassName: nvidia + repository: vllm/vllm-openai + tag: "v0.29.0" + modelURL: Qwen/Qwen3-4B-AWQ + replicaCount: 1 + requestCPU: 2 + requestMemory: "4Gi" + requestGPU: 1 + requestGPUType: "nvidia.com/gpu" + limitCPU: "4" + limitMemory: "8Gi" + pvcStorage: "10Gi" + pvcAccessMode: + - ReadWriteOnce + vllmConfig: + tensorParallelSize: 1 + quantization: "awq" + maxModelLen: 4096 + dtype: "float16" + gpuMemoryUtilization: 0.80 + extraArgs: + - "--served-model-name" + - "qwen3-4b" + routerSpec: + tag: "v0.1.12" + imagePullPolicy: IfNotPresent +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: vllm-api + namespace: vllm +spec: + parentRefs: + - group: gateway.networking.k8s.io + kind: Gateway + name: envoy-gateway + namespace: envoy-gateway-system + rules: + - matches: + - path: + type: PathPrefix + value: / + backendRefs: + - group: "" + kind: Service + name: vllm-router-service + port: 80 diff --git a/clusters/tinycloud/flux-system/gotk-sync.yaml b/clusters/tinycloud/flux-system/gotk-sync.yaml index dd61aa0..9dd3c2b 100644 --- a/clusters/tinycloud/flux-system/gotk-sync.yaml +++ b/clusters/tinycloud/flux-system/gotk-sync.yaml @@ -11,7 +11,7 @@ spec: branch: main secretRef: name: flux-system - url: ssh://git@github.com/sojournerdev/tinycloud + url: ssh://git@github.com/sojournerdev/homelab --- apiVersion: kustomize.toolkit.fluxcd.io/v1 kind: Kustomization diff --git a/clusters/tinycloud/infrastructure/metallb-config.yaml b/clusters/tinycloud/infrastructure.yaml similarity index 57% rename from clusters/tinycloud/infrastructure/metallb-config.yaml rename to clusters/tinycloud/infrastructure.yaml index b842b94..9b1dd88 100644 --- a/clusters/tinycloud/infrastructure/metallb-config.yaml +++ b/clusters/tinycloud/infrastructure.yaml @@ -1,16 +1,18 @@ apiVersion: kustomize.toolkit.fluxcd.io/v1 kind: Kustomization metadata: - name: metallb-config + name: infrastructure namespace: flux-system spec: - dependsOn: - - name: metallb-controller interval: 30m - path: ./clusters/tinycloud/infrastructure/configs/metallb + path: ./clusters/tinycloud/infrastructure prune: true sourceRef: kind: GitRepository name: flux-system + decryption: + provider: sops + secretRef: + name: sops-age wait: true - timeout: 5m + timeout: 10m diff --git a/clusters/tinycloud/infrastructure/configs/metallb/config.yaml b/clusters/tinycloud/infrastructure/configs/metallb/config.yaml deleted file mode 100644 index 00b3fad..0000000 --- a/clusters/tinycloud/infrastructure/configs/metallb/config.yaml +++ /dev/null @@ -1,17 +0,0 @@ -apiVersion: metallb.io/v1beta1 -kind: IPAddressPool -metadata: - name: homelab-lan - namespace: metallb-system -spec: - addresses: - - 192.168.1.240-192.168.1.250 ---- -apiVersion: metallb.io/v1beta1 -kind: L2Advertisement -metadata: - name: homelab-lan - namespace: metallb-system -spec: - ipAddressPools: - - homelab-lan diff --git a/clusters/tinycloud/infrastructure/configs/metallb/kustomization.yaml b/clusters/tinycloud/infrastructure/configs/metallb/kustomization.yaml deleted file mode 100644 index bd49a97..0000000 --- a/clusters/tinycloud/infrastructure/configs/metallb/kustomization.yaml +++ /dev/null @@ -1,4 +0,0 @@ -apiVersion: kustomize.config.k8s.io/v1beta1 -kind: Kustomization -resources: - - config.yaml diff --git a/clusters/tinycloud/infrastructure/controllers/metallb/helmrelease.yaml b/clusters/tinycloud/infrastructure/controllers/metallb/helmrelease.yaml deleted file mode 100644 index baa3a67..0000000 --- a/clusters/tinycloud/infrastructure/controllers/metallb/helmrelease.yaml +++ /dev/null @@ -1,25 +0,0 @@ -apiVersion: helm.toolkit.fluxcd.io/v2 -kind: HelmRelease -metadata: - name: metallb - namespace: metallb-system -spec: - interval: 30m - timeout: 10m - chart: - spec: - chart: metallb - version: 0.15.3 - sourceRef: - kind: HelmRepository - name: metallb - namespace: flux-system - interval: 12h - install: - crds: Create - remediation: - retries: 3 - upgrade: - crds: CreateReplace - remediation: - retries: 3 diff --git a/clusters/tinycloud/infrastructure/controllers/metallb/kustomization.yaml b/clusters/tinycloud/infrastructure/controllers/metallb/kustomization.yaml deleted file mode 100644 index 0370974..0000000 --- a/clusters/tinycloud/infrastructure/controllers/metallb/kustomization.yaml +++ /dev/null @@ -1,5 +0,0 @@ -apiVersion: kustomize.config.k8s.io/v1beta1 -kind: Kustomization -resources: - - namespace.yaml - - helmrelease.yaml diff --git a/clusters/tinycloud/infrastructure/controllers/metallb/namespace.yaml b/clusters/tinycloud/infrastructure/controllers/metallb/namespace.yaml deleted file mode 100644 index 2f26516..0000000 --- a/clusters/tinycloud/infrastructure/controllers/metallb/namespace.yaml +++ /dev/null @@ -1,8 +0,0 @@ -apiVersion: v1 -kind: Namespace -metadata: - name: metallb-system - labels: - pod-security.kubernetes.io/enforce: privileged - pod-security.kubernetes.io/audit: privileged - pod-security.kubernetes.io/warn: privileged diff --git a/clusters/tinycloud/infrastructure/envoy-gateway.yaml b/clusters/tinycloud/infrastructure/envoy-gateway.yaml new file mode 100644 index 0000000..d79677c --- /dev/null +++ b/clusters/tinycloud/infrastructure/envoy-gateway.yaml @@ -0,0 +1,84 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: envoy-gateway-system +--- +apiVersion: source.toolkit.fluxcd.io/v1 +kind: OCIRepository +metadata: + name: envoy-gateway + namespace: envoy-gateway-system +spec: + interval: 1h + url: oci://docker.io/envoyproxy/gateway-helm + layerSelector: + mediaType: application/vnd.cncf.helm.chart.content.v1.tar+gzip + operation: copy + ref: + tag: v1.9.1 +--- +apiVersion: helm.toolkit.fluxcd.io/v2 +kind: HelmRelease +metadata: + name: envoy-gateway + namespace: envoy-gateway-system +spec: + interval: 30m + timeout: 10m + releaseName: envoy-gateway + chartRef: + kind: OCIRepository + name: envoy-gateway + install: + crds: CreateReplace + remediation: + retries: 3 + upgrade: + crds: CreateReplace + remediation: + retries: 3 +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: GatewayClass +metadata: + name: envoy-gateway +spec: + controllerName: gateway.envoyproxy.io/gatewayclass-controller +--- +apiVersion: gateway.envoyproxy.io/v1alpha1 +kind: EnvoyProxy +metadata: + name: envoy-proxy + namespace: envoy-gateway-system +spec: + provider: + type: Kubernetes + kubernetes: + envoyService: + type: LoadBalancer + externalTrafficPolicy: Local +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + name: envoy-gateway + namespace: envoy-gateway-system +spec: + gatewayClassName: envoy-gateway + infrastructure: + parametersRef: + group: gateway.envoyproxy.io + kind: EnvoyProxy + name: envoy-proxy + listeners: + - name: https + protocol: HTTPS + port: 443 + allowedRoutes: + namespaces: + from: All + tls: + mode: Terminate + certificateRefs: + - name: gateway-tls + kind: Secret diff --git a/clusters/tinycloud/infrastructure/kustomization.yaml b/clusters/tinycloud/infrastructure/kustomization.yaml index d52bb0a..06d48e5 100644 --- a/clusters/tinycloud/infrastructure/kustomization.yaml +++ b/clusters/tinycloud/infrastructure/kustomization.yaml @@ -1,6 +1,7 @@ apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization resources: - - infrastructure-sources.yaml - - metallb-controller.yaml - - metallb-config.yaml + - metallb.yaml + - envoy-gateway.yaml + - nvidia-device-plugin.yaml + - secrets.yaml diff --git a/clusters/tinycloud/infrastructure/metallb-controller.yaml b/clusters/tinycloud/infrastructure/metallb-controller.yaml deleted file mode 100644 index 3cf03d5..0000000 --- a/clusters/tinycloud/infrastructure/metallb-controller.yaml +++ /dev/null @@ -1,16 +0,0 @@ -apiVersion: kustomize.toolkit.fluxcd.io/v1 -kind: Kustomization -metadata: - name: metallb-controller - namespace: flux-system -spec: - dependsOn: - - name: infrastructure-sources - interval: 30m - path: ./clusters/tinycloud/infrastructure/controllers/metallb - prune: true - sourceRef: - kind: GitRepository - name: flux-system - wait: true - timeout: 5m diff --git a/clusters/tinycloud/infrastructure/metallb.yaml b/clusters/tinycloud/infrastructure/metallb.yaml new file mode 100644 index 0000000..90c7634 --- /dev/null +++ b/clusters/tinycloud/infrastructure/metallb.yaml @@ -0,0 +1,63 @@ +apiVersion: source.toolkit.fluxcd.io/v1 +kind: HelmRepository +metadata: + name: metallb + namespace: flux-system +spec: + interval: 24h + url: https://metallb.github.io/metallb +--- +apiVersion: v1 +kind: Namespace +metadata: + name: metallb-system +--- +apiVersion: helm.toolkit.fluxcd.io/v2 +kind: HelmRelease +metadata: + name: metallb + namespace: metallb-system +spec: + interval: 30m + timeout: 10m + chart: + spec: + chart: metallb + version: 0.16.1 + sourceRef: + kind: HelmRepository + name: metallb + namespace: flux-system + interval: 12h + install: + crds: CreateReplace + remediation: + retries: 3 + upgrade: + crds: CreateReplace + remediation: + retries: 3 + values: + frrk8s: + enabled: false + speaker: + frr: + enabled: false +--- +apiVersion: metallb.io/v1beta1 +kind: IPAddressPool +metadata: + name: lan-pool + namespace: metallb-system +spec: + addresses: + - 192.168.1.240-192.168.1.250 +--- +apiVersion: metallb.io/v1beta1 +kind: L2Advertisement +metadata: + name: lan-advertisement + namespace: metallb-system +spec: + ipAddressPools: + - lan-pool diff --git a/clusters/tinycloud/infrastructure/nvidia-device-plugin.yaml b/clusters/tinycloud/infrastructure/nvidia-device-plugin.yaml new file mode 100644 index 0000000..5fb3932 --- /dev/null +++ b/clusters/tinycloud/infrastructure/nvidia-device-plugin.yaml @@ -0,0 +1,48 @@ +apiVersion: source.toolkit.fluxcd.io/v1 +kind: HelmRepository +metadata: + name: nvidia-device-plugin + namespace: flux-system +spec: + interval: 24h + url: https://nvidia.github.io/k8s-device-plugin +--- +apiVersion: v1 +kind: Namespace +metadata: + name: nvidia-device-plugin +--- +apiVersion: helm.toolkit.fluxcd.io/v2 +kind: HelmRelease +metadata: + name: nvidia-device-plugin + namespace: nvidia-device-plugin +spec: + interval: 30m + timeout: 10m + chart: + spec: + chart: nvidia-device-plugin + version: 0.20.0 + sourceRef: + kind: HelmRepository + name: nvidia-device-plugin + namespace: flux-system + interval: 12h + install: + crds: CreateReplace + remediation: + retries: 3 + upgrade: + crds: CreateReplace + remediation: + retries: 3 + values: + failOnInitError: true + deviceDiscoveryStrategy: nvml + runtimeClassName: nvidia + affinity: {} + devicePlugin: + enabled: true + gfd: + enabled: true diff --git a/clusters/tinycloud/infrastructure/secrets.yaml b/clusters/tinycloud/infrastructure/secrets.yaml new file mode 100644 index 0000000..a5858e8 --- /dev/null +++ b/clusters/tinycloud/infrastructure/secrets.yaml @@ -0,0 +1,24 @@ +apiVersion: v1 +kind: Secret +metadata: + name: gateway-tls + namespace: envoy-gateway-system +type: kubernetes.io/tls +data: + tls.crt: ENC[AES256_GCM,data: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,iv:cWrrUk3ReDheQX4E2453ISrPbQd16XzotYNP4lKeCfo=,tag:hROCPsNOLThYZBStHjFZCw==,type:str] + tls.key: ENC[AES256_GCM,data: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,iv:uyAMZC3LGR9GICx/6Ek7nCBmjAOC60/OFanQNYQhDvQ=,tag:WZQPEHwGQavY4qGt7jG74g==,type:str] +sops: + age: + - enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBsOEJVK21ROTUvNGFKSGhm + blg3QUFVdkhnSHFPYUNLQkd2S0xBcTZGeDJJCjZiU3BmZ2hBTHF3QzVHOFI1OHZV + QjhWTDZYNzFuWnFibE5QMm55Zitmek0KLS0tIDVVNzAwQ05VcEQ3SXQrY1dqQWh6 + d0Jud2I3NGFTUDB4S0V6V3lTOExDdXMKIsK7UNbVtQoFNMNYECi4SvPrQtxIREWb + /w/vb/pexhYJYNNLv9UY6BEkHX3rxjKwLi2HqeUHqoP9MpFDWd3U9w== + -----END AGE ENCRYPTED FILE----- + recipient: age1hvn6a2qlvc2wzzax29psfxxuwrc58xv0226ysuphpr3dnhr5k4ysmwv087 + encrypted_regex: ^(data|stringData)$ + lastmodified: "2026-09-22T22:11:36Z" + mac: ENC[AES256_GCM,data:WxZh6zm0N6EzMzsfb2E2VeFOA2Tq8G1Lc5GGwVmsCusBDRFs1kzsvbi+qbwV6svaBHB1O4FbTNHWLWHLXLsjHTgF4NehRjFmdGMSeWYcS7vRtrxVa5VgDjiYoIckgJO8kyCUSuML3g/+4igVxHHNYz1Ho1GmH8V3C3Cu2FuOZ1M=,iv:Dnkl4fuYqL/bnHiR5nMIU9ntZPFdMFjzc8z821ODkRo=,tag:sY7rLIUT8lGBB8llk3tH8A==,type:str] + version: 3.13.3 diff --git a/clusters/tinycloud/infrastructure/sources/metallb.yaml b/clusters/tinycloud/infrastructure/sources/metallb.yaml deleted file mode 100644 index 2d86825..0000000 --- a/clusters/tinycloud/infrastructure/sources/metallb.yaml +++ /dev/null @@ -1,8 +0,0 @@ -apiVersion: source.toolkit.fluxcd.io/v1 -kind: HelmRepository -metadata: - name: metallb - namespace: flux-system -spec: - interval: 1h - url: https://metallb.github.io/metallb diff --git a/clusters/tinycloud/kustomization.yaml b/clusters/tinycloud/kustomization.yaml index 29ae0fa..6ba923a 100644 --- a/clusters/tinycloud/kustomization.yaml +++ b/clusters/tinycloud/kustomization.yaml @@ -1,5 +1,7 @@ apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization resources: - - flux-system - - infrastructure + - flux-system/gotk-components.yaml + - flux-system/gotk-sync.yaml + - infrastructure.yaml + - apps.yaml diff --git a/machines/ansible.cfg b/machines/ansible.cfg new file mode 100644 index 0000000..51d9b99 --- /dev/null +++ b/machines/ansible.cfg @@ -0,0 +1,17 @@ +[defaults] +local_tmp = .ansible/tmp +roles_path = roles +collections_path = ./collections +host_key_checking = accept-new +timeout = 30 +retry_files_enabled = false +inject_facts_as_vars = false + +[privilege_escalation] +become_method = sudo +become_user = root + +[ssh_connection] +control_path_dir = .ansible/cp +pipelining = true +ssh_args = -o ControlMaster=auto -o ControlPersist=60s -o ServerAliveInterval=30 diff --git a/machines/inventory/server.yml b/machines/inventory/server.yml new file mode 100644 index 0000000..913099e --- /dev/null +++ b/machines/inventory/server.yml @@ -0,0 +1,9 @@ +--- +all: + hosts: + homelab: + ansible_host: homelab + ansible_user: ansible + ansible_python_interpreter: /usr/bin/python3 + ansible_ssh_private_key_file: ~/.ssh/ansible + k3s_server_node_name: tinycloud diff --git a/machines/inventory/workstation.yml b/machines/inventory/workstation.yml new file mode 100644 index 0000000..4cf15f8 --- /dev/null +++ b/machines/inventory/workstation.yml @@ -0,0 +1,20 @@ +--- +all: + children: + workstations: + hosts: + workstation: + ansible_connection: local + ansible_python_interpreter: /opt/homebrew/bin/python3 + homelab_host: homelab + homelab_user: ansible + homelab_kubernetes_server: "https://tinycloud:6443" + + # Delegated host — auto_silent suppresses Python discovery warning. + servers: + hosts: + homelab: + ansible_host: homelab + ansible_user: ansible + ansible_ssh_private_key_file: "~/.ssh/ansible" + ansible_python_interpreter: auto_silent diff --git a/machines/playbooks/server.yml b/machines/playbooks/server.yml new file mode 100644 index 0000000..4f6df9e --- /dev/null +++ b/machines/playbooks/server.yml @@ -0,0 +1,37 @@ +--- +- name: Base hardening and firewall + hosts: all + become: true + roles: + - role: base + - role: firewall + +- name: Provision GPU + hosts: all + become: true + roles: + - role: gpu + +- name: Provision k3s server + hosts: all + become: true + pre_tasks: + - name: Install Ansible collections + delegate_to: localhost + run_once: true + become: false + ansible.builtin.command: + cmd: ansible-galaxy collection install -r ../requirements.yml -p ../collections + chdir: "{{ playbook_dir }}" + changed_when: false + + - name: Install Python dependencies for Ansible + delegate_to: localhost + run_once: true + become: false + ansible.builtin.command: + cmd: pipx inject ansible-core kubernetes + changed_when: false + + roles: + - role: k3s_server diff --git a/machines/playbooks/workstation.yml b/machines/playbooks/workstation.yml new file mode 100644 index 0000000..7b3d8bf --- /dev/null +++ b/machines/playbooks/workstation.yml @@ -0,0 +1,19 @@ +--- +- name: Configure local Ansible controller + hosts: workstation + gather_facts: true + + pre_tasks: + - name: Install Ansible collections + ansible.builtin.command: + cmd: ansible-galaxy collection install -r ../requirements.yml -p ../collections + chdir: "{{ playbook_dir }}" + changed_when: false + + - name: Install Python dependencies for Ansible + ansible.builtin.command: + cmd: pipx inject ansible-core kubernetes + changed_when: false + + roles: + - role: workstation diff --git a/machines/requirements.yml b/machines/requirements.yml new file mode 100644 index 0000000..b899d1b --- /dev/null +++ b/machines/requirements.yml @@ -0,0 +1,10 @@ +--- +collections: + - name: ansible.posix + version: ">=2.2.0" + - name: community.general + version: ">=8.0.0" + - name: community.crypto + version: ">=2.18.0" + - name: kubernetes.core + version: ">=5.0.0" diff --git a/machines/roles/base/defaults/main.yml b/machines/roles/base/defaults/main.yml new file mode 100644 index 0000000..5bf28fb --- /dev/null +++ b/machines/roles/base/defaults/main.yml @@ -0,0 +1,68 @@ +--- +base_packages: + - ca-certificates + - curl + - gnupg + - lsb-release + - python3 + - apt-utils + - jq + - unzip + - htop + - iotop + - tmux + - git + - net-tools + - iputils-ping + - dnsutils + - ethtool + - apt-transport-https + +base_ssh_port: 22 +base_ssh_permit_root_login: "no" +base_ssh_password_authentication: "no" +base_ssh_pubkey_authentication: "yes" +base_ssh_kbd_interactive_auth: "no" +base_ssh_x11_forwarding: "no" +base_ssh_max_auth_tries: 3 +base_ssh_login_grace_time: 60 +base_ssh_client_alive_interval: 300 +base_ssh_client_alive_count: 2 + +base_ntp_servers: + - 0.pool.ntp.org iburst + - 1.pool.ntp.org iburst + - 2.pool.ntp.org iburst + - 3.pool.ntp.org iburst + +base_sysctl: + # Required by k3s: bridge, pod-to-pod routing, IPv6 mesh + net.bridge.bridge-nf-call-iptables: "1" + net.bridge.bridge-nf-call-ip6tables: "1" + net.ipv4.ip_forward: "1" + net.ipv4.conf.all.forwarding: "1" + net.ipv6.conf.all.forwarding: "1" + # Mitigates IP spoofing and ICMP redirect attacks + net.ipv4.conf.all.rp_filter: "1" + net.ipv4.conf.default.rp_filter: "1" + net.ipv4.icmp_echo_ignore_broadcasts: "1" + net.ipv4.conf.all.accept_redirects: "0" + net.ipv4.conf.default.accept_redirects: "0" + net.ipv6.conf.all.accept_redirects: "0" + net.ipv4.conf.all.send_redirects: "0" + net.ipv4.conf.default.send_redirects: "0" + net.ipv4.conf.all.accept_source_route: "0" + # Scales for k3s node with many pods/services + vm.swappiness: "10" + fs.file-max: "2097152" + net.core.somaxconn: "65535" + net.ipv4.tcp_max_syn_backlog: "65535" + +base_kernel_modules: + - overlay + - br_netfilter + - ip_vs + - ip_vs_rr + - ip_vs_wrr + - ip_vs_sh + - nf_conntrack diff --git a/machines/roles/base/handlers/main.yml b/machines/roles/base/handlers/main.yml new file mode 100644 index 0000000..34d52b8 --- /dev/null +++ b/machines/roles/base/handlers/main.yml @@ -0,0 +1,10 @@ +--- +- name: Restart sshd + ansible.builtin.systemd: + name: sshd + state: restarted + +- name: Restart timesyncd + ansible.builtin.systemd: + name: systemd-timesyncd + state: restarted diff --git a/machines/roles/base/meta/main.yml b/machines/roles/base/meta/main.yml new file mode 100644 index 0000000..8abba9a --- /dev/null +++ b/machines/roles/base/meta/main.yml @@ -0,0 +1,20 @@ +--- +galaxy_info: + role_name: base + author: sojournerdev + description: > + Foundational server configuration: system packages, SSH hardening, + NTP synchronization, kernel tuning for k3s, and unattended security + updates. Every other role assumes this has run first. + license: MIT + min_ansible_version: "2.15" + platforms: + - name: Ubuntu + versions: + - noble + galaxy_tags: + - hardening + - ssh + - sysctl + - ntp +dependencies: [] diff --git a/machines/roles/base/tasks/main.yml b/machines/roles/base/tasks/main.yml new file mode 100644 index 0000000..a0e1091 --- /dev/null +++ b/machines/roles/base/tasks/main.yml @@ -0,0 +1,97 @@ +--- +- name: Install base packages + ansible.builtin.apt: + name: "{{ base_packages }}" + state: present + update_cache: true + cache_valid_time: 3600 + tags: base + +- name: Persist kernel modules + ansible.builtin.copy: + dest: /etc/modules-load.d/base.conf + owner: root + group: root + mode: "0644" + content: | + {% for module in base_kernel_modules %} + {{ module }} + {% endfor %} + tags: base + +- name: Load kernel modules + ansible.builtin.command: + cmd: "modprobe {{ item }}" + loop: "{{ base_kernel_modules }}" + register: _base_modprobe + changed_when: _base_modprobe.rc == 0 + tags: base + +- name: Apply sysctl settings + ansible.posix.sysctl: + name: "{{ item.key }}" + value: "{{ item.value }}" + sysctl_set: true + state: present + reload: true + loop: "{{ base_sysctl | dict2items }}" + tags: base + +- name: Deploy hardened sshd_config + ansible.builtin.template: + src: sshd_config.j2 + dest: /etc/ssh/sshd_config.d/99-hardened.conf + owner: root + group: root + mode: "0600" + validate: "sshd -t -f %s" + notify: Restart sshd + tags: base + +- name: Deploy timesyncd config for NTP + ansible.builtin.template: + src: timesyncd.conf.j2 + dest: /etc/systemd/timesyncd.conf + owner: root + group: root + mode: "0644" + notify: Restart timesyncd + tags: base + +- name: Ensure unattended-upgrades is installed + ansible.builtin.apt: + name: unattended-upgrades + state: present + tags: base + +- name: Enable automatic security updates + ansible.builtin.copy: + dest: /etc/apt/apt.conf.d/20auto-upgrades + owner: root + group: root + mode: "0644" + content: | + APT::Periodic::Update-Package-Lists "1"; + APT::Periodic::Unattended-Upgrade "1"; + APT::Periodic::AutocleanInterval "7"; + tags: base + +- name: Apply pending base restarts + ansible.builtin.meta: flush_handlers + tags: base + +- name: Verify sshd is running and accepting connections + ansible.builtin.command: + cmd: ssh -o BatchMode=yes -o ConnectTimeout=3 localhost true + register: base_sshd_check + changed_when: false + failed_when: base_sshd_check.rc != 0 and base_sshd_check.rc != 255 + tags: base + +- name: Verify timesyncd is synchronized + ansible.builtin.command: + cmd: timedatectl show --property=NTPSynchronized + register: base_ntp_sync + changed_when: false + failed_when: "'yes' not in base_ntp_sync.stdout" + tags: base diff --git a/machines/roles/base/templates/sshd_config.j2 b/machines/roles/base/templates/sshd_config.j2 new file mode 100644 index 0000000..90d7b7c --- /dev/null +++ b/machines/roles/base/templates/sshd_config.j2 @@ -0,0 +1,11 @@ +# Managed by Ansible — do not edit manually +Port {{ base_ssh_port }} +PermitRootLogin {{ base_ssh_permit_root_login }} +PubkeyAuthentication {{ base_ssh_pubkey_authentication }} +PasswordAuthentication {{ base_ssh_password_authentication }} +KbdInteractiveAuthentication {{ base_ssh_kbd_interactive_auth }} +X11Forwarding {{ base_ssh_x11_forwarding }} +MaxAuthTries {{ base_ssh_max_auth_tries }} +LoginGraceTime {{ base_ssh_login_grace_time }} +ClientAliveInterval {{ base_ssh_client_alive_interval }} +ClientAliveCountMax {{ base_ssh_client_alive_count }} diff --git a/machines/roles/base/templates/timesyncd.conf.j2 b/machines/roles/base/templates/timesyncd.conf.j2 new file mode 100644 index 0000000..af7dbe7 --- /dev/null +++ b/machines/roles/base/templates/timesyncd.conf.j2 @@ -0,0 +1,7 @@ +# Managed by Ansible — do not edit manually +[Time] +NTP={{ base_ntp_servers | join(' ') }} +FallbackNTP=0.pool.ntp.org 1.pool.ntp.org +RootDistanceMaxSec=5 +PollIntervalMinSec=32 +PollIntervalMaxSec=2048 diff --git a/machines/roles/firewall/defaults/main.yml b/machines/roles/firewall/defaults/main.yml new file mode 100644 index 0000000..630763a --- /dev/null +++ b/machines/roles/firewall/defaults/main.yml @@ -0,0 +1,20 @@ +--- +# k3s manages its own iptables — UFW must stay disabled. +# This role handles host-level security: fail2ban and port documentation. +firewall_required_ports: + tcp: + - {port: 22, desc: "SSH"} + - {port: 6443, desc: "k3s API server"} + - {port: 10250, desc: "kubelet"} + - {port: 80, desc: "HTTP via MetalLB/Traefik"} + - {port: 443, desc: "HTTPS via MetalLB/Traefik"} + udp: + - {port: 8472, desc: "flannel VXLAN overlay"} + tcp_ranges: + - {start: 7946, end: 7946, desc: "MetalLB speaker"} + - {start: 30000, end: 32767, desc: "NodePort range"} + +firewall_fail2ban_enabled: true +firewall_fail2ban_maxretry: 5 +firewall_fail2ban_bantime: 3600 +firewall_fail2ban_findtime: 600 diff --git a/machines/roles/firewall/handlers/main.yml b/machines/roles/firewall/handlers/main.yml new file mode 100644 index 0000000..83fad83 --- /dev/null +++ b/machines/roles/firewall/handlers/main.yml @@ -0,0 +1,5 @@ +--- +- name: Restart fail2ban + ansible.builtin.systemd: + name: fail2ban + state: restarted diff --git a/machines/roles/firewall/meta/main.yml b/machines/roles/firewall/meta/main.yml new file mode 100644 index 0000000..ce8b45e --- /dev/null +++ b/machines/roles/firewall/meta/main.yml @@ -0,0 +1,19 @@ +--- +galaxy_info: + role_name: firewall + author: sojournerdev + description: > + Host-level security for k3s nodes. Disables UFW (k3s manages its own + iptables), installs fail2ban for SSH brute-force protection, and + documents required ports for router configuration. + license: MIT + min_ansible_version: "2.15" + platforms: + - name: Ubuntu + versions: + - noble + galaxy_tags: + - firewall + - fail2ban + - security +dependencies: [] diff --git a/machines/roles/firewall/tasks/main.yml b/machines/roles/firewall/tasks/main.yml new file mode 100644 index 0000000..36ab835 --- /dev/null +++ b/machines/roles/firewall/tasks/main.yml @@ -0,0 +1,81 @@ +--- +- name: Check whether UFW is active + ansible.builtin.command: + cmd: ufw status + register: firewall_ufw_status + changed_when: false + failed_when: false + tags: firewall + +- name: Disable UFW for k3s + ansible.builtin.command: + cmd: ufw disable + register: firewall_ufw_disable + changed_when: "'Status: active' in firewall_ufw_status.stdout" + when: + - firewall_ufw_status.rc == 0 + - "'Status: active' in firewall_ufw_status.stdout" + tags: firewall + +- name: Install fail2ban for SSH brute-force protection + ansible.builtin.apt: + name: fail2ban + state: present + when: firewall_fail2ban_enabled + tags: firewall + +- name: Deploy fail2ban jail config + ansible.builtin.template: + src: jail.local.j2 + dest: /etc/fail2ban/jail.local + owner: root + group: root + mode: "0644" + when: firewall_fail2ban_enabled + notify: Restart fail2ban + tags: firewall + +- name: Enable and start fail2ban + ansible.builtin.systemd: + name: fail2ban + enabled: true + state: started + when: firewall_fail2ban_enabled + tags: firewall + +- name: Apply pending firewall restarts + ansible.builtin.meta: flush_handlers + when: firewall_fail2ban_enabled + tags: firewall + +- name: Verify fail2ban sshd jail is active + ansible.builtin.command: + cmd: fail2ban-client status sshd + register: firewall_fail2ban_status + changed_when: false + retries: 3 + delay: 5 + until: firewall_fail2ban_status.rc == 0 + when: firewall_fail2ban_enabled + tags: firewall + +- name: Display fail2ban status + ansible.builtin.debug: + msg: "{{ firewall_fail2ban_status.stdout_lines }}" + when: firewall_fail2ban_enabled + tags: firewall + +- name: Display required ports for router configuration + ansible.builtin.debug: + msg: | + Required open ports (configure on router/firewall): + {% for p in firewall_required_ports.tcp %} + TCP {{ p.port }} — {{ p.desc }} + {% endfor %} + {% for p in firewall_required_ports.udp %} + UDP {{ p.port }} — {{ p.desc }} + {% endfor %} + {% for r in firewall_required_ports.tcp_ranges %} + TCP {{ r.start }}:{{ r.end }} — {{ r.desc }} + {% endfor %} + tags: firewall diff --git a/machines/roles/firewall/templates/jail.local.j2 b/machines/roles/firewall/templates/jail.local.j2 new file mode 100644 index 0000000..038c575 --- /dev/null +++ b/machines/roles/firewall/templates/jail.local.j2 @@ -0,0 +1,11 @@ +# Managed by Ansible — do not edit manually +[DEFAULT] +bantime = {{ firewall_fail2ban_bantime }} +findtime = {{ firewall_fail2ban_findtime }} +maxretry = {{ firewall_fail2ban_maxretry }} + +[sshd] +enabled = true +port = ssh +filter = sshd +logpath = /var/log/auth.log diff --git a/machines/roles/gpu/defaults/main.yml b/machines/roles/gpu/defaults/main.yml new file mode 100644 index 0000000..fa8abc0 --- /dev/null +++ b/machines/roles/gpu/defaults/main.yml @@ -0,0 +1,10 @@ +--- +gpu_nvidia_driver_package: nvidia-driver-580-open + +gpu_nvidia_container_toolkit_key_url: >- + https://nvidia.github.io/libnvidia-container/gpgkey +gpu_nvidia_container_toolkit_repo: >- + deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] + https://nvidia.github.io/libnvidia-container/stable/deb/$(ARCH) / + +gpu_nvidia_container_toolkit_package: nvidia-container-toolkit diff --git a/machines/roles/gpu/handlers/main.yml b/machines/roles/gpu/handlers/main.yml new file mode 100644 index 0000000..940b129 --- /dev/null +++ b/machines/roles/gpu/handlers/main.yml @@ -0,0 +1,4 @@ +--- +- name: Reboot for nvidia driver + ansible.builtin.reboot: + reboot_timeout: 300 diff --git a/machines/roles/gpu/meta/main.yml b/machines/roles/gpu/meta/main.yml new file mode 100644 index 0000000..ade7246 --- /dev/null +++ b/machines/roles/gpu/meta/main.yml @@ -0,0 +1,19 @@ +--- +galaxy_info: + role_name: gpu + author: sojournerdev + description: > + Installs NVIDIA container toolkit so K3s auto-detects GPU runtimes. + Verifies runtime binary and GPU accessibility after install. + license: MIT + min_ansible_version: "2.15" + platforms: + - name: Ubuntu + versions: + - noble + galaxy_tags: + - nvidia + - gpu + - containerd + - kubernetes +dependencies: [] diff --git a/machines/roles/gpu/tasks/main.yml b/machines/roles/gpu/tasks/main.yml new file mode 100644 index 0000000..aea9341 --- /dev/null +++ b/machines/roles/gpu/tasks/main.yml @@ -0,0 +1,65 @@ +--- +- name: Install NVIDIA driver + ansible.builtin.apt: + name: "{{ gpu_nvidia_driver_package }}" + state: present + update_cache: true + cache_valid_time: 3600 + notify: Reboot for nvidia driver + tags: gpu + +- name: Deploy NVIDIA container toolkit GPG key + ansible.builtin.get_url: + url: "{{ gpu_nvidia_container_toolkit_key_url }}" + dest: /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg + owner: root + group: root + mode: "0644" + tags: gpu + +- name: Configure NVIDIA container toolkit repository + ansible.builtin.copy: + dest: /etc/apt/sources.list.d/nvidia-container-toolkit.list + owner: root + group: root + mode: "0644" + content: "{{ gpu_nvidia_container_toolkit_repo }}" + tags: gpu + +- name: Install NVIDIA container toolkit + ansible.builtin.apt: + name: "{{ gpu_nvidia_container_toolkit_package }}" + state: present + update_cache: true + cache_valid_time: 3600 + tags: gpu + +- name: Verify nvidia-ctk binary + ansible.builtin.command: + cmd: which nvidia-ctk + register: gpu_nvidia_ctk_check + changed_when: false + tags: gpu + +- name: Verify nvidia-container-runtime binary + ansible.builtin.command: + cmd: which nvidia-container-runtime + register: gpu_nvidia_runtime_bin + changed_when: false + tags: gpu + +- name: Flush pending reboot + ansible.builtin.meta: flush_handlers + tags: gpu + +- name: Query GPU status via nvidia-smi + ansible.builtin.command: + cmd: >- + nvidia-smi --query-gpu=name,driver_version,memory.total + --format=csv,noheader + register: gpu_nvidia_smi_output + changed_when: false + retries: 3 + delay: 5 + until: gpu_nvidia_smi_output.rc == 0 + tags: gpu diff --git a/machines/roles/k3s_server/defaults/main.yml b/machines/roles/k3s_server/defaults/main.yml new file mode 100644 index 0000000..4df15ba --- /dev/null +++ b/machines/roles/k3s_server/defaults/main.yml @@ -0,0 +1,22 @@ +--- +k3s_server_version: v1.35.3+k3s1 + +k3s_server_install_exec: server +k3s_server_install_script_url: https://get.k3s.io + +k3s_server_config_dir: /etc/rancher/k3s +k3s_server_config_file: "{{ k3s_server_config_dir }}/config.yaml" +k3s_server_install_script_path: "{{ k3s_server_config_dir }}/install.sh" + +k3s_server_binary_path: /usr/local/bin/k3s +k3s_server_status_command: >- + {{ k3s_server_binary_path }} kubectl get nodes -o wide +k3s_server_node_name: "{{ inventory_hostname }}" +k3s_server_write_kubeconfig_mode: "0600" + +k3s_server_disable_components: + - servicelb + - traefik + +# Age key for SOPS — path on the workstation where the key is generated. +k3s_server_age_key_path: "{{ lookup('env', 'HOME') }}/.sops/age.agekey" diff --git a/bootstrap/ansible/roles/k3s_server/handlers/main.yml b/machines/roles/k3s_server/handlers/main.yml similarity index 59% rename from bootstrap/ansible/roles/k3s_server/handlers/main.yml rename to machines/roles/k3s_server/handlers/main.yml index 289afdf..33d94ce 100644 --- a/bootstrap/ansible/roles/k3s_server/handlers/main.yml +++ b/machines/roles/k3s_server/handlers/main.yml @@ -3,4 +3,4 @@ ansible.builtin.systemd: name: k3s state: restarted - when: not (k3s_needs_install | default(false) | bool) + when: not (k3s_server_needs_install | default(false) | bool) diff --git a/machines/roles/k3s_server/meta/main.yml b/machines/roles/k3s_server/meta/main.yml new file mode 100644 index 0000000..35e1a18 --- /dev/null +++ b/machines/roles/k3s_server/meta/main.yml @@ -0,0 +1,21 @@ +--- +galaxy_info: + role_name: k3s_server + author: sojournerdev + description: > + Installs and configures k3s as a single-node Kubernetes server. + Compares installed version against desired, installs only when needed, + and verifies cluster health after bootstrap. Uses system containerd + socket for CRI. + license: MIT + min_ansible_version: "2.15" + platforms: + - name: Ubuntu + versions: + - noble + galaxy_tags: + - k3s + - kubernetes + - k8s + - container +dependencies: [] diff --git a/machines/roles/k3s_server/tasks/main.yml b/machines/roles/k3s_server/tasks/main.yml new file mode 100644 index 0000000..b8b1824 --- /dev/null +++ b/machines/roles/k3s_server/tasks/main.yml @@ -0,0 +1,127 @@ +--- +- name: Assert K3s version is valid semver + ansible.builtin.assert: + that: + - k3s_server_version is match('^v[0-9]+\.[0-9]+\.[0-9]+\+k3s[0-9]+$') + fail_msg: >- + k3s_server_version '{{ k3s_server_version }}' must match vX.Y.Z+k3sN + tags: [k3s] + +- name: Ensure K3s config directory exists + ansible.builtin.file: + path: "{{ k3s_server_config_dir }}" + state: directory + owner: root + group: root + mode: "0700" + tags: k3s + +- name: Check current K3s version + ansible.builtin.command: + cmd: "{{ k3s_server_binary_path }} --version" + register: k3s_server_version_result + changed_when: false + failed_when: false + tags: k3s + +- name: Check K3s version against desired + ansible.builtin.set_fact: + k3s_server_needs_install: >- + {{ k3s_server_detected_version != k3s_server_version }} + vars: + k3s_server_detected_version: >- + {{ (k3s_server_version_result.stdout | default('')) + | regex_search(k3s_server_version_regex) + | default('', true) }} + k3s_server_version_regex: "v[0-9]+\\.[0-9]+\\.[0-9]+\\+k3s[0-9]+" + tags: k3s + +- name: Render K3s server config + ansible.builtin.template: + src: config.yaml.j2 + dest: "{{ k3s_server_config_file }}" + owner: root + group: root + mode: "0600" + notify: Restart K3s service + tags: k3s + +- name: Download K3s installer + ansible.builtin.get_url: + url: "{{ k3s_server_install_script_url }}" + dest: "{{ k3s_server_install_script_path }}" + owner: root + group: root + mode: "0700" + when: k3s_server_needs_install | bool + tags: k3s + +- name: Install requested K3s version + ansible.builtin.command: + cmd: "{{ k3s_server_install_script_path }}" + environment: + INSTALL_K3S_VERSION: "{{ k3s_server_version }}" + INSTALL_K3S_EXEC: "{{ k3s_server_install_exec }}" + register: k3s_server_install + changed_when: k3s_server_install.rc == 0 + when: k3s_server_needs_install | bool + tags: k3s + +- name: Remove K3s installer + ansible.builtin.file: + path: "{{ k3s_server_install_script_path }}" + state: absent + when: k3s_server_needs_install | bool + tags: k3s + +- name: Ensure K3s service is running + ansible.builtin.systemd: + name: k3s + enabled: true + state: started + tags: k3s + +- name: Apply pending K3s restart + ansible.builtin.meta: flush_handlers + tags: k3s + +- name: Verify K3s node is healthy + ansible.builtin.command: + cmd: "{{ k3s_server_status_command }}" + register: k3s_server_node_health + retries: 12 + delay: 10 + until: k3s_server_node_health.rc == 0 + changed_when: false + tags: k3s + +- name: Ensure flux-system namespace exists + delegate_to: localhost + become: false + kubernetes.core.k8s: + name: flux-system + api_version: v1 + kind: Namespace + state: present + register: _k3s_server_flux_namespace + retries: 3 + delay: 5 + until: _k3s_server_flux_namespace is succeeded + tags: sops + +- name: Create SOPS age Secret in flux-system + delegate_to: localhost + become: false + kubernetes.core.k8s: + state: present + definition: + apiVersion: v1 + kind: Secret + metadata: + name: sops-age + namespace: flux-system + type: Opaque + immutable: true + stringData: + age.agekey: "{{ lookup('file', k3s_server_age_key_path) }}" + tags: sops diff --git a/machines/roles/k3s_server/templates/config.yaml.j2 b/machines/roles/k3s_server/templates/config.yaml.j2 new file mode 100644 index 0000000..02ba2db --- /dev/null +++ b/machines/roles/k3s_server/templates/config.yaml.j2 @@ -0,0 +1,8 @@ +node-name: "{{ k3s_server_node_name }}" +write-kubeconfig-mode: "{{ k3s_server_write_kubeconfig_mode }}" +{% if k3s_server_disable_components | length > 0 %} +disable: +{% for component in k3s_server_disable_components %} + - {{ component }} +{% endfor %} +{% endif %} diff --git a/machines/roles/workstation/defaults/main.yml b/machines/roles/workstation/defaults/main.yml new file mode 100644 index 0000000..e2705e1 --- /dev/null +++ b/machines/roles/workstation/defaults/main.yml @@ -0,0 +1,23 @@ +--- +workstation_brew_packages: + - mise + - pipx + +workstation_pipx_packages: + - ansible-core + - ansible-lint + +# SSH keypair for homelab server access. +# Role asserts it exists — never generates or overwrites. +workstation_ssh_key_path: "{{ lookup('env', 'HOME') }}/.ssh/ansible" + +# Age key for SOPS encryption. +workstation_age_key_path: "{{ lookup('env', 'HOME') }}/.sops/age.agekey" + +# Public key recipient in .sops.yaml — must match the age key. +workstation_sops_age_recipient: >- + age1hvn6a2qlvc2wzzax29psfxxuwrc58xv0226ysuphpr3dnhr5k4ysmwv087 + +workstation_kubeconfig_dir: "{{ lookup('env', 'HOME') }}/.kube/tinycloud" +workstation_kubeconfig_file: "{{ workstation_kubeconfig_dir }}/config" +workstation_kubeconfig_mode: "0600" diff --git a/machines/roles/workstation/meta/main.yml b/machines/roles/workstation/meta/main.yml new file mode 100644 index 0000000..5cfb113 --- /dev/null +++ b/machines/roles/workstation/meta/main.yml @@ -0,0 +1,19 @@ +--- +galaxy_info: + role_name: workstation + author: sojournerdev + description: > + Configures the local Ansible controller: installs packages, distributes + SSH access to the server, fetches kubeconfig, and validates SOPS + recipient against the local age key. + license: MIT + min_ansible_version: "2.15" + platforms: + - name: MacOSX + versions: + - all + galaxy_tags: + - workstation + - controller + - bootstrap +dependencies: [] diff --git a/machines/roles/workstation/tasks/main.yml b/machines/roles/workstation/tasks/main.yml new file mode 100644 index 0000000..7ee3745 --- /dev/null +++ b/machines/roles/workstation/tasks/main.yml @@ -0,0 +1,167 @@ +--- +- name: Assert required variables are set + ansible.builtin.assert: + that: + - homelab_host is defined and homelab_host != "" + - homelab_user is defined and homelab_user != "" + - homelab_kubernetes_server is defined and homelab_kubernetes_server != "" + - >- + workstation_sops_age_recipient is defined and + workstation_sops_age_recipient != "" + fail_msg: "Missing required variable — check inventory host_vars" + tags: [workstation, workstation_verify] + +- name: Assert SSH key exists + ansible.builtin.stat: + path: "{{ workstation_ssh_key_path }}" + register: _workstation_ssh_key + failed_when: not _workstation_ssh_key.stat.exists + tags: [workstation, workstation_verify] + +- name: Ensure SOPS age key directory exists + ansible.builtin.file: + path: "{{ workstation_age_key_path | dirname }}" + state: directory + owner: "{{ lookup('env', 'USER') }}" + mode: "0700" + tags: [workstation, workstation_configure] + +- name: Generate SOPS age key + ansible.builtin.command: + cmd: "age-keygen -o {{ workstation_age_key_path }}" + creates: "{{ workstation_age_key_path }}" + tags: [workstation, workstation_configure] + +- name: Restrict age key permissions + ansible.builtin.file: + path: "{{ workstation_age_key_path }}" + owner: "{{ lookup('env', 'USER') }}" + mode: "0600" + tags: [workstation, workstation_configure] + +- name: Extract age public key + ansible.builtin.command: + cmd: "age-keygen -y {{ workstation_age_key_path }}" + register: _workstation_age_public + changed_when: false + tags: [workstation, workstation_verify] + +- name: Verify age key matches SOPS recipient + ansible.builtin.assert: + that: + - _workstation_age_public.stdout == workstation_sops_age_recipient + fail_msg: >- + Age public key does not match. Add this to .sops.yaml and update inventory: + {{ _workstation_age_public.stdout }} + tags: [workstation, workstation_verify] + +- name: Install Homebrew packages + community.general.homebrew: + name: "{{ workstation_brew_packages }}" + state: present + tags: [workstation, workstation_install] + +- name: List installed pipx tools + ansible.builtin.command: + cmd: pipx list --short + register: _workstation_pipx_list + changed_when: false + tags: [workstation, workstation_install] + +- name: Install pipx-managed tools + ansible.builtin.command: + cmd: "pipx install {{ item }}" + loop: "{{ workstation_pipx_packages }}" + when: item not in _workstation_pipx_list.stdout + register: _workstation_pipx_install + changed_when: _workstation_pipx_install.rc == 0 + tags: [workstation, workstation_install] + +- name: Ensure .ssh directory exists + ansible.builtin.file: + path: "{{ lookup('env', 'HOME') }}/.ssh" + state: directory + owner: "{{ lookup('env', 'USER') }}" + mode: "0700" + tags: [workstation, workstation_configure] + +- name: Install SSH key on homelab server + delegate_to: "{{ homelab_host }}" + ansible.posix.authorized_key: + user: "{{ homelab_user }}" + key: "{{ lookup('file', workstation_ssh_key_path + '.pub') }}" + state: present + tags: [workstation, workstation_configure] + +- name: Ensure kubeconfig directory exists + ansible.builtin.file: + path: "{{ workstation_kubeconfig_dir }}" + state: directory + owner: "{{ lookup('env', 'USER') }}" + mode: "0700" + tags: [workstation, workstation_configure] + +- name: Fetch K3s kubeconfig from server + delegate_to: "{{ homelab_host }}" + become: true + ansible.builtin.fetch: + src: /etc/rancher/k3s/k3s.yaml + dest: "{{ workstation_kubeconfig_file }}" + flat: true + tags: [workstation, workstation_configure] + +- name: Set kubeconfig file permissions + ansible.builtin.file: + path: "{{ workstation_kubeconfig_file }}" + owner: "{{ lookup('env', 'USER') }}" + mode: "{{ workstation_kubeconfig_mode }}" + tags: [workstation, workstation_configure] + +- name: Check kubeconfig server address + ansible.builtin.shell: + cmd: >- + grep -q 'server: {{ homelab_kubernetes_server }}' + {{ workstation_kubeconfig_file }} && echo correct || echo wrong + register: _workstation_kubeconfig_server + changed_when: false + tags: [workstation, workstation_configure] + +- name: Patch kubeconfig server address + ansible.builtin.replace: + path: "{{ workstation_kubeconfig_file }}" + regexp: "server: .*" + replace: "server: {{ homelab_kubernetes_server }}" + when: _workstation_kubeconfig_server.stdout == "wrong" + tags: [workstation, workstation_configure] + +- name: Verify SSH key is valid + ansible.builtin.command: + cmd: ssh-keygen -l -f {{ workstation_ssh_key_path }} + register: _workstation_ssh_key_valid + changed_when: false + tags: [workstation, workstation_verify] + +- name: Verify kubeconfig connects to cluster + ansible.builtin.command: + cmd: kubectl get nodes + environment: + KUBECONFIG: "{{ workstation_kubeconfig_file }}" + register: _workstation_kubeconfig_valid + changed_when: false + retries: 3 + delay: 5 + until: _workstation_kubeconfig_valid.rc == 0 + tags: [workstation, workstation_verify] + +- name: Verify SSH authenticates to server + ansible.builtin.command: + cmd: >- + ssh -o BatchMode=yes -o ConnectTimeout=10 + -o IdentitiesOnly=yes -i {{ workstation_ssh_key_path }} + {{ homelab_user }}@{{ homelab_host }} true + register: _workstation_ssh_auth + changed_when: false + retries: 3 + delay: 5 + until: _workstation_ssh_auth.rc == 0 + tags: [workstation, workstation_verify] diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..fbca284 --- /dev/null +++ b/mise.toml @@ -0,0 +1,68 @@ +[tools] +# ansible-core and ansible-lint are managed via pipx, not mise. +age = "1.3.2" +flux2 = "2.9.5" +helm = "4.3.0" +kubectl = "1.35.3" +kustomize = "5.8.1" +mkcert = "1.4.4" +actionlint = "1.7.7" +shellcheck = "0.11.0" +sops = "3.13.3" +yamllint = "1.36.1" + +[env] +_.path = ["~/.local/bin"] +ANSIBLE_COLLECTIONS_PATH = "{{config_root}}/machines/collections:~/.ansible/collections" +KUBECONFIG = "{{ env.HOME }}/.kube/tinycloud/config" +SOPS_CONFIG = "{{config_root}}/.sops.yaml" +SOPS_AGE_KEY_FILE = "{{ env.HOME }}/.sops/age.agekey" + +# ── Lint ────────────────────────────────────────────────────────── +[tasks.lint] +description = "Run all linters" +dir = "machines" +run = [ + "actionlint ../.github/workflows/*.yml", + "shellcheck ../scripts/*.sh", + "ansible-galaxy collection install -r requirements.yml -p ./collections", + "ansible-lint roles/", + "ansible-playbook playbooks/workstation.yml -i inventory/workstation.yml --syntax-check", + "ansible-playbook playbooks/server.yml -i inventory/server.yml --syntax-check", + "yamllint roles/ inventory/ playbooks/ ../clusters/tinycloud/infrastructure/ ../clusters/tinycloud/apps/", +] + +# ── Check ───────────────────────────────────────────────────────── +[tasks.check] +description = "Run all checks" +depends = ["lint", "cluster-test"] + +[tasks.cluster-test] +description = "Catch invalid Flux resources before deployment" +run = [ + "flux plugin install schema@0.5.0", + "scripts/cluster-test-config.sh", +] + +# ── Workstation ─────────────────────────────────────────────────── +[tasks.workstation-init] +description = "Configure the workstation for homelab use" +run = "ansible-playbook playbooks/workstation.yml -i inventory/workstation.yml" +dir = "machines" + +# ── Host ────────────────────────────────────────────────────────── +[tasks.host-deploy] +description = "Configure the homelab host" +confirm = "Apply configuration to homelab?" +run = "ansible-playbook playbooks/server.yml -i inventory/server.yml -c ssh" +dir = "machines" + +[tasks.host-dry] +description = "Preview host changes without applying them" +run = "ansible-playbook playbooks/server.yml -i inventory/server.yml -c ssh --check --diff" +dir = "machines" + +# ── TLS ────────────────────────────────────────────────────────── +[tasks.tls-generate] +description = "Generate TLS cert and store encrypted secret in Git" +run = "scripts/generate-tls.sh" diff --git a/scripts/bootstrap.sh b/scripts/bootstrap.sh new file mode 100755 index 0000000..06b6ab7 --- /dev/null +++ b/scripts/bootstrap.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -euo pipefail + +command -v mise >/dev/null 2>&1 || brew install mise + +mise install +mise run workstation-init diff --git a/scripts/cluster-test-config.sh b/scripts/cluster-test-config.sh new file mode 100755 index 0000000..c50944f --- /dev/null +++ b/scripts/cluster-test-config.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -euo pipefail + +cluster_dir="clusters/tinycloud" +schema_config=".fluxschema.yml" + +flux build kustomization infrastructure \ + --path "$cluster_dir/infrastructure" \ + --kustomization-file "$cluster_dir/infrastructure.yaml" \ + --dry-run \ + | flux schema validate --config "$schema_config" + +flux build kustomization apps \ + --path "$cluster_dir/apps" \ + --kustomization-file "$cluster_dir/apps.yaml" \ + --dry-run \ + | flux schema validate --config "$schema_config" diff --git a/scripts/generate-tls.sh b/scripts/generate-tls.sh new file mode 100755 index 0000000..53e2ae1 --- /dev/null +++ b/scripts/generate-tls.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Always regenerates cert and re-encrypts. Idempotent — safe to run anytime. +# Re-encryption changes the SOPS output (new nonce) but the cert is unchanged. + +SECRET="clusters/tinycloud/infrastructure/secrets.yaml" +DOMAIN="*.internal" +CERT=".certs/${DOMAIN}.pem" +KEY=".certs/${DOMAIN}-key.pem" + +trap 'rm -rf .certs' EXIT + +mkcert -install + +mkdir -p .certs +mkcert -cert-file "$CERT" -key-file "$KEY" "$DOMAIN" + +mkdir -p "$(dirname "$SECRET")" +cat > "$SECRET" < ["tools", "tools:status"].freeze -}.freeze -TASK_NAMESPACE_BY_FILE = { - "taskfiles/bootstrap.yml" => "bootstrap", - "taskfiles/flux.yml" => "flux", - "taskfiles/kubeconfig.yml" => "kubeconfig", - "taskfiles/lint.yml" => "lint", - "taskfiles/tools.yml" => "tools" -}.freeze - -def load_taskfile(path, errors) - YAML.load_file(path) -rescue Psych::SyntaxError => e - errors << "#{path}: YAML parse error: #{e.message}" - nil -end - -def lint_lines(path, errors) - File.readlines(path, chomp: true).each_with_index do |line, index| - line_number = index + 1 - - lint_line_length(path, line_number, line, errors) - lint_task_call_style(path, line_number, line, errors) - lint_shell_task_call(path, line_number, line, errors) - lint_tools_bin_quoting(path, line_number, line, errors) - end -end - -def lint_line_length(path, line_number, line, errors) - return unless line.length > MAX_LINE_LENGTH - - errors << "#{path}:#{line_number}: line is #{line.length} chars; max is #{MAX_LINE_LENGTH}" -end - -def lint_task_call_style(path, line_number, line, errors) - return unless line.match?(/^\s+-\s+task\s+[a-z0-9:_-]+/) - - errors << "#{path}:#{line_number}: use structured task calls, e.g. `- task: name`" -end - -def lint_shell_task_call(path, line_number, line, errors) - return unless line.match?(/^\s+-\s+task\s+/) - - errors << "#{path}:#{line_number}: use structured task calls instead of shelling out to task" -end - -def lint_tools_bin_quoting(path, line_number, line, errors) - return unless line.match?(/"\{\{\.TOOLS_BIN\}\}\/[^"]+\s[^"]+"/) - - errors << "#{path}:#{line_number}: quote only the executable path, not its arguments" -end - -def lint_deps_usage(path, data, errors) - tasks = data.fetch("tasks", {}) - - tasks.each do |name, config| - next unless config.is_a?(Hash) && config.key?("deps") - next if allowed_deps?(path, name) - - errors << "#{path}: task #{name.inspect} uses deps; use ordered `cmds: - task:` for workflow prechecks" - end -end - -def allowed_deps?(path, task_name) - ALLOWED_DEPS.fetch(path, []).include?(task_name) -end - -def lint_includes(path, data, errors) - return unless path == "Taskfile.yml" - - includes = data.fetch("includes", {}) - includes.each do |name, config| - unless config.is_a?(Hash) - errors << "#{path}: include #{name.inspect} must use long form with taskfile and flatten" - next - end - - expected_path = "./taskfiles/#{name}.yml" - errors << "#{path}: include #{name.inspect} should use #{expected_path}" unless config["taskfile"] == expected_path - errors << "#{path}: include #{name.inspect} must set flatten: true" unless config["flatten"] == true - end -end - -def lint_task_metadata(path, data, errors) - tasks = data.fetch("tasks", {}) - namespace = TASK_NAMESPACE_BY_FILE[path] - - tasks.each do |name, config| - next unless config.is_a?(Hash) - - errors << "#{path}: task #{name.inspect} is missing desc" unless config.key?("desc") - next unless namespace - - next if name == namespace || name.start_with?("#{namespace}:") - - errors << "#{path}: task #{name.inspect} should use #{namespace.inspect} namespace" - end -end - -def lint_task_can_load(errors) - return if system("task --list >/dev/null") - - errors << "Taskfile.yml: task --list failed" -end - -errors = [] -parsed_taskfiles = {} - -TASKFILES.each do |path| - data = load_taskfile(path, errors) - parsed_taskfiles[path] = data if data - - lint_lines(path, errors) -end - -parsed_taskfiles.each do |path, data| - lint_includes(path, data, errors) - lint_task_metadata(path, data, errors) - lint_deps_usage(path, data, errors) -end - -lint_task_can_load(errors) - -if errors.empty? - puts "Taskfile lint passed" -else - warn errors.join("\n") - exit 1 -end diff --git a/taskfiles/bootstrap.yml b/taskfiles/bootstrap.yml deleted file mode 100644 index 8c19d02..0000000 --- a/taskfiles/bootstrap.yml +++ /dev/null @@ -1,46 +0,0 @@ -version: "3" - -tasks: - bootstrap: - desc: Prepare the Ubuntu Server node and install K3s - dir: "{{.ANSIBLE_DIR}}" - cmds: - - task: bootstrap:ping - - "{{.ANSIBLE_PLAYBOOK}} playbooks/site.yml" - - bootstrap:ping: - desc: Check Ansible connectivity to the homelab node - dir: "{{.ANSIBLE_DIR}}" - cmds: - - ansible homelab -i "{{.INVENTORY}}" -m ping - - bootstrap:prep: - desc: Prepare the Ubuntu Server node for K3s - dir: "{{.ANSIBLE_DIR}}" - cmds: - - task: bootstrap:ping - - "{{.ANSIBLE_PLAYBOOK}} playbooks/prep.yml" - - bootstrap:k3s: - desc: Install K3s server - dir: "{{.ANSIBLE_DIR}}" - cmds: - - task: bootstrap:ping - - "{{.ANSIBLE_PLAYBOOK}} playbooks/k3s.yml" - - bootstrap:status: - desc: Verify K3s node health - dir: "{{.ANSIBLE_DIR}}" - cmds: - - >- - ansible k3s_servers - -i "{{.INVENTORY}}" - -b - -m command - -a "/usr/local/bin/k3s kubectl get nodes -o wide" - - bootstrap:check: - desc: Run Ansible syntax checks - dir: "{{.ANSIBLE_DIR}}" - cmds: - - "{{.ANSIBLE_PLAYBOOK}} playbooks/site.yml --syntax-check" diff --git a/taskfiles/flux.yml b/taskfiles/flux.yml deleted file mode 100644 index 41c44f1..0000000 --- a/taskfiles/flux.yml +++ /dev/null @@ -1,47 +0,0 @@ -version: "3" - -tasks: - flux:login: - desc: Log in to GitHub CLI for Flux bootstrap - cmds: - - task: tools:gh - - >- - "{{.TOOLS_BIN}}/gh" auth status || - "{{.TOOLS_BIN}}/gh" auth login --web --git-protocol ssh - - flux:check: - desc: Verify Flux prerequisites - env: - KUBECONFIG: "{{.KUBECONFIG_LOCAL}}" - cmds: - - task: tools:flux - - task: kubeconfig:status - - >- - "{{.TOOLS_BIN}}/flux" check --pre - - flux:bootstrap: - desc: Bootstrap Flux into the tinycloud cluster - env: - KUBECONFIG: "{{.KUBECONFIG_LOCAL}}" - cmds: - - task: flux:check - - task: flux:login - - | - GITHUB_TOKEN="$("{{.TOOLS_BIN}}/gh" auth token)" - export GITHUB_TOKEN - "{{.TOOLS_BIN}}/flux" bootstrap github \ - --owner="{{.FLUX_GITHUB_OWNER}}" \ - --repository="{{.FLUX_GITHUB_REPOSITORY}}" \ - --branch="{{.FLUX_GITHUB_BRANCH}}" \ - --path="{{.FLUX_CLUSTER_PATH}}" \ - --personal \ - --token-auth=false - - flux:status: - desc: Show Flux reconciliation status - env: - KUBECONFIG: "{{.KUBECONFIG_LOCAL}}" - cmds: - - task: flux:check - - >- - "{{.TOOLS_BIN}}/flux" get all -A diff --git a/taskfiles/kubeconfig.yml b/taskfiles/kubeconfig.yml deleted file mode 100644 index e4541fc..0000000 --- a/taskfiles/kubeconfig.yml +++ /dev/null @@ -1,29 +0,0 @@ -version: "3" - -tasks: - kubeconfig:fetch: - desc: Fetch the K3s kubeconfig for local kubectl access - dir: "{{.ANSIBLE_DIR}}" - cmds: - - task: tools:kubectl - - mkdir -p "{{.KUBECONFIG_DIR}}" - - chmod 700 "{{.KUBECONFIG_DIR}}" - - >- - ansible k3s_servers - -i "{{.INVENTORY}}" - -b - -m fetch - -a "src=/etc/rancher/k3s/k3s.yaml dest={{.KUBECONFIG_LOCAL}} flat=true" - --ask-become-pass - - >- - "{{.TOOLS_BIN}}/kubectl" config set-cluster "{{.KUBECONFIG_CLUSTER}}" - --server="{{.K3S_SERVER_URL}}" - --kubeconfig="{{.KUBECONFIG_LOCAL}}" - - chmod 600 "{{.KUBECONFIG_LOCAL}}" - - KUBECONFIG="{{.KUBECONFIG_LOCAL}}" "{{.TOOLS_BIN}}/kubectl" config view --minify - - kubeconfig:status: - desc: Verify local kubectl access using the fetched K3s kubeconfig - cmds: - - task: tools:kubectl - - KUBECONFIG="{{.KUBECONFIG_LOCAL}}" "{{.TOOLS_BIN}}/kubectl" get nodes -o wide diff --git a/taskfiles/lint.yml b/taskfiles/lint.yml deleted file mode 100644 index 7b9856c..0000000 --- a/taskfiles/lint.yml +++ /dev/null @@ -1,7 +0,0 @@ -version: "3" - -tasks: - lint: - desc: Validate Taskfile syntax and conventions - cmds: - - ruby scripts/lint-taskfiles.rb diff --git a/taskfiles/tools.yml b/taskfiles/tools.yml deleted file mode 100644 index 8fe1e92..0000000 --- a/taskfiles/tools.yml +++ /dev/null @@ -1,83 +0,0 @@ -version: "3" - -vars: - TOOLS_TMP: "{{.TOOLS_DIR}}/tmp" - -tasks: - tools: - desc: Install repo-local CLI tools - deps: - - tools:flux - - tools:gh - - tools:kubectl - - tools:flux: - desc: Install Flux CLI into .tools/bin - vars: - FLUX_ARCHIVE_VERSION: '{{ trimPrefix "v" .FLUX_VERSION }}' - FLUX_ARCHIVE: flux_{{.FLUX_ARCHIVE_VERSION}}_darwin_arm64.tar.gz - FLUX_URL: https://github.com/fluxcd/flux2/releases/download/{{.FLUX_VERSION}}/{{.FLUX_ARCHIVE}} - FLUX_TMP: "{{.TOOLS_TMP}}/flux" - cmds: - - mkdir -p "{{.TOOLS_BIN}}" "{{.FLUX_TMP}}" - - curl -fsSL "{{.FLUX_URL}}" -o "{{.FLUX_TMP}}/{{.FLUX_ARCHIVE}}" - - tar -xzf "{{.FLUX_TMP}}/{{.FLUX_ARCHIVE}}" -C "{{.FLUX_TMP}}" flux - - install -m 0755 "{{.FLUX_TMP}}/flux" "{{.TOOLS_BIN}}/flux" - - rm -rf "{{.FLUX_TMP}}" - - rmdir "{{.TOOLS_TMP}}" 2>/dev/null || true - status: - - test -x "{{.TOOLS_BIN}}/flux" - - test "$("{{.TOOLS_BIN}}/flux" --version | awk '{print $3}')" = "{{.FLUX_ARCHIVE_VERSION}}" - - tools:kubectl: - desc: Install kubectl into .tools/bin - vars: - KUBECTL_URL: https://dl.k8s.io/release/{{.KUBECTL_VERSION}}/bin/darwin/arm64/kubectl - KUBECTL_TMP: "{{.TOOLS_TMP}}/kubectl" - cmds: - - mkdir -p "{{.TOOLS_BIN}}" "{{.KUBECTL_TMP}}" - - curl -fsSL "{{.KUBECTL_URL}}" -o "{{.KUBECTL_TMP}}/kubectl" - - install -m 0755 "{{.KUBECTL_TMP}}/kubectl" "{{.TOOLS_BIN}}/kubectl" - - rm -rf "{{.KUBECTL_TMP}}" - - rmdir "{{.TOOLS_TMP}}" 2>/dev/null || true - status: - - test -x "{{.TOOLS_BIN}}/kubectl" - - >- - test "$("{{.TOOLS_BIN}}/kubectl" version --client=true | - awk '/Client Version:/ { print $3 }')" = "{{.KUBECTL_VERSION}}" - - tools:gh: - desc: Install GitHub CLI into .tools/bin - vars: - GH_ARCHIVE_VERSION: '{{ trimPrefix "v" .GH_VERSION }}' - GH_ARCHIVE: gh_{{.GH_ARCHIVE_VERSION}}_macOS_arm64.zip - GH_URL: https://github.com/cli/cli/releases/download/{{.GH_VERSION}}/{{.GH_ARCHIVE}} - GH_TMP: "{{.TOOLS_TMP}}/gh" - cmds: - - mkdir -p "{{.TOOLS_BIN}}" "{{.GH_TMP}}" - - curl -fsSL "{{.GH_URL}}" -o "{{.GH_TMP}}/{{.GH_ARCHIVE}}" - - unzip -q "{{.GH_TMP}}/{{.GH_ARCHIVE}}" -d "{{.GH_TMP}}" - - install -m 0755 "{{.GH_TMP}}/gh_{{.GH_ARCHIVE_VERSION}}_macOS_arm64/bin/gh" "{{.TOOLS_BIN}}/gh" - - rm -rf "{{.GH_TMP}}" - - rmdir "{{.TOOLS_TMP}}" 2>/dev/null || true - status: - - test -x "{{.TOOLS_BIN}}/gh" - - test "$("{{.TOOLS_BIN}}/gh" --version | awk '/gh version/ { print $3; exit }')" = "{{.GH_ARCHIVE_VERSION}}" - - tools:status: - desc: Show repo-local CLI tool versions - deps: - - tools - cmds: - - echo "PATH={{.TOOLS_BIN}}" - - >- - "{{.TOOLS_BIN}}/flux" --version - - >- - "{{.TOOLS_BIN}}/gh" --version - - >- - "{{.TOOLS_BIN}}/kubectl" version --client=true - - tools:clean: - desc: Remove repo-local CLI tools - cmds: - - rm -rf "{{.TOOLS_DIR}}" From e0b6b4b9a02d4a4fb82818637b9360d93b58505d Mon Sep 17 00:00:00 2001 From: Marcos Blount Date: Wed, 23 Sep 2026 01:46:45 -0400 Subject: [PATCH 2/2] feat: platform infra, dev tooling, and gitops validation --- .fluxschema.yml | 2 +- .github/workflows/ci.yml | 24 ----- machines/playbooks/server.yml | 18 ---- machines/playbooks/workstation.yml | 12 --- machines/requirements.lock | 152 +++++++++++++++++++++++++++++ machines/requirements.txt | 6 ++ machines/requirements.yml | 8 +- mise.toml | 63 +++++++----- 8 files changed, 200 insertions(+), 85 deletions(-) delete mode 100644 .github/workflows/ci.yml create mode 100644 machines/requirements.lock create mode 100644 machines/requirements.txt diff --git a/.fluxschema.yml b/.fluxschema.yml index a0fc62d..55e5b6a 100644 --- a/.fluxschema.yml +++ b/.fluxschema.yml @@ -10,4 +10,4 @@ validate: skipFile: - '.*' verbose: true - output: json + output: text diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index ab5653f..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,24 +0,0 @@ -name: CI - -on: - pull_request: - branches: [main] - -jobs: - validate: - runs-on: ubuntu-latest - env: - ANSIBLE_COLLECTIONS_PATH: machines/collections - steps: - # Install tools (mise manages versions, pipx for ansible-lint) - - uses: actions/checkout@v4 - - uses: jdx/mise-action@v4 - with: - install: true - - run: pipx install ansible-lint - - - name: Decrypt SOPS secrets - run: mkdir -p ~/.sops && echo "${{ secrets.SOPS_AGE_KEY }}" > ~/.sops/age.agekey - - # Validate linting, syntax, and Flux schemas - - run: mise run check diff --git a/machines/playbooks/server.yml b/machines/playbooks/server.yml index 4f6df9e..111f1ac 100644 --- a/machines/playbooks/server.yml +++ b/machines/playbooks/server.yml @@ -15,23 +15,5 @@ - name: Provision k3s server hosts: all become: true - pre_tasks: - - name: Install Ansible collections - delegate_to: localhost - run_once: true - become: false - ansible.builtin.command: - cmd: ansible-galaxy collection install -r ../requirements.yml -p ../collections - chdir: "{{ playbook_dir }}" - changed_when: false - - - name: Install Python dependencies for Ansible - delegate_to: localhost - run_once: true - become: false - ansible.builtin.command: - cmd: pipx inject ansible-core kubernetes - changed_when: false - roles: - role: k3s_server diff --git a/machines/playbooks/workstation.yml b/machines/playbooks/workstation.yml index 7b3d8bf..8c314cd 100644 --- a/machines/playbooks/workstation.yml +++ b/machines/playbooks/workstation.yml @@ -3,17 +3,5 @@ hosts: workstation gather_facts: true - pre_tasks: - - name: Install Ansible collections - ansible.builtin.command: - cmd: ansible-galaxy collection install -r ../requirements.yml -p ../collections - chdir: "{{ playbook_dir }}" - changed_when: false - - - name: Install Python dependencies for Ansible - ansible.builtin.command: - cmd: pipx inject ansible-core kubernetes - changed_when: false - roles: - role: workstation diff --git a/machines/requirements.lock b/machines/requirements.lock new file mode 100644 index 0000000..e1def9f --- /dev/null +++ b/machines/requirements.lock @@ -0,0 +1,152 @@ +# This file was autogenerated by uv via the following command: +# uv pip compile machines/requirements.txt --output-file machines/requirements.lock +aiohappyeyeballs==2.7.1 + # via aiohttp +aiohttp==3.14.3 + # via kubernetes +aiosignal==1.4.0 + # via aiohttp +ansible-compat==26.9.0 + # via ansible-lint +ansible-core==2.21.4 + # via + # -r machines/requirements.txt + # ansible-compat + # ansible-lint +ansible-lint==26.9.0 + # via -r machines/requirements.txt +attrs==26.1.0 + # via + # aiohttp + # jsonschema + # referencing +black==26.5.1 + # via ansible-lint +bracex==3.0.1 + # via wcmatch +certifi==2026.7.22 + # via + # kubernetes + # requests +cffi==2.1.1 + # via + # ansible-lint + # cryptography +charset-normalizer==3.5.1 + # via requests +click==8.5.0 + # via black +cryptography==50.0.1 + # via + # ansible-core + # ansible-lint +distro==1.9.0 + # via ansible-lint +durationpy==0.11 + # via kubernetes +filelock==4.0.1 + # via ansible-lint +frozenlist==1.8.0 + # via + # aiohttp + # aiosignal +idna==3.20 + # via + # requests + # yarl +jinja2==3.1.6 + # via ansible-core +jsonpatch==1.33 + # via -r machines/requirements.txt +jsonpointer==3.1.1 + # via jsonpatch +jsonschema==4.26.0 + # via + # ansible-compat + # ansible-lint +jsonschema-specifications==2025.9.1 + # via jsonschema +kubernetes==36.0.3 + # via -r machines/requirements.txt +markupsafe==3.0.3 + # via jinja2 +multidict==6.9.1 + # via + # aiohttp + # yarl +mypy-extensions==1.1.0 + # via black +oauthlib==3.3.1 + # via requests-oauthlib +packaging==26.3 + # via + # ansible-compat + # ansible-core + # ansible-lint + # black +pathspec==1.1.1 + # via + # ansible-lint + # black + # yamllint +platformdirs==4.11.12 + # via black +propcache==0.5.4 + # via + # aiohttp + # yarl +pycparser==3.0 + # via cffi +python-dateutil==2.9.0.post0 + # via kubernetes +pytokens==0.4.1 + # via black +pyyaml==6.0.3 + # via + # -r machines/requirements.txt + # ansible-compat + # ansible-core + # ansible-lint + # kubernetes + # yamllint +referencing==0.37.0 + # via + # ansible-lint + # jsonschema + # jsonschema-specifications +requests==2.34.2 + # via + # kubernetes + # requests-oauthlib +requests-oauthlib==2.0.0 + # via + # -r machines/requirements.txt + # kubernetes +resolvelib==1.2.1 + # via ansible-core +rpds-py==2026.6.3 + # via + # jsonschema + # referencing +ruamel-yaml==0.19.1 + # via ansible-lint +six==1.17.0 + # via + # kubernetes + # python-dateutil +subprocess-tee==0.4.2 + # via + # ansible-compat + # ansible-lint +urllib3==2.8.0 + # via + # kubernetes + # requests +wcmatch==11.0.1 + # via ansible-lint +websocket-client==1.9.2 + # via kubernetes +yamllint==1.38.0 + # via ansible-lint +yarl==1.25.1 + # via aiohttp diff --git a/machines/requirements.txt b/machines/requirements.txt new file mode 100644 index 0000000..7030995 --- /dev/null +++ b/machines/requirements.txt @@ -0,0 +1,6 @@ +ansible-core==2.21.4 +ansible-lint==26.9.0 +kubernetes==36.0.3 +requests-oauthlib==2.0.0 +jsonpatch==1.33 +PyYAML==6.0.3 diff --git a/machines/requirements.yml b/machines/requirements.yml index b899d1b..2d48568 100644 --- a/machines/requirements.yml +++ b/machines/requirements.yml @@ -1,10 +1,10 @@ --- collections: - name: ansible.posix - version: ">=2.2.0" + version: "2.2.2" - name: community.general - version: ">=8.0.0" + version: "13.4.0" - name: community.crypto - version: ">=2.18.0" + version: "3.4.0" - name: kubernetes.core - version: ">=5.0.0" + version: "6.6.0" diff --git a/mise.toml b/mise.toml index fbca284..42983d1 100644 --- a/mise.toml +++ b/mise.toml @@ -1,31 +1,59 @@ [tools] -# ansible-core and ansible-lint are managed via pipx, not mise. +uv = "0.9.18" +python = "3.14.7" age = "1.3.2" flux2 = "2.9.5" helm = "4.3.0" kubectl = "1.35.3" kustomize = "5.8.1" mkcert = "1.4.4" -actionlint = "1.7.7" shellcheck = "0.11.0" sops = "3.13.3" yamllint = "1.36.1" [env] -_.path = ["~/.local/bin"] -ANSIBLE_COLLECTIONS_PATH = "{{config_root}}/machines/collections:~/.ansible/collections" -KUBECONFIG = "{{ env.HOME }}/.kube/tinycloud/config" +_.file = ".env" +_.python.venv = { path = ".venv", create = true } SOPS_CONFIG = "{{config_root}}/.sops.yaml" -SOPS_AGE_KEY_FILE = "{{ env.HOME }}/.sops/age.agekey" + +# ── Ansible ─────────────────────────────────────────────────────── +[tasks.python-deps] +description = "Install locked Ansible controller dependencies" +dir = "machines" +run = "uv pip sync requirements.lock" + +[tasks.collections] +description = "Install pinned Ansible collections" +depends = ["python-deps"] +dir = "machines" +run = "ansible-galaxy collection install -r requirements.yml" + +[tasks.workstation-deploy] +description = "Configure workstation" +depends = ["collections"] +dir = "machines" +run = "ansible-playbook playbooks/workstation.yml -i inventory/workstation.yml" + +[tasks.host-deploy] +description = "Apply host configuration" +dir = "machines" +depends = ["collections"] +confirm = "Apply host configuration?" +run = "ansible-playbook playbooks/server.yml -i inventory/server.yml -c ssh" + +[tasks.host-check] +description = "Preview host changes with Ansible check mode" +depends = ["collections"] +dir = "machines" +run = "ansible-playbook playbooks/server.yml -i inventory/server.yml -c ssh --check --diff" # ── Lint ────────────────────────────────────────────────────────── [tasks.lint] description = "Run all linters" +depends = ["collections"] dir = "machines" run = [ - "actionlint ../.github/workflows/*.yml", "shellcheck ../scripts/*.sh", - "ansible-galaxy collection install -r requirements.yml -p ./collections", "ansible-lint roles/", "ansible-playbook playbooks/workstation.yml -i inventory/workstation.yml --syntax-check", "ansible-playbook playbooks/server.yml -i inventory/server.yml --syntax-check", @@ -37,6 +65,7 @@ run = [ description = "Run all checks" depends = ["lint", "cluster-test"] +# ── Cluster ───────────────────────────────────────────────────────── [tasks.cluster-test] description = "Catch invalid Flux resources before deployment" run = [ @@ -44,24 +73,6 @@ run = [ "scripts/cluster-test-config.sh", ] -# ── Workstation ─────────────────────────────────────────────────── -[tasks.workstation-init] -description = "Configure the workstation for homelab use" -run = "ansible-playbook playbooks/workstation.yml -i inventory/workstation.yml" -dir = "machines" - -# ── Host ────────────────────────────────────────────────────────── -[tasks.host-deploy] -description = "Configure the homelab host" -confirm = "Apply configuration to homelab?" -run = "ansible-playbook playbooks/server.yml -i inventory/server.yml -c ssh" -dir = "machines" - -[tasks.host-dry] -description = "Preview host changes without applying them" -run = "ansible-playbook playbooks/server.yml -i inventory/server.yml -c ssh --check --diff" -dir = "machines" - # ── TLS ────────────────────────────────────────────────────────── [tasks.tls-generate] description = "Generate TLS cert and store encrypted secret in Git"