diff --git a/.gitignore b/.gitignore index ca3180e..3d5cb33 100644 --- a/.gitignore +++ b/.gitignore @@ -47,6 +47,7 @@ etc/secrets/ *-password.txt *secret.yaml .vault_pass +/conf/prole.cfg /secrets/ /data/ /logs/ diff --git a/etc/init_forego.sh b/etc/init_forego.sh new file mode 100644 index 0000000..1318ff9 --- /dev/null +++ b/etc/init_forego.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Backward-compat wrapper for a common misspelling. +# Prefer: etc/init_forgejo.sh + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec bash "$SCRIPT_DIR/init_forgejo.sh" "$@" diff --git a/etc/init_forgejo.sh b/etc/init_forgejo.sh new file mode 100644 index 0000000..ed5e0fa --- /dev/null +++ b/etc/init_forgejo.sh @@ -0,0 +1,248 @@ +#!/usr/bin/env bash +set -euo pipefail + +PROG="init_forgejo" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# shellcheck disable=SC1090 +source "$SCRIPT_DIR/prole_cfg.sh" + +MODE="$(prole_normalize_mode "${PROLE_MODE:-${DEPLOYMENT_MODE:-k3d}}")" +NAMESPACE="${FORGEJO_NAMESPACE:-}" +CFG_PATH="" +FORCE=0 + +usage() { + cat < Deployment mode (default: ${MODE:-k3d}) + -n, --namespace Target namespace (default: forgejo) + -c, --config Path to prole.cfg (defaults to detected) + --force Delete existing Forgejo resources before deploy + --help Show this help + +Behavior: + - Applies a minimal Forgejo Deployment/Service manifest. + - Uses CloudNativePG (CNPG) Postgres as the database backend. + - By default, creates a dedicated DB + role inside the CNPG cluster. +EOF +} + +die() { echo "[ERROR] $*" >&2; exit 2; } +log() { echo "[INFO] $*"; } +warn() { echo "[WARN] $*" >&2; } + +while [[ $# -gt 0 ]]; do + case "$1" in + --mode) MODE="$(prole_normalize_mode "${2:-}")"; shift 2 ;; + --mode=*) MODE="$(prole_normalize_mode "${1#*=}")"; shift 1 ;; + -n|--namespace) NAMESPACE="${2:-}"; shift 2 ;; + --namespace=*) NAMESPACE="${1#*=}"; shift 1 ;; + -c|--config) CFG_PATH="${2:-}"; shift 2 ;; + --config=*) CFG_PATH="${1#*=}"; shift 1 ;; + --force) FORCE=1; shift 1 ;; + -h|--help) usage; exit 0 ;; + *) break ;; + esac +done + +# Resolve config path and namespace defaults from prole.cfg when present +if [[ -z "$CFG_PATH" && -n "${PROLE_CONF:-}" && -f "${PROLE_CONF}/prole.cfg" ]]; then + CFG_PATH="${PROLE_CONF}/prole.cfg" +elif [[ -z "$CFG_PATH" && -f "$SCRIPT_DIR/../conf/prole.cfg" ]]; then + CFG_PATH="$SCRIPT_DIR/../conf/prole.cfg" +fi + +if [[ -z "$NAMESPACE" && -n "$CFG_PATH" ]]; then + maybe_ns="$(_prole_cfg_extract_key "$CFG_PATH" "GITOPS_NAMESPACE")" + [[ -z "$maybe_ns" ]] && maybe_ns="$(_prole_cfg_extract_key "$CFG_PATH" "FORGEJO_NAMESPACE")" + NAMESPACE="$maybe_ns" +fi +NAMESPACE="${NAMESPACE:-forgejo}" +export FORGEJO_NAMESPACE="$NAMESPACE" + +case "$MODE" in + k3d|k3s|k8s|local) ;; + *) die "Unsupported mode '$MODE' (use k3d, k3s, k8s, or local)" ;; +esac +export PROLE_MODE="$MODE" + +command -v kubectl >/dev/null || die "kubectl not found" + +DB_NAMESPACE="${PROLE_NAMESPACE:-}" +if [[ -z "$DB_NAMESPACE" && -n "$CFG_PATH" ]]; then + DB_NAMESPACE="$(_prole_cfg_extract_key "$CFG_PATH" "NAMESPACE")" +fi +DB_NAMESPACE="${DB_NAMESPACE:-default}" + +CNPG_CLUSTER_NAME="${CNPG_CLUSTER_NAME:-prole-db}" +FORGEJO_DB_NAME="${FORGEJO_DB_NAME:-forgejo}" +FORGEJO_DB_USER="${FORGEJO_DB_USER:-forgejo}" +FORGEJO_DB_PASSWORD="${FORGEJO_DB_PASSWORD:-}" +if [[ -z "$FORGEJO_DB_PASSWORD" ]]; then + # 24 chars alnum for broad chart compatibility. + FORGEJO_DB_PASSWORD=$(LC_ALL=C tr -dc 'A-Za-z0-9' /dev/null | head -c 24 || true) +fi + +DB_HOST="${CNPG_CLUSTER_NAME}-rw.${DB_NAMESPACE}.svc.cluster.local" +DB_PORT="${FORGEJO_DB_PORT:-5432}" + +kubectl get ns "$NAMESPACE" >/dev/null 2>&1 || kubectl create namespace "$NAMESPACE" >/dev/null 2>&1 + +if [[ "$FORCE" -eq 1 ]]; then + warn "--force specified; removing existing Forgejo resources in namespace $NAMESPACE" + kubectl -n "$NAMESPACE" delete deploy/forgejo svc/forgejo-http svc/forgejo-ssh ingress/forgejo >/dev/null 2>&1 || true +fi + +ensure_db() { + local primary_pod="" + primary_pod=$(kubectl -n "$DB_NAMESPACE" get pods \ + -l "cnpg.io/cluster=${CNPG_CLUSTER_NAME},role=primary" \ + -o jsonpath='{.items[0].metadata.name}' 2>/dev/null || true) + if [[ -z "$primary_pod" ]]; then + primary_pod=$(kubectl -n "$DB_NAMESPACE" get pods \ + -l "cnpg.io/cluster=${CNPG_CLUSTER_NAME}" \ + -o jsonpath='{.items[0].metadata.name}' 2>/dev/null || true) + fi + if [[ -z "$primary_pod" ]]; then + die "Could not find CNPG pod for cluster '${CNPG_CLUSTER_NAME}' in namespace '${DB_NAMESPACE}'" + fi + + log "Ensuring database '${FORGEJO_DB_NAME}' and role '${FORGEJO_DB_USER}' exist in CNPG cluster '${CNPG_CLUSTER_NAME}' (ns=${DB_NAMESPACE})" + + # Create role/db idempotently. + kubectl -n "$DB_NAMESPACE" exec "$primary_pod" -- bash -lc "psql -v ON_ERROR_STOP=1 -U postgres -d postgres" </dev/null +} + +ensure_db + +FORGEJO_IMAGE_REPO="${FORGEJO_IMAGE_REPO:-codeberg.org/forgejo/forgejo}" +FORGEJO_IMAGE_TAG="${FORGEJO_IMAGE_TAG:-latest}" + +log "Deploying Forgejo to namespace '$NAMESPACE' (mode=$MODE)" + +cat < Deployment mode (default: ${MODE:-k3d}) + -n, --namespace Target namespace (default: gitlab) + -c, --config Path to prole.cfg (defaults to detected) + --force Uninstall existing GitLab release before deploy + --help Show this help + +Behavior: + - Installs self-hosted GitLab via the official Helm chart. + - Uses CloudNativePG (CNPG) Postgres as the database backend (external DB). +EOF +} + +die() { echo "[ERROR] $*" >&2; exit 2; } +log() { echo "[INFO] $*"; } +warn() { echo "[WARN] $*" >&2; } + +while [[ $# -gt 0 ]]; do + case "$1" in + --mode) MODE="$(prole_normalize_mode "${2:-}")"; shift 2 ;; + --mode=*) MODE="$(prole_normalize_mode "${1#*=}")"; shift 1 ;; + -n|--namespace) NAMESPACE="${2:-}"; shift 2 ;; + --namespace=*) NAMESPACE="${1#*=}"; shift 1 ;; + -c|--config) CFG_PATH="${2:-}"; shift 2 ;; + --config=*) CFG_PATH="${1#*=}"; shift 1 ;; + --force) FORCE=1; shift 1 ;; + -h|--help) usage; exit 0 ;; + *) break ;; + esac +done + +# Resolve config path and namespace defaults from prole.cfg when present +if [[ -z "$CFG_PATH" && -n "${PROLE_CONF:-}" && -f "${PROLE_CONF}/prole.cfg" ]]; then + CFG_PATH="${PROLE_CONF}/prole.cfg" +elif [[ -z "$CFG_PATH" && -f "$SCRIPT_DIR/../conf/prole.cfg" ]]; then + CFG_PATH="$SCRIPT_DIR/../conf/prole.cfg" +fi + +if [[ -z "$NAMESPACE" && -n "$CFG_PATH" ]]; then + maybe_ns="$(_prole_cfg_extract_key "$CFG_PATH" "GITOPS_NAMESPACE")" + [[ -z "$maybe_ns" ]] && maybe_ns="$(_prole_cfg_extract_key "$CFG_PATH" "GITLAB_NAMESPACE")" + NAMESPACE="$maybe_ns" +fi +NAMESPACE="${NAMESPACE:-gitlab}" +export GITLAB_NAMESPACE="$NAMESPACE" + +case "$MODE" in + k3d|k3s|k8s|local) ;; + *) die "Unsupported mode '$MODE' (use k3d, k3s, k8s, or local)" ;; +esac +export PROLE_MODE="$MODE" + +command -v kubectl >/dev/null || die "kubectl not found" +command -v helm >/dev/null || die "helm not found (required for GitLab install)" + +DB_NAMESPACE="${PROLE_NAMESPACE:-}" +if [[ -z "$DB_NAMESPACE" && -n "$CFG_PATH" ]]; then + DB_NAMESPACE="$(_prole_cfg_extract_key "$CFG_PATH" "NAMESPACE")" +fi +DB_NAMESPACE="${DB_NAMESPACE:-default}" + +CNPG_CLUSTER_NAME="${CNPG_CLUSTER_NAME:-prole-db}" +GITLAB_DB_NAME="${GITLAB_DB_NAME:-gitlabhq_production}" +GITLAB_DB_USER="${GITLAB_DB_USER:-gitlab}" +GITLAB_DB_PASSWORD="${GITLAB_DB_PASSWORD:-}" +if [[ -z "$GITLAB_DB_PASSWORD" ]]; then + GITLAB_DB_PASSWORD=$(LC_ALL=C tr -dc 'A-Za-z0-9' /dev/null | head -c 32 || true) +fi + +DB_HOST="${CNPG_CLUSTER_NAME}-rw.${DB_NAMESPACE}.svc.cluster.local" +DB_PORT="${GITLAB_DB_PORT:-5432}" + +kubectl get ns "$NAMESPACE" >/dev/null 2>&1 || kubectl create namespace "$NAMESPACE" >/dev/null 2>&1 + +ensure_db() { + local primary_pod="" + primary_pod=$(kubectl -n "$DB_NAMESPACE" get pods \ + -l "cnpg.io/cluster=${CNPG_CLUSTER_NAME},role=primary" \ + -o jsonpath='{.items[0].metadata.name}' 2>/dev/null || true) + if [[ -z "$primary_pod" ]]; then + primary_pod=$(kubectl -n "$DB_NAMESPACE" get pods \ + -l "cnpg.io/cluster=${CNPG_CLUSTER_NAME}" \ + -o jsonpath='{.items[0].metadata.name}' 2>/dev/null || true) + fi + if [[ -z "$primary_pod" ]]; then + die "Could not find CNPG pod for cluster '${CNPG_CLUSTER_NAME}' in namespace '${DB_NAMESPACE}'" + fi + + log "Ensuring database '${GITLAB_DB_NAME}' and role '${GITLAB_DB_USER}' exist in CNPG cluster '${CNPG_CLUSTER_NAME}' (ns=${DB_NAMESPACE})" + + kubectl -n "$DB_NAMESPACE" exec "$primary_pod" -- bash -lc "psql -v ON_ERROR_STOP=1 -U postgres -d postgres" </dev/null +} + +ensure_db + +RELEASE_NAME="${GITLAB_RELEASE_NAME:-gitlab}" + +if [[ "$FORCE" -eq 1 ]]; then + warn "--force specified; uninstalling existing release '$RELEASE_NAME' in namespace '$NAMESPACE' (if present)" + helm -n "$NAMESPACE" uninstall "$RELEASE_NAME" >/dev/null 2>&1 || true +fi + +GITLAB_DOMAIN="${GITLAB_DOMAIN:-${DOMAIN:-${PROLE_DOMAIN:-}}}" +GITLAB_DOMAIN="${GITLAB_DOMAIN:-example.local}" + +log "Adding/updating GitLab Helm repo" +helm repo add gitlab https://charts.gitlab.io/ >/dev/null 2>&1 || true +helm repo update >/dev/null + +log "Installing GitLab (release=$RELEASE_NAME, ns=$NAMESPACE, domain=$GITLAB_DOMAIN)" + +# Notes: +# - We keep the chart install minimal to avoid assuming ingress/cert-manager setup. +# - We disable the bundled PostgreSQL and point GitLab to CNPG. +helm upgrade --install "$RELEASE_NAME" gitlab/gitlab \ + -n "$NAMESPACE" \ + --timeout 30m \ + --wait \ + --set global.hosts.domain="$GITLAB_DOMAIN" \ + --set global.hosts.https=false \ + --set certmanager.install=false \ + --set prometheus.install=false \ + --set postgresql.install=false \ + --set global.psql.host="$DB_HOST" \ + --set global.psql.port="$DB_PORT" \ + --set global.psql.username="$GITLAB_DB_USER" \ + --set global.psql.database="$GITLAB_DB_NAME" \ + --set global.psql.password.secret=gitlab-db \ + --set global.psql.password.key=password + +log "Done. Inspect services/pods with: kubectl -n $NAMESPACE get pods,svc" diff --git a/infrastructure/inventory/host_vars/merlin.prole.org.yml b/infrastructure/inventory/host_vars/merlin.prole.org.yml index 70a1a7c..a1b28c3 100644 --- a/infrastructure/inventory/host_vars/merlin.prole.org.yml +++ b/infrastructure/inventory/host_vars/merlin.prole.org.yml @@ -49,8 +49,19 @@ k3s_service_node_labels: [] # iSCSI # `/var/lib/rancher` is local host storage (do not manage it via iSCSI). -iscsi_portal: 10.0.0.203 +iscsi_portal: 10.0.0.203:3260 -iscsi_targets: [] +iscsi_targets: + # PROLE-DATA-4 + # Repurposed from myrddin's former `/var/lib/rancher` Synology LUN. + - iqn: "iqn.2000-01.com.synology:synology.Target-15.292d45194a1" + chap_user: "prole" + chap_password: "{{ vault_iscsi_prole_password }}" + mounts: + - name: d004 + path: /prole/d004 + fstype: xfs + opts: "_netdev,noatime" + mkfs_if_missing: true iscsi_absent_mounts: [] diff --git a/infrastructure/roles/iscsi/tasks/iscsi_mount.yml b/infrastructure/roles/iscsi/tasks/iscsi_mount.yml index 760111c..7e5b771 100644 --- a/infrastructure/roles/iscsi/tasks/iscsi_mount.yml +++ b/infrastructure/roles/iscsi/tasks/iscsi_mount.yml @@ -4,6 +4,121 @@ path: "{{ m.path }}" state: directory mode: "0755" + tags: + - iscsi + - iscsi_mount + - iscsi_storage + +- name: Reset computed mount source + ansible.builtin.set_fact: + _iscsi_mount_src: "" + tags: + - iscsi + - iscsi_mount + - iscsi_storage + +- name: Decide whether a block device is required for this mount + ansible.builtin.set_fact: + _iscsi_need_blockdev: >- + {{ (m.mkfs_once | default(false) | bool) + or (m.mkfs_if_missing | default(false) | bool) + or (m.device is defined and (m.device | string | length) > 0) + or (m.src is not defined) or ((m.src | default('') | string | length) == 0) }} + tags: + - iscsi + - iscsi_mount + - iscsi_storage + +- name: Parse iSCSI portal (host/port) + ansible.builtin.set_fact: + _iscsi_portal_host: "{{ (iscsi_portal | string).split(':')[0] }}" + _iscsi_portal_port: "{{ ((iscsi_portal | string).split(':') | length > 1) | ternary((iscsi_portal | string).split(':')[1], '3260') }}" + when: + - _iscsi_need_blockdev | default(false) | bool + - iscsi_portal is defined + - (iscsi_portal | string | length) > 0 + tags: + - iscsi + - iscsi_mount + - iscsi_storage + +- name: Use explicitly provided block device (if any) + ansible.builtin.set_fact: + _iscsi_blockdev: "{{ m.device }}" + when: + - _iscsi_need_blockdev | default(false) | bool + - m.device is defined + - (m.device | string | length) > 0 + tags: + - iscsi + - iscsi_mount + - iscsi_storage + +- name: Compute iSCSI by-path pattern + ansible.builtin.set_fact: + _iscsi_by_path_pattern: >- + ip-{{ _iscsi_portal_host }}:{{ _iscsi_portal_port }}-iscsi-{{ iscsi_target.iqn }}-lun-{{ (m.lun is defined) | ternary((m.lun | string), '*') }} + when: + - _iscsi_need_blockdev | default(false) | bool + - (_iscsi_blockdev is not defined) or ((_iscsi_blockdev | string | length) == 0) + - iscsi_target is defined + - iscsi_target.iqn is defined + - _iscsi_portal_host is defined + - (_iscsi_portal_host | string | length) > 0 + - _iscsi_portal_port is defined + - (_iscsi_portal_port | string | length) > 0 + tags: + - iscsi + - iscsi_mount + - iscsi_storage + +- name: Wait for iSCSI by-path device to appear + ansible.builtin.find: + paths: + - /dev/disk/by-path + patterns: + - "{{ _iscsi_by_path_pattern }}" + file_type: any + register: _iscsi_by_path_find + until: (_iscsi_by_path_find.matched | default(0) | int) > 0 + retries: "{{ ((m.device_timeout | default(60)) | int // 5) + 1 }}" + delay: 5 + when: + - _iscsi_need_blockdev | default(false) | bool + - (_iscsi_blockdev is not defined) or ((_iscsi_blockdev | string | length) == 0) + - _iscsi_by_path_pattern is defined + - (_iscsi_by_path_pattern | string | length) > 0 + tags: + - iscsi + - iscsi_mount + - iscsi_storage + +- name: Select iSCSI block device from by-path matches + ansible.builtin.set_fact: + _iscsi_blockdev: "{{ (_iscsi_by_path_find.files | map(attribute='path') | list | sort | first) }}" + when: + - _iscsi_need_blockdev | default(false) | bool + - (_iscsi_blockdev is not defined) or ((_iscsi_blockdev | string | length) == 0) + - _iscsi_by_path_find is defined + - (_iscsi_by_path_find.matched | default(0) | int) > 0 + tags: + - iscsi + - iscsi_mount + - iscsi_storage + +- name: Read existing filesystem type (if any) + ansible.builtin.command: "blkid -o value -s TYPE {{ _iscsi_blockdev }}" + register: _iscsi_blkid_type + changed_when: false + failed_when: false + when: + - _iscsi_need_blockdev | default(false) | bool + - _iscsi_blockdev is defined + - (_iscsi_blockdev | string | length) > 0 + tags: + - iscsi + - iscsi_mount + - iscsi_storage - name: Ensure marker directory exists (mkfs_once) ansible.builtin.file: @@ -13,22 +128,33 @@ group: root mode: "0755" when: m.mkfs_once | default(false) | bool + tags: + - iscsi + - iscsi_mkfs + - iscsi_storage - name: Compute mkfs marker path (mkfs_once) ansible.builtin.set_fact: _iscsi_mkfs_marker: >- /var/lib/prole/iscsi/mkfs- - {{ (m.src | regex_replace('^UUID=', 'uuid-') - | regex_replace('^LABEL=', 'label-') - | regex_replace('[^A-Za-z0-9_.-]', '_')) }} + {{ (((iscsi_target.iqn | default('unknown-iqn')) ~ '-' ~ (m.path | default('unknown-path'))) + | regex_replace('[^A-Za-z0-9_.-]', '_')) }} .done when: m.mkfs_once | default(false) | bool + tags: + - iscsi + - iscsi_mkfs + - iscsi_storage - name: Check mkfs marker presence (mkfs_once) ansible.builtin.stat: path: "{{ _iscsi_mkfs_marker }}" register: _iscsi_mkfs_marker_stat when: m.mkfs_once | default(false) | bool + tags: + - iscsi + - iscsi_mkfs + - iscsi_storage - name: Fail if mountpoint is already mounted but mkfs_once requested ansible.builtin.command: "findmnt -n {{ m.path }}" @@ -38,43 +164,98 @@ when: - m.mkfs_once | default(false) | bool - _iscsi_mkfs_marker_stat.stat.exists is not defined or not _iscsi_mkfs_marker_stat.stat.exists + tags: + - iscsi + - iscsi_mkfs + - iscsi_storage - name: Refuse to mkfs when mountpoint is mounted ansible.builtin.fail: - msg: "Refusing to mkfs {{ m.src }} because {{ m.path }} is already mounted ({{ _iscsi_findmnt_before_mkfs.stdout | default('') }})." + msg: "Refusing to mkfs for {{ m.path }} because it is already mounted ({{ _iscsi_findmnt_before_mkfs.stdout | default('') }})." when: - m.mkfs_once | default(false) | bool - _iscsi_mkfs_marker_stat.stat.exists is not defined or not _iscsi_mkfs_marker_stat.stat.exists - _iscsi_findmnt_before_mkfs.rc == 0 + tags: + - iscsi + - iscsi_mkfs + - iscsi_storage -- name: Resolve block device for mkfs (mkfs_once) - ansible.builtin.command: >- - {{ (m.src | regex_search('^UUID=') ) - | ternary('blkid -U ' ~ (m.src | regex_replace('^UUID=', '')), - (m.src | regex_search('^LABEL=') ) - | ternary('blkid -L ' ~ (m.src | regex_replace('^LABEL=', '')), - 'echo ' ~ (m.src))) }} - register: _iscsi_mkfs_dev - changed_when: false - when: - - m.mkfs_once | default(false) | bool - - _iscsi_mkfs_marker_stat.stat.exists is not defined or not _iscsi_mkfs_marker_stat.stat.exists - -- name: Require resolved device to look like /dev/* (mkfs_once) +- name: Require iSCSI block device path for mkfs_once ansible.builtin.assert: that: - - (_iscsi_mkfs_dev.stdout | default('') | trim) is match('^/dev/') - fail_msg: "Unable to resolve a /dev/* block device for mkfs from src={{ m.src }} (got: {{ _iscsi_mkfs_dev.stdout | default('') | trim }})." + - _iscsi_blockdev is defined + - (_iscsi_blockdev | string | length) > 0 + fail_msg: "mkfs_once requested for {{ m.path }} but no block device was computed (set m.device or ensure iscsi_target+iscsi_portal are available)." when: - m.mkfs_once | default(false) | bool - _iscsi_mkfs_marker_stat.stat.exists is not defined or not _iscsi_mkfs_marker_stat.stat.exists + tags: + - iscsi + - iscsi_mkfs + - iscsi_storage -- name: Re-initialize filesystem (mkfs.ext4) (mkfs_once) - ansible.builtin.command: "mkfs.ext4 -F {{ _iscsi_mkfs_dev.stdout | trim }}" +- name: Re-initialize filesystem (mkfs_once) + ansible.builtin.command: >- + {{ ((m.fstype | default('ext4')) == 'xfs') + | ternary( + 'mkfs.xfs -f ' ~ _iscsi_blockdev, + (((m.fstype | default('ext4')) == 'ext4') + | ternary( + 'mkfs.ext4 -F ' ~ _iscsi_blockdev, + 'mkfs -t ' ~ (m.fstype | default('ext4')) ~ ' ' ~ _iscsi_blockdev + ) + ) + ) }} changed_when: true when: - m.mkfs_once | default(false) | bool - _iscsi_mkfs_marker_stat.stat.exists is not defined or not _iscsi_mkfs_marker_stat.stat.exists + tags: + - iscsi + - iscsi_mkfs + - iscsi_storage + +- name: Create filesystem when missing (mkfs_if_missing) + ansible.builtin.command: >- + {{ ((m.fstype | default('ext4')) == 'xfs') + | ternary( + 'mkfs.xfs -f ' ~ _iscsi_blockdev, + (((m.fstype | default('ext4')) == 'ext4') + | ternary( + 'mkfs.ext4 -F ' ~ _iscsi_blockdev, + 'mkfs -t ' ~ (m.fstype | default('ext4')) ~ ' ' ~ _iscsi_blockdev + ) + ) + ) }} + changed_when: true + when: + - m.mkfs_if_missing | default(false) | bool + - _iscsi_blockdev is defined + - (_iscsi_blockdev | string | length) > 0 + - _iscsi_blkid_type is not defined or (_iscsi_blkid_type.stdout | default('') | trim) == '' + tags: + - iscsi + - iscsi_mkfs + - iscsi_storage + +- name: Refuse to mount when existing filesystem type does not match + ansible.builtin.fail: + msg: >- + Existing filesystem type on {{ _iscsi_blockdev }} is + '{{ _iscsi_blkid_type.stdout | default('') | trim }}' but mount {{ m.path }} + requests fstype='{{ m.fstype | default('ext4') }}'. + Wipe the device signature (e.g. wipefs) or set mkfs_once=true if you intend + to reformat this LUN. + when: + - _iscsi_blkid_type is defined + - (_iscsi_blkid_type.stdout | default('') | trim) != '' + - (_iscsi_blkid_type.stdout | trim) != (m.fstype | default('ext4')) + - not (m.mkfs_once | default(false) | bool) + tags: + - iscsi + - iscsi_mount + - iscsi_storage - name: Write mkfs marker (mkfs_once) ansible.builtin.file: @@ -86,11 +267,64 @@ when: - m.mkfs_once | default(false) | bool - _iscsi_mkfs_marker_stat.stat.exists is not defined or not _iscsi_mkfs_marker_stat.stat.exists + tags: + - iscsi + - iscsi_mkfs + - iscsi_storage + +- name: Resolve mount source + ansible.builtin.set_fact: + _iscsi_mount_src: "{{ m.src }}" + when: + - m.src is defined + - (m.src | string | length) > 0 + tags: + - iscsi + - iscsi_mount + - iscsi_storage + +- name: Resolve mount source UUID from block device + ansible.builtin.command: "blkid -o value -s UUID {{ _iscsi_blockdev }}" + register: _iscsi_blkid_uuid + changed_when: false + when: + - (_iscsi_mount_src | default('') | string | length) == 0 + - _iscsi_blockdev is defined + - (_iscsi_blockdev | string | length) > 0 + tags: + - iscsi + - iscsi_mount + - iscsi_storage + +- name: Require resolved UUID for mounting + ansible.builtin.assert: + that: + - _iscsi_blkid_uuid is defined + - (_iscsi_blkid_uuid.stdout | default('') | trim) != '' + fail_msg: "Unable to resolve a filesystem UUID for {{ m.path }} from {{ _iscsi_blockdev }}." + when: (_iscsi_mount_src | default('') | string | length) == 0 + tags: + - iscsi + - iscsi_mount + - iscsi_storage + +- name: Set mount source to UUID + ansible.builtin.set_fact: + _iscsi_mount_src: "UUID={{ _iscsi_blkid_uuid.stdout | trim }}" + when: (_iscsi_mount_src | default('') | string | length) == 0 + tags: + - iscsi + - iscsi_mount + - iscsi_storage - name: Mount {{ m.path }} ansible.builtin.mount: path: "{{ m.path }}" - src: "{{ m.src }}" + src: "{{ _iscsi_mount_src }}" fstype: "{{ m.fstype | default('ext4') }}" opts: "{{ m.opts | default('_netdev,noatime') }}" state: mounted + tags: + - iscsi + - iscsi_mount + - iscsi_storage diff --git a/infrastructure/roles/iscsi/tasks/iscsi_target_mounts.yml b/infrastructure/roles/iscsi/tasks/iscsi_target_mounts.yml new file mode 100644 index 0000000..1efb017 --- /dev/null +++ b/infrastructure/roles/iscsi/tasks/iscsi_target_mounts.yml @@ -0,0 +1,9 @@ +--- +- name: Mount target filesystems + ansible.builtin.include_tasks: iscsi_mount.yml + loop: "{{ t.mounts | default([]) }}" + loop_control: + loop_var: m + label: "{{ m.path | default('') }}" + vars: + iscsi_target: "{{ t }}" diff --git a/infrastructure/roles/iscsi/tasks/login.yml b/infrastructure/roles/iscsi/tasks/login.yml index 3790410..20b9a8e 100644 --- a/infrastructure/roles/iscsi/tasks/login.yml +++ b/infrastructure/roles/iscsi/tasks/login.yml @@ -4,13 +4,33 @@ name: - open-iscsi - util-linux + - xfsprogs state: present + tags: + - iscsi + - iscsi_login + - iscsi_storage - name: Ensure iscsid enabled ansible.builtin.service: name: iscsid state: started enabled: true + tags: + - iscsi + - iscsi_login + - iscsi_storage + +- name: Discover iSCSI targets + ansible.builtin.command: >- + iscsiadm -m discovery -t st -p {{ iscsi_portal }} + register: _iscsi_discovery + changed_when: false + tags: + - iscsi + - iscsi_discovery + - iscsi_login + - iscsi_storage - name: Configure and login iSCSI targets ansible.builtin.include_tasks: iscsi_target.yml @@ -18,3 +38,7 @@ loop_control: loop_var: t label: "{{ t.iqn | default('') }}" + tags: + - iscsi + - iscsi_login + - iscsi_storage diff --git a/infrastructure/roles/iscsi/tasks/mount.yml b/infrastructure/roles/iscsi/tasks/mount.yml index 4bef7cd..da08d23 100644 --- a/infrastructure/roles/iscsi/tasks/mount.yml +++ b/infrastructure/roles/iscsi/tasks/mount.yml @@ -17,11 +17,11 @@ | map(attribute='mounts') | list | flatten | selectattr('path', 'equalto', '/var/lib/rancher') | list | length) == 0 - fail_msg: >- - Inventory attempts to mount `/var/lib/rancher` via iSCSI. K3s state storage is - intended to be local host storage (e.g. USB3 SSD on myrddin). Remove - `/var/lib/rancher` from `iscsi_targets[*].mounts` (or set - `iscsi_allow_rancher_mount: true` if you are intentionally overriding this). + fail_msg: >- + Inventory attempts to mount `/var/lib/rancher` via iSCSI. K3s state storage is + intended to be local host storage (e.g. USB3 SSD on myrddin). Remove + `/var/lib/rancher` from `iscsi_targets[*].mounts` (or set + `iscsi_allow_rancher_mount: true` if you are intentionally overriding this). when: not (iscsi_allow_rancher_mount | default(false) | bool) - name: Remove stale iSCSI fstab entries @@ -36,10 +36,15 @@ - iscsi_cleanup - name: Mount filesystems for iSCSI targets - ansible.builtin.include_tasks: iscsi_mount.yml - loop: "{{ iscsi_targets | default([]) | map(attribute='mounts') | list | flatten }}" + ansible.builtin.include_tasks: iscsi_target_mounts.yml + loop: "{{ iscsi_targets | default([]) }}" loop_control: - loop_var: m + loop_var: t + label: "{{ t.iqn | default('') }}" + tags: + - iscsi + - iscsi_mount + - iscsi_storage - name: Guardrail - ensure required mountpoints are mounted ansible.builtin.command: "findmnt -n {{ item.path }}" @@ -49,3 +54,7 @@ loop: "{{ iscsi_targets | default([]) | map(attribute='mounts') | list | flatten }}" loop_control: label: "{{ item.path }}" + tags: + - iscsi + - iscsi_mount + - iscsi_storage diff --git a/installer/core/controller.py b/installer/core/controller.py index c93a323..0ad3df3 100644 --- a/installer/core/controller.py +++ b/installer/core/controller.py @@ -110,6 +110,28 @@ class ProleController: except Exception: pass + # Ensure shared shell libraries are available for etc/ scripts. + # Many scripts source `etc/common_core_lib.sh`, which in turn expects + # `../lib/shell/common_core_lib.sh` relative to `$PROLE_HOME/etc`. + try: + source_lib_shell = self.project_root / "lib" / "shell" + target_lib_shell = prole_home / "lib" / "shell" + if source_lib_shell.exists(): + target_lib_shell.mkdir(parents=True, exist_ok=True) + for src in source_lib_shell.rglob("*"): + if not src.is_file(): + continue + rel = src.relative_to(source_lib_shell) + dst = target_lib_shell / rel + dst.parent.mkdir(parents=True, exist_ok=True) + try: + if (not dst.exists()) or (src.stat().st_mtime > dst.stat().st_mtime): + shutil.copy2(src, dst) + except Exception: + pass + except Exception: + pass + # Copy script and set permissions if source_script.exists(): if source_script.resolve() != target_script.resolve(): diff --git a/tests/installer/test_controller.py b/tests/installer/test_controller.py index da5450b..e1890ff 100644 --- a/tests/installer/test_controller.py +++ b/tests/installer/test_controller.py @@ -166,6 +166,40 @@ def test_run_script_copies_k8s_and_conf(tmp_path): assert (target / "conf").exists() +def test_run_script_copies_lib_shell_for_etc_scripts(tmp_path): + """Regression: etc/ scripts may source ../lib/shell/common_core_lib.sh.""" + _scaffold_project(tmp_path) + + (tmp_path / "lib" / "shell").mkdir(parents=True, exist_ok=True) + (tmp_path / "lib" / "shell" / "common_core_lib.sh").write_text( + "#!/usr/bin/env bash\ncommon_core_preparse_config() { :; }\n" + ) + + script = tmp_path / "etc" / "needs_lib.sh" + script.write_text( + "#!/bin/bash\n" + "SCRIPT_DIR=$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\n" + "# shellcheck disable=SC1091\n" + "source \"$SCRIPT_DIR/../lib/shell/common_core_lib.sh\"\n" + "common_core_preparse_config\n" + "echo ok\n" + ) + + target = tmp_path / "prole_home_lib" + target.mkdir() + + ctrl = _make_controller(tmp_path) + lines: list[str] = [] + rc = ctrl.run_script( + "needs_lib.sh", + env={"PROLE_HOME": str(target)}, + on_line=lambda l: lines.append(l), + ) + assert rc == 0 + assert any("ok" in l for l in lines) + assert (target / "lib" / "shell" / "common_core_lib.sh").exists() + + def test_run_script_copies_db_version(tmp_path): """Covers the prole-db/.version copy path.""" _scaffold_project(tmp_path)