mirror of
https://github.com/immich-app/yucca.git
synced 2026-09-30 13:33:00 +08:00
* feat(ceph): import yucca-ceph ansible + terraform infrastructure
Imports the yucca-ceph Ansible tree into ansible/ceph/ and adds the
Terraform stack at tf/ that drives it. Cuts over from ansible-vault
to the hybrid secrets architecture (TF as inventory authority, 1P
as secrets store, op-inject at deploy time) in one atomic move.
Source: internal yucca-ceph working tree; fresh subtree-style
import, history not preserved. Andy continues operating sietch +
painbox post-merge; yucca-team hosts the code and reviews changes.
What it adds:
- sietch (3-node Austin, production Ceph S3 backend, untouched
by this PR)
- painbox (single-node Hetzner SX295 in Helsinki) as a second
deployable cluster
- Future clusters land by appending to clusters.auto.tfvars in
the matching environment stack (tf/deployment/<env>/ceph/) —
no per-cluster TF code required
How it works (full map: ansible/ceph/docs/architecture.md):
- tf/shared/modules/ceph-cluster renders inventory.ini variants
+ secrets.yml.tpl per cluster from clusters.auto.tfvars
- secrets.yml.tpl carries op:// refs; `op inject -f` resolves
them at deploy time from the matching yucca_tf_<env> vault
- State in OVH yucca-tf-state bucket (key ceph/<env>/<stack>/)
- 11 ADRs capture the decisions: ansible/ceph/docs/adr/
Out of scope (intentional):
- LUKS keys not yet in 1P (deferred until hybrid is stable)
- tf/shared/modules/ceph-cluster/secrets.tf.disabled is dormant;
today's 1P items via `op item create` per
ansible/ceph/docs/adding-a-cluster.md
- Talos K8s on sietch is a separate workstream
Atomicity + rollback: TF-rendered inventory + secrets-template
files are gitignored (TF generates them) and ansible-vault removal
is coupled to the op-inject path. Splitting this PR lands in a
non-bootable state — merge as one unit. The merge itself is
reversible via `git revert` until the post-merge `tf:apply` runs;
after apply, full rollback needs state restore or `tofu state mv`
(land + validate before applying).
Dev-env impact: adds opentofu + terragrunt to yucca root mise tools
plus a self-contained ansible/ceph/.mise.toml. No new commands or
prereqs for immich-side contributors who don't touch ceph or run
tf:* tasks.
Verification:
- `mise run lint` (from ansible/ceph/): 130 files, 0 warnings
- `mise run check`: 19 playbooks parse clean
- `mise run tf:plan`: succeeds; 7 expected file path-rename
replacements (3 painbox + 4 sietch). State drift from import,
no cluster-side change.
- painbox deployed 2026-04-26 on the new code path: Bookworm +
Ceph Tentacle, 15 OSDs (14 HDD + 1 SSD) up + in, mon/mgr/rgw
running. HEALTH_WARN is expected on a single-node cluster.
Post-merge: from the yucca root, `mise run tf:apply` flips the
bucket state to the new monorepo paths (the 7 renames above).
* fix(ceph): exempt ansible/ and tf/ subtrees from root prettier
The imported infrastructure subtrees enforce their own format
conventions (yamllint + ansible-lint inside ansible/ceph/; tofu fmt
inside tf/). Prettier on ansible YAML reflows long Jinja2 expressions
and shell command blocks in unwanted ways, so root prettier checks
are skipped for both subtrees.
Also reformat root README.md table column alignment to match prettier
conventions (only the imported subtrees are exempt; yucca-side files
including the root README still follow root prettier rules).
* fix(ceph): clean up secrets tmpfile after ansible-playbook exits
`ansible-play.sh` rendered the resolved secrets file via `op inject`
into a `mktemp` tmpfile, set up a `trap 'rm -f "$TMPFILE"' EXIT INT
TERM`, then `exec`'d ansible-playbook. The `exec` replaced the bash
shell entirely, so the EXIT trap never fired — every play left a
plaintext-secrets file in /tmp.
In practice this was masked because /tmp is tmpfs (RAM only on this
operator's setup), so files evaporate on reboot. But within an
operator session, files accumulated linearly with each playbook
invocation. Recent count on the import-PR session: 38 files.
Drop the `exec`. With `set -euo pipefail` already on, bash:
- propagates ansible-playbook's exit code (set -e)
- fires the EXIT trap before exiting (always)
- cleans up the tmpfile on success, failure, or signal
Verified: `CEPH_ENV=... scripts/ansible-play.sh status.yml
--syntax-check` creates and removes the tmpfile within the same
invocation — /tmp is clean before and after.
`scripts/preflight.sh` uses the same trap pattern but does not
`exec`, so its tmpfile cleanup was already correct (and the suffix
differs: `-secrets-test.yml` vs `-secrets.yml`, confirming
ansible-play.sh as the sole offender).
209 lines
8.6 KiB
YAML
209 lines
8.6 KiB
YAML
---
|
|
# Provision sietch-ceph nodes from the Debian 12 live image.
|
|
#
|
|
# Fresh-install workflow (no cloning):
|
|
# 1. Boot all target nodes to Debian 12 live image (user/live credentials)
|
|
# 2. Ensure the live image is reachable on its reserved IP (= bond_ip)
|
|
# 3. CEPH_ENV=inventories/<cluster>/inventory-provision.ini \
|
|
# scripts/ansible-play.sh provision.yml -e confirm_wipe=true
|
|
#
|
|
# Flags:
|
|
# confirm_wipe=true required — destroys all data on SSDs
|
|
# provision_create_iac_keypair=true opt-in: preflight auto-generates
|
|
# the ansible-iac deploy keypair if
|
|
# it doesn't already exist on disk
|
|
# provision_skip_reboot=true opt-in: skip the final reboot and
|
|
# the post-reboot verification play
|
|
# (canary inspection of the chroot)
|
|
# --limit <host> provision one node at a time
|
|
#
|
|
# Post-reboot: a separate verification play waits on the installed OS
|
|
# (reached via the production sietch-ceph-<name> SSH alias / ansible-iac
|
|
# user with key) and confirms the provisioning marker is in place.
|
|
|
|
# --- Preflight: controller-side prerequisites -------------------------
|
|
#
|
|
# Runs BEFORE the target hosts are touched. Verifies the ansible-iac
|
|
# deploy keypair exists on the controller (it gets installed into every
|
|
# node's ~ansible-iac/.ssh/authorized_keys via file lookup during the
|
|
# main play). Keypair generation is strictly opt-in via
|
|
# -e provision_create_iac_keypair=true — default behavior is to fail
|
|
# fast with a clear message if the keypair is missing.
|
|
|
|
- name: Preflight — verify controller prerequisites
|
|
hosts: localhost
|
|
gather_facts: false
|
|
connection: local
|
|
tasks:
|
|
- name: Stat ansible-iac private key
|
|
ansible.builtin.stat:
|
|
path: "{{ provision_iac_ssh_key_path | expanduser }}"
|
|
register: iac_privkey_stat
|
|
|
|
- name: Stat ansible-iac public key
|
|
ansible.builtin.stat:
|
|
path: "{{ (provision_iac_ssh_key_path | expanduser) ~ '.pub' }}"
|
|
register: iac_pubkey_stat
|
|
|
|
- name: Generate ansible-iac keypair (only when opt-in flag is set)
|
|
ansible.builtin.command:
|
|
cmd: >-
|
|
ssh-keygen -t ed25519 -N ''
|
|
-C "ansible-iac@{{ cluster_name }}-{{ cluster_role }}.{{ cluster_domain }}"
|
|
-f {{ provision_iac_ssh_key_path | expanduser }}
|
|
args:
|
|
creates: "{{ provision_iac_ssh_key_path | expanduser }}"
|
|
when:
|
|
- not iac_privkey_stat.stat.exists
|
|
- provision_create_iac_keypair | default(false) | bool
|
|
|
|
- name: Fail if ansible-iac keypair missing and create flag not set
|
|
ansible.builtin.fail:
|
|
msg: |
|
|
ansible-iac deploy keypair is missing on the controller:
|
|
{{ provision_iac_ssh_key_path | expanduser }}
|
|
{{ provision_iac_ssh_key_path | expanduser }}.pub
|
|
|
|
This keypair is required to provision the cluster — it is
|
|
installed into ansible-iac's authorized_keys on every node
|
|
and used by ansible_ssh_private_key_file in inventory.ini.
|
|
|
|
To auto-generate it on this run, re-invoke with:
|
|
-e provision_create_iac_keypair=true
|
|
|
|
Or generate it yourself first:
|
|
ssh-keygen -t ed25519 -N '' \
|
|
-C "ansible-iac@{{ cluster_name }}-{{ cluster_role }}.{{ cluster_domain }}" \
|
|
-f {{ provision_iac_ssh_key_path }}
|
|
when:
|
|
- not iac_privkey_stat.stat.exists
|
|
- not (provision_create_iac_keypair | default(false) | bool)
|
|
|
|
- name: Re-stat public key after potential creation
|
|
ansible.builtin.stat:
|
|
path: "{{ (provision_iac_ssh_key_path | expanduser) ~ '.pub' }}"
|
|
register: iac_pubkey_final_stat
|
|
|
|
- name: Assert ansible-iac public key exists after preflight
|
|
ansible.builtin.assert:
|
|
that:
|
|
- iac_pubkey_final_stat.stat.exists
|
|
fail_msg: >-
|
|
ansible-iac public key still missing at
|
|
{{ (provision_iac_ssh_key_path | expanduser) ~ '.pub' }}
|
|
after preflight completed. Investigate ssh-keygen task above.
|
|
|
|
- name: Report ansible-iac keypair ready
|
|
ansible.builtin.debug:
|
|
msg: >-
|
|
ansible-iac keypair present at
|
|
{{ provision_iac_ssh_key_path | expanduser }}(.pub) —
|
|
preflight OK.
|
|
|
|
- name: Provision sietch-ceph nodes (live image → installed OS)
|
|
hosts: provision_targets
|
|
gather_facts: false
|
|
serial: 1
|
|
become: true
|
|
max_fail_percentage: 0
|
|
|
|
roles:
|
|
- role: provision_host
|
|
|
|
- name: Verify provisioned nodes after reboot
|
|
hosts: provision_targets
|
|
gather_facts: false
|
|
become: false
|
|
tasks:
|
|
# Verification runs on localhost via delegate_to + the production
|
|
# sietch-ceph-<name> SSH alias. That avoids having to juggle live
|
|
# vs installed credentials inside a single play.
|
|
|
|
- name: Skip verification play when reboot was intentionally skipped
|
|
ansible.builtin.meta: end_play
|
|
when: provision_skip_reboot | default(false) | bool
|
|
|
|
# Give the box a head start before we start hammering ssh. R730xd
|
|
# POST + GRUB + initramfs + systemd brings the bond up in ~60-90s.
|
|
- name: Initial reboot wait
|
|
delegate_to: localhost
|
|
ansible.builtin.pause:
|
|
seconds: "{{ provision_reboot_wait_delay | default(60) }}"
|
|
|
|
# The ssh probe is the actual gate — it honors ~/.ssh/config
|
|
# (including any ProxyJump or ProxyCommand entries) so it works
|
|
# from the controller regardless of whether the target subnet is
|
|
# directly routable. wait_for/tcp does NOT honor ssh_config, so
|
|
# we rely on retries here instead.
|
|
#
|
|
# We force user + identity explicitly with -l / -i / IdentitiesOnly
|
|
# because ~/.ssh/config for the host alias lands as the 'ops' human
|
|
# user (which has NO authorized_keys on a fresh provision — operator
|
|
# keys are distributed out-of-band). The verify play is an automation
|
|
# reachability test, so it uses ansible-iac (the same identity
|
|
# ansible_ssh_private_key_file points at in inventory.ini).
|
|
#
|
|
# UserKnownHostsFile=/dev/null + StrictHostKeyChecking=no bypass
|
|
# known_hosts entirely: fresh provisioning generates new host keys,
|
|
# and on reprovisioning a stale known_hosts entry would otherwise
|
|
# trigger "REMOTE HOST IDENTIFICATION HAS CHANGED" and abort. The
|
|
# real identity check happens via the marker assertion below, not
|
|
# via the SSH host key cache. LogLevel=ERROR suppresses the
|
|
# "Warning: Permanently added..." noise.
|
|
- name: Probe installed OS via production SSH alias (as ansible-iac)
|
|
delegate_to: localhost
|
|
ansible.builtin.command:
|
|
cmd: >-
|
|
ssh -o BatchMode=yes -o ConnectTimeout=10
|
|
-o StrictHostKeyChecking=no
|
|
-o UserKnownHostsFile=/dev/null
|
|
-o IdentitiesOnly=yes
|
|
-o LogLevel=ERROR
|
|
-l ansible-iac
|
|
-i {{ provision_iac_ssh_key_path | expanduser }}
|
|
{{ hostname_short }}
|
|
"cat {{ provision_marker_path | default('/etc/ceph-provisioned.json') }}"
|
|
register: marker_read
|
|
changed_when: false
|
|
retries: "{{ provision_verify_retries | default(30) }}"
|
|
delay: "{{ provision_verify_delay | default(15) }}"
|
|
until: marker_read.rc == 0
|
|
|
|
- name: Parse provisioning marker
|
|
ansible.builtin.set_fact:
|
|
marker: "{{ marker_read.stdout | from_json }}"
|
|
|
|
- name: Verify marker contents match inventory expectations
|
|
ansible.builtin.assert:
|
|
that:
|
|
- marker.hostname == hostname_short
|
|
- marker.bond_ip == bond_ip
|
|
- marker.cluster_domain == cluster_domain
|
|
- marker.admin_user == admin_user
|
|
fail_msg: "Marker contents do not match inventory: {{ marker }}"
|
|
success_msg: "{{ hostname_short }} provisioned OK at {{ marker.provisioned_at }}"
|
|
|
|
- name: Gather installed OS hostname (as ansible-iac)
|
|
delegate_to: localhost
|
|
ansible.builtin.command:
|
|
cmd: >-
|
|
ssh -o BatchMode=yes -o ConnectTimeout=10
|
|
-o StrictHostKeyChecking=no
|
|
-o UserKnownHostsFile=/dev/null
|
|
-o IdentitiesOnly=yes
|
|
-o LogLevel=ERROR
|
|
-l ansible-iac
|
|
-i {{ provision_iac_ssh_key_path | expanduser }}
|
|
{{ hostname_short }} hostname -f
|
|
register: installed_hostname
|
|
changed_when: false
|
|
|
|
- name: Display post-provision state
|
|
ansible.builtin.debug:
|
|
msg:
|
|
- "hostname -f: {{ installed_hostname.stdout | trim }}"
|
|
- "marker.hostname: {{ marker.hostname }}"
|
|
- "marker.fqdn: {{ marker.fqdn }}"
|
|
- "marker.bond_ip: {{ marker.bond_ip }}"
|
|
- "marker.provisioned: {{ marker.provisioned_at }}"
|