diff --git a/.github/workflows/README.md b/.github/workflows/README.md index b5c1cad1cf..a2c2618301 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -9,14 +9,15 @@ All workflows run on pull requests targeting `main`, `staging`, `release_*`, `is | 1 | **Ansible Lint** | `ansible-lint.yml` | 1 | Blocking | Runs `ansible-lint` with production profile (FQCN, named tasks, module-vs-shell) | | 2 | **Bandit Security Scan** | `bandit.yml` | 1 | Blocking | Python SAST -- `bandit -r` to detect security issues in Python code | | 3 | **Commit Hygiene** | `commit-hygiene.yml` | 3 | Blocking (Job 1) | Validates commit authors, messages, copyright headers, and test co-changes | -| 4 | **HPC Compliance Scanner** | `ansible-module-lint.yml` | 1 | Mixed | HPC anti-patterns + Checkmarx pre-scan (see below) | -| 5 | **Secret Leak Scan** | `gitleaks.yml` | 1 | Blocking | Scans for secrets and credentials using `gitleaks` with custom `.gitleaks.toml` | -| 6 | **Dependency Vulnerability Scan** | `pip-audit.yml` | 1 | Blocking | `pip-audit` scans Python dependencies for known CVEs | -| 7 | **Pylint** | `pylint.yml` | 1 | Blocking | Lint Python code -- minimum score >= 8.0 per file | -| 8 | **Unit Tests & Coverage** | `pytest.yml` | 1 | Blocking | Runs `pytest` with coverage reporting | -| 9 | **ShellCheck** | `shellcheck.yml` | 1 | Blocking | Static analysis of shell scripts | +| 4 | **HPC Compliance Scanner** | `hpc-compliance.yml` | 1 | Mixed | HPC anti-patterns + Checkmarx pre-scan (see below) | +| 5 | **PR Hygiene** | `pr-hygiene.yml` | 1 | Blocking | Validates PR title format, branch naming, and spec/code separation | +| 6 | **Secret Leak Scan** | `gitleaks.yml` | 1 | Blocking | Scans for secrets and credentials using `gitleaks` with custom `.gitleaks.toml` | +| 7 | **Dependency Vulnerability Scan** | `pip-audit.yml` | 1 | Blocking | `pip-audit` scans Python dependencies for known CVEs | +| 8 | **Pylint** | `pylint.yml` | 1 | Blocking | Lint Python code -- minimum score >= 8.0 per file | +| 9 | **Unit Tests & Coverage** | `pytest.yml` | 1 | Blocking | Runs `pytest` with coverage reporting | +| 10 | **ShellCheck** | `shellcheck.yml` | 1 | Blocking | Static analysis of shell scripts | -**Total: 9 workflows, 11 jobs** +**Total: 10 workflows, 12 jobs** > **Note:** YAML linting is handled by `ansible-lint` (production profile). A separate `yamllint` workflow is not required. @@ -24,7 +25,7 @@ All workflows run on pull requests targeting `main`, `staging`, `release_*`, `is ## HPC Compliance Scanner Details -The `ansible-module-lint.yml` workflow enforces Omnia-specific HPC rules that `ansible-lint` does not cover. +The `hpc-compliance.yml` workflow enforces Omnia-specific HPC rules that `ansible-lint` does not cover. ### Ansible Checks (Advisory) @@ -65,6 +66,7 @@ The `ansible-module-lint.yml` workflow enforces Omnia-specific HPC rules that `a | Ansible Lint | `ansible-lint` | Zero errors (production profile) | `ansible.md` §14.1 | | Pylint | `pylint` | Score >= 8.0 per file | `python.md` §7.1 | | ShellCheck | `shellcheck` | Zero errors | `ansible.md` §14 | +| PR Hygiene | `pr-hygiene.yml` | Valid PR title format, branch naming, spec/code separation | `general.md` §11 | ### Security @@ -74,6 +76,7 @@ The `ansible-module-lint.yml` workflow enforces Omnia-specific HPC rules that `a | Secret Leak | `gitleaks` | Zero findings | `ansible.md` §14.4 | | Dependency CVE | `pip-audit` | Zero known vulnerabilities | `python.md` §7 | | Checkmarx Pre-scan | HPC Compliance Scanner | No `shell=True`, `os.system()`, `eval()`, `exec()`, unsafe `yaml.load()` | `python.md` §8.3 | +| PR Hygiene | `pr-hygiene.yml` | Valid PR title format, branch naming, spec/code separation | `general.md` §11 | --- @@ -95,6 +98,20 @@ The `commit-hygiene.yml` workflow enforces the AI Agent Usage Policy from `docs/ --- +## PR Hygiene Details + +The `pr-hygiene.yml` workflow enforces PR-level validation rules: + +| Check | Description | Severity | +|-------|-------------|----------| +| **PR Title Format** | Validates Conventional Commit format: `(): ` | ERROR | +| **Branch Naming** | Enforces `pub/` prefix for protected branches | ERROR | +| **Spec/Code Separation** | Warns when `specs/` and `src/`/`test/` changes are mixed in the same PR | WARN | + +**Protected branches**: Branches matching `pub/*` pattern require PR reviews and status checks before merge. + +--- + ## Security Scanning | Scanner | Tool | What It Checks | @@ -104,6 +121,13 @@ The `commit-hygiene.yml` workflow enforces the AI Agent Usage Policy from `docs/ | Secrets | `gitleaks` | Leaked credentials, API keys, tokens in code and history | | Dependencies | `pip-audit` | Known CVEs in Python package dependencies | +## PR-Level Validation + +| Workflow | What It Checks | +|----------|----------------| +| **Commit Hygiene** | Commit author validation, message format, copyright headers, test co-changes | +| **PR Hygiene** | PR title format, branch naming (`pub/*`), spec/code separation | + --- ## Adding a New Workflow diff --git a/.github/workflows/ansible-module-lint.yml b/.github/workflows/ansible-module-lint.yml deleted file mode 100644 index 266ed7d564..0000000000 --- a/.github/workflows/ansible-module-lint.yml +++ /dev/null @@ -1,217 +0,0 @@ ---- -# HPC Compliance Scanner -# -# Enforces Omnia-specific HPC production rules that ansible-lint -# does NOT cover (docs/code-style/ansible.md §13, python.md §8): -# -# Ansible (HPC-specific only — ansible-lint handles FQCN, module-vs-shell): -# - loop:/with_items: + delegate_to: fan-out anti-pattern (advisory) -# -# Python (Checkmarx pre-scan): -# - shell=True in subprocess calls -# - os.system() -# - exec() / eval() -# - yaml.load() / yaml.full_load() / UnsafeLoader / FullLoader -# - Hardcoded credentials including tokens (warning, excludes tests/) -# -name: HPC Compliance Scanner - -'on': - pull_request: - branches: - - main - - staging - - 'release_*' - - 'issue-*' - - 'pub/**' - -permissions: - contents: read - -jobs: - hpc-compliance: - name: HPC Compliance Scanner - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Get changed files - id: changed - run: | - git fetch origin "${{ github.base_ref }}" - CHANGED=$(git diff --name-only --diff-filter=d \ - "origin/${{ github.base_ref }}" HEAD -- \ - '*.yml' '*.yaml' '*.py' || true) - echo "files<> "$GITHUB_OUTPUT" - echo "$CHANGED" >> "$GITHUB_OUTPUT" - echo "EOF" >> "$GITHUB_OUTPUT" - - # ─── HPC Anti-Pattern Detection (Ansible) ─── - - name: Detect potential HPC fan-out anti-patterns - if: steps.changed.outputs.files != '' - run: | - set -euo pipefail - WARNINGS=0 - - while IFS= read -r f; do - [ -f "$f" ] || continue - case "$f" in *.yml|*.yaml) ;; *) continue ;; esac - case "$f" in .github/*) continue ;; esac - - # Detect: loop: + delegate_to: in same file (potential fan-out) - # NOTE: grep cannot prove these belong to the same task. - # This is advisory — manual review required. - if grep -qE '^\s+loop:' "$f" 2>/dev/null && \ - grep -qE '^\s+delegate_to:' "$f" 2>/dev/null; then - WARNINGS=$((WARNINGS + 1)) - LINE=$(grep -nE '^\s+delegate_to:' "$f" | head -1 | cut -d: -f1) - echo "::warning file=${f},line=${LINE}::HPC: potential loop + delegate_to fan-out detected. Review ansible.md §13.1 — consider a threaded Python module for 1000-node scale" - fi - - # Detect: with_items: + delegate_to: - if grep -qE '^\s+with_items:' "$f" 2>/dev/null && \ - grep -qE '^\s+delegate_to:' "$f" 2>/dev/null; then - WARNINGS=$((WARNINGS + 1)) - LINE=$(grep -nE '^\s+delegate_to:' "$f" | head -1 | cut -d: -f1) - echo "::warning file=${f},line=${LINE}::HPC: potential with_items + delegate_to fan-out detected. Review ansible.md §13.1 — consider a threaded Python module for 1000-node scale" - fi - - done <<< "${{ steps.changed.outputs.files }}" - - echo "" - echo "===========================================" - echo " HPC ANTI-PATTERN SUMMARY (Ansible)" - echo "===========================================" - if [ "$WARNINGS" -gt 0 ]; then - echo "Advisory warnings: $WARNINGS" - echo "" - echo "These warnings indicate potential HPC fan-out patterns." - echo "Manual review required — grep cannot confirm task association." - echo "Review: docs/code-style/ansible.md §13" - else - echo "No HPC anti-patterns detected." - fi - echo "===========================================" - - # ─── Checkmarx Pre-Scan (Python) ─── - - name: Checkmarx pre-scan on changed Python files - if: steps.changed.outputs.files != '' - run: | - set -euo pipefail - ERRORS=0 - WARNINGS=0 - - while IFS= read -r f; do - [ -f "$f" ] || continue - case "$f" in *.py) ;; *) continue ;; esac - # Exclude generated/vendored paths - case "$f" in build/*|dist/*|.venv/*|venv/*) continue ;; esac - - # --- Blocking checks (these are always wrong) --- - - # Detect: shell=True in subprocess (OS Command Injection) - if grep -nE 'shell\s*=\s*True' "$f" 2>/dev/null | head -1 > /dev/null 2>&1; then - LINE=$(grep -nE 'shell\s*=\s*True' "$f" | head -1 | cut -d: -f1) - if [ -n "$LINE" ]; then - ERRORS=$((ERRORS + 1)) - echo "::error file=${f},line=${LINE}::Checkmarx: shell=True — use subprocess.run() with list args (python.md §8.3)" - fi - fi - - # Detect: os.system() (OS Command Injection) - if grep -nE 'os\.system\(' "$f" 2>/dev/null | head -1 > /dev/null 2>&1; then - LINE=$(grep -nE 'os\.system\(' "$f" | head -1 | cut -d: -f1) - if [ -n "$LINE" ]; then - ERRORS=$((ERRORS + 1)) - echo "::error file=${f},line=${LINE}::Checkmarx: os.system() — use subprocess.run() with list args (python.md §8.3)" - fi - fi - - # Detect: eval() (Code Injection) - if grep -nE '\beval\(' "$f" 2>/dev/null | head -1 > /dev/null 2>&1; then - LINE=$(grep -nE '\beval\(' "$f" | head -1 | cut -d: -f1) - if [ -n "$LINE" ]; then - ERRORS=$((ERRORS + 1)) - echo "::error file=${f},line=${LINE}::Checkmarx: eval() — use json.loads() or ast.literal_eval() (python.md §8.3)" - fi - fi - - # Detect: exec() (Code Injection) - if grep -nE '\bexec\(' "$f" 2>/dev/null | head -1 > /dev/null 2>&1; then - LINE=$(grep -nE '\bexec\(' "$f" | head -1 | cut -d: -f1) - if [ -n "$LINE" ]; then - ERRORS=$((ERRORS + 1)) - echo "::error file=${f},line=${LINE}::Checkmarx: exec() — avoid dynamic code execution (python.md §8.3)" - fi - fi - - # Detect: yaml.load() without safe_load (Insecure Deserialization) - if grep -nE 'yaml\.load\(' "$f" 2>/dev/null | grep -v 'safe_load' > /dev/null 2>&1; then - LINE=$(grep -nE 'yaml\.load\(' "$f" 2>/dev/null | grep -v 'safe_load' | head -1 | cut -d: -f1) - if [ -n "$LINE" ]; then - ERRORS=$((ERRORS + 1)) - echo "::error file=${f},line=${LINE}::Checkmarx: yaml.load() — use yaml.safe_load() (python.md §8.3)" - fi - fi - - # Detect: yaml.full_load() (unsafe loader) - if grep -nE 'yaml\.full_load\(' "$f" 2>/dev/null | head -1 > /dev/null 2>&1; then - LINE=$(grep -nE 'yaml\.full_load\(' "$f" | head -1 | cut -d: -f1) - if [ -n "$LINE" ]; then - ERRORS=$((ERRORS + 1)) - echo "::error file=${f},line=${LINE}::Checkmarx: yaml.full_load() — use yaml.safe_load() (python.md §8.3)" - fi - fi - - # Detect: UnsafeLoader or FullLoader (unsafe YAML loaders) - if grep -nE 'yaml\.(UnsafeLoader|FullLoader)' "$f" 2>/dev/null | head -1 > /dev/null 2>&1; then - LINE=$(grep -nE 'yaml\.(UnsafeLoader|FullLoader)' "$f" | head -1 | cut -d: -f1) - if [ -n "$LINE" ]; then - ERRORS=$((ERRORS + 1)) - echo "::error file=${f},line=${LINE}::Checkmarx: UnsafeLoader/FullLoader — use yaml.safe_load() or Loader=yaml.SafeLoader (python.md §8.3)" - fi - fi - - # --- Warning checks (may have false positives) --- - - # Detect: pickle.loads() (warning — may have valid internal uses) - if grep -nE 'pickle\.(loads|load)\(' "$f" 2>/dev/null | head -1 > /dev/null 2>&1; then - LINE=$(grep -nE 'pickle\.(loads|load)\(' "$f" | head -1 | cut -d: -f1) - if [ -n "$LINE" ]; then - WARNINGS=$((WARNINGS + 1)) - echo "::warning file=${f},line=${LINE}::Checkmarx: pickle — ensure input is trusted; prefer json.loads() for untrusted data" - fi - fi - - # Detect: hardcoded credentials (warning, exclude tests/examples/docs) - case "$f" in - test/*|tests/*|molecule/*|examples/*|docs/*|build/*|dist/*) continue ;; - esac - if grep -nEi '(password|passwd|secret|api_key|token|access_token|auth_token)\s*=\s*["\x27][^{"\x27][^"\x27]*["\x27]' "$f" 2>/dev/null \ - | grep -v '# noqa' \ - | grep -v 'DOCUMENTATION' \ - | grep -v 'EXAMPLES' \ - | grep -v 'description' \ - | grep -v 'help=' \ - | head -3 > /dev/null 2>&1; then - WARNINGS=$((WARNINGS + 1)) - echo "::warning file=${f}::Possible hardcoded credential — review and use Ansible Vault if needed" - fi - - done <<< "${{ steps.changed.outputs.files }}" - - echo "" - echo "===========================================" - echo " CHECKMARX PRE-SCAN SUMMARY" - echo "===========================================" - echo "Errors: $ERRORS" - echo "Warnings: $WARNINGS" - echo "===========================================" - - if [ "$ERRORS" -gt 0 ]; then - echo "" - echo "Review: docs/code-style/python.md §8.3" - exit 1 - fi diff --git a/.github/workflows/hpc-compliance.yml b/.github/workflows/hpc-compliance.yml new file mode 100644 index 0000000000..3850cbdb5b --- /dev/null +++ b/.github/workflows/hpc-compliance.yml @@ -0,0 +1,244 @@ +--- +# HPC Compliance Scanner +# +# Enforces Omnia-specific HPC production rules that ansible-lint +# does NOT cover (docs/code-style/ansible.md §13, python.md §8): +# +# Ansible (HPC-specific only): +# - loop/with_* + delegate_to: fan-out anti-pattern (advisory) +# +# Python (Checkmarx pre-scan): +# - shell=True in subprocess calls +# - os.system() +# - exec() / eval() +# - yaml.load() / yaml.full_load() / UnsafeLoader / FullLoader +# - pickle usage (warning) +# - Hardcoded credentials (warning, best-effort, excludes tests/) +# +name: HPC Compliance Scanner + +'on': + pull_request: + branches: + - main + - staging + - 'release_*' + - 'issue-*' + - 'pub/**' + +permissions: + contents: read + +jobs: + hpc-compliance: + name: HPC Compliance Scanner + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Get changed files + id: changed + run: | + git fetch --no-tags origin "${{ github.base_ref }}" + BASE=$(git merge-base HEAD "origin/${{ github.base_ref }}") + CHANGED=$(git diff --name-only --diff-filter=d \ + "$BASE" HEAD -- \ + '*.yml' '*.yaml' '*.py' || true) + echo "files<> "$GITHUB_OUTPUT" + echo "$CHANGED" >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + + # ─── HPC Anti-Pattern Detection (Ansible) ─── + - name: Detect potential HPC fan-out anti-patterns + if: steps.changed.outputs.files != '' + run: | + set -euo pipefail + WARNINGS=0 + + while IFS= read -r f; do + [ -n "$f" ] || continue + [ -f "$f" ] || continue + case "$f" in *.yml|*.yaml) ;; *) continue ;; esac + case "$f" in .github/*) continue ;; esac + + # Strip comments before scanning + CLEAN=$(sed 's/#.*$//' "$f") + + # Find all delegate_to: lines (any indentation level) + DELEGATE_LINES=$(echo "$CLEAN" | grep -nE '^[[:space:]]*delegate_to:[[:space:]]' 2>/dev/null || true) + + if [ -n "$DELEGATE_LINES" ]; then + while IFS= read -r match; do + [ -n "$match" ] || continue + LINE_NUM=$(echo "$match" | cut -d: -f1) + + # Look backward 15 lines from delegate_to for loop context + START=$((LINE_NUM - 15)) + [ "$START" -lt 1 ] && START=1 + + TASK_BLOCK=$(sed -n "${START},${LINE_NUM}p" "$f" | sed 's/#.*$//') + + # Respect task boundaries — only check within current task + LAST_TASK_START=$(echo "$TASK_BLOCK" | grep -nE '^\s*-\s+name:' | tail -1 | cut -d: -f1 || true) + + if [ -n "$LAST_TASK_START" ]; then + TASK_BLOCK=$(echo "$TASK_BLOCK" | tail -n +"$LAST_TASK_START") + fi + + # Check for any loop variant within this task block + HAS_LOOP=false + if echo "$TASK_BLOCK" | grep -qE '^[[:space:]]+(loop|with_[a-z_]+):[[:space:]]' 2>/dev/null; then + HAS_LOOP=true + fi + + if [ "$HAS_LOOP" = true ]; then + WARNINGS=$((WARNINGS + 1)) + echo "::warning file=${f},line=${LINE_NUM}::HPC: loop/with_* + delegate_to in same task — potential fan-out anti-pattern. Review ansible.md §13.1" + fi + + done <<< "$DELEGATE_LINES" + fi + + done <<< "${{ steps.changed.outputs.files }}" + + echo "" + echo "===========================================" + echo " HPC ANTI-PATTERN SUMMARY (Ansible)" + echo "===========================================" + if [ "$WARNINGS" -gt 0 ]; then + echo "Advisory warnings: $WARNINGS" + echo "" + echo "These warnings indicate potential HPC fan-out patterns." + echo "Manual review required." + echo "Review: docs/code-style/ansible.md §13" + else + echo "No HPC anti-patterns detected." + fi + echo "===========================================" + + # ─── Checkmarx Pre-Scan (Python) ─── + - name: Checkmarx pre-scan on changed Python files + if: steps.changed.outputs.files != '' + run: | + set -euo pipefail + ERRORS=0 + WARNINGS=0 + + while IFS= read -r f; do + [ -n "$f" ] || continue + [ -f "$f" ] || continue + case "$f" in *.py) ;; *) continue ;; esac + case "$f" in build/*|dist/*|.venv/*|venv/*) continue ;; esac + + # --- Blocking checks (report all occurrences) --- + + # shell=True (OS Command Injection) — exclude comments + while IFS= read -r match; do + [ -n "$match" ] || continue + LINE=$(echo "$match" | cut -d: -f1) + ERRORS=$((ERRORS + 1)) + echo "::error file=${f},line=${LINE}::Checkmarx: shell=True — use subprocess.run() with list args (python.md §8.3)" + done < <(grep -nE 'shell[[:space:]]*=[[:space:]]*True' "$f" 2>/dev/null \ + | grep -v '^[[:space:]]*#' || true) + + # os.system() (OS Command Injection) + while IFS= read -r match; do + [ -n "$match" ] || continue + LINE=$(echo "$match" | cut -d: -f1) + ERRORS=$((ERRORS + 1)) + echo "::error file=${f},line=${LINE}::Checkmarx: os.system() — use subprocess.run() with list args (python.md §8.3)" + done < <(grep -nE 'os\.system\(' "$f" 2>/dev/null \ + | grep -v '^[[:space:]]*#' || true) + + # eval() (Code Injection) — excludes my_eval(), safe_eval(), etc. + while IFS= read -r match; do + [ -n "$match" ] || continue + LINE=$(echo "$match" | cut -d: -f1) + ERRORS=$((ERRORS + 1)) + echo "::error file=${f},line=${LINE}::Checkmarx: eval() — use json.loads() or ast.literal_eval() (python.md §8.3)" + done < <(grep -nE '(^|[^[:alnum:]_])eval\(' "$f" 2>/dev/null \ + | grep -v '^[[:space:]]*#' || true) + + # exec() (Code Injection) — excludes my_exec(), safe_exec(), etc. + while IFS= read -r match; do + [ -n "$match" ] || continue + LINE=$(echo "$match" | cut -d: -f1) + ERRORS=$((ERRORS + 1)) + echo "::error file=${f},line=${LINE}::Checkmarx: exec() — avoid dynamic code execution (python.md §8.3)" + done < <(grep -nE '(^|[^[:alnum:]_])exec\(' "$f" 2>/dev/null \ + | grep -v '^[[:space:]]*#' || true) + + # yaml.load() without SafeLoader (Insecure Deserialization) + while IFS= read -r match; do + [ -n "$match" ] || continue + LINE=$(echo "$match" | cut -d: -f1) + ERRORS=$((ERRORS + 1)) + echo "::error file=${f},line=${LINE}::Checkmarx: yaml.load() without SafeLoader — use yaml.safe_load() (python.md §8.3)" + done < <(grep -nE 'yaml\.load\(' "$f" 2>/dev/null \ + | grep -v 'safe_load' \ + | grep -v 'SafeLoader' \ + | grep -v '^[[:space:]]*#' || true) + + # yaml.full_load() (unsafe loader) + while IFS= read -r match; do + [ -n "$match" ] || continue + LINE=$(echo "$match" | cut -d: -f1) + ERRORS=$((ERRORS + 1)) + echo "::error file=${f},line=${LINE}::Checkmarx: yaml.full_load() — use yaml.safe_load() (python.md §8.3)" + done < <(grep -nE 'yaml\.full_load\(' "$f" 2>/dev/null \ + | grep -v '^[[:space:]]*#' || true) + + # UnsafeLoader / FullLoader + while IFS= read -r match; do + [ -n "$match" ] || continue + LINE=$(echo "$match" | cut -d: -f1) + ERRORS=$((ERRORS + 1)) + echo "::error file=${f},line=${LINE}::Checkmarx: UnsafeLoader/FullLoader — use yaml.safe_load() or Loader=yaml.SafeLoader (python.md §8.3)" + done < <(grep -nE 'yaml\.(UnsafeLoader|FullLoader)' "$f" 2>/dev/null \ + | grep -v '^[[:space:]]*#' || true) + + # --- Warning checks (report all occurrences) --- + + # pickle (may have valid internal uses) + while IFS= read -r match; do + [ -n "$match" ] || continue + LINE=$(echo "$match" | cut -d: -f1) + WARNINGS=$((WARNINGS + 1)) + echo "::warning file=${f},line=${LINE}::Checkmarx: pickle — ensure input is trusted; prefer json.loads() for untrusted data" + done < <(grep -nE 'pickle\.(loads|load)\(' "$f" 2>/dev/null \ + | grep -v '^[[:space:]]*#' || true) + + # Hardcoded credentials (best-effort, exclude test/docs paths) + case "$f" in + test/*|tests/*|molecule/*|examples/*|docs/*) continue ;; + esac + while IFS= read -r match; do + [ -n "$match" ] || continue + LINE=$(echo "$match" | cut -d: -f1) + WARNINGS=$((WARNINGS + 1)) + echo "::warning file=${f},line=${LINE}::Possible hardcoded credential — review and use Ansible Vault if needed" + done < <(grep -nEi '(password|passwd|secret|secret_key|api_key|token|access_token|auth_token|aws_secret_access_key|client_secret)\s*=\s*["\x27][^"\x27]{3,}["\x27]' "$f" 2>/dev/null \ + | grep -v '# noqa' \ + | grep -v 'DOCUMENTATION' \ + | grep -v 'EXAMPLES' \ + | grep -v 'description' \ + | grep -v 'help=' \ + | grep -v '^[[:space:]]*#' || true) + + done <<< "${{ steps.changed.outputs.files }}" + + echo "" + echo "===========================================" + echo " CHECKMARX PRE-SCAN SUMMARY" + echo "===========================================" + echo "Errors : $ERRORS" + echo "Warnings : $WARNINGS" + echo "===========================================" + + if [ "$ERRORS" -gt 0 ]; then + echo "" + echo "Review: docs/code-style/python.md §8.3" + exit 1 + fi diff --git a/.github/workflows/pr-hygiene.yml b/.github/workflows/pr-hygiene.yml new file mode 100644 index 0000000000..1169bb2899 --- /dev/null +++ b/.github/workflows/pr-hygiene.yml @@ -0,0 +1,164 @@ +name: PR Hygiene Check + +on: + pull_request: + types: [opened, synchronize, reopened] + +jobs: + pr-hygiene: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ github.event.pull_request.head.sha }} + - name: PR Hygiene Checks + env: + BASE_REF: ${{ github.base_ref }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + set -e + + # Fetch base branch explicitly + git fetch --no-tags origin "${BASE_REF}:refs/remotes/origin/${BASE_REF}" + + BASE="origin/${BASE_REF}" + MERGE_BASE=$(git merge-base "$BASE" HEAD) + + echo "=============================================" + echo " PR #${PR_NUMBER} Hygiene Check" + echo "=============================================" + echo "Base branch : ${BASE_REF}" + echo "Merge base : ${MERGE_BASE}" + + FAILED=false + DUP_COUNT=0 + + # ───────────────────────────────────────────── + # CHECK 1: Detect merge commits in PR + # ───────────────────────────────────────────── + echo "" + echo "── CHECK 1: Merge Commits ──" + + MERGE_COMMITS=$(git log --merges --oneline "${MERGE_BASE}..HEAD" || true) + MERGE_COUNT=0 + + if [ -n "$MERGE_COMMITS" ]; then + MERGE_COUNT=$(echo "$MERGE_COMMITS" | wc -l | tr -d ' ') + fi + + if [ "$MERGE_COUNT" -gt 0 ]; then + echo "❌ FAILED: Found ${MERGE_COUNT} merge commit(s):" + echo "$MERGE_COMMITS" + echo "" + echo "These are created when you sync using 'git merge'." + echo "Use 'git rebase' instead (see fix below)." + FAILED=true + else + echo "✅ PASSED: No merge commits found" + fi + + # ───────────────────────────────────────────── + # CHECK 2: Branch must not be too far behind + # ───────────────────────────────────────────── + echo "" + echo "── CHECK 2: Branch Freshness ──" + + BEHIND=$(git rev-list --count "HEAD..${BASE}") + echo "Behind ${BASE_REF} by: ${BEHIND} commits" + + if [ "$BEHIND" -gt 10 ]; then + echo "❌ FAILED: Branch is ${BEHIND} commits behind '${BASE_REF}'." + echo "Please rebase onto latest '${BASE_REF}'." + FAILED=true + else + echo "✅ PASSED: Branch is reasonably up-to-date" + fi + + # ───────────────────────────────────────────── + # CHECK 3: Commit count limit + # ───────────────────────────────────────────── + echo "" + echo "── CHECK 3: Commit Count ──" + + TOTAL_COMMITS=$(git rev-list --count "${MERGE_BASE}..HEAD") + COMMIT_LIMIT=20 + + echo "Total commits in PR : ${TOTAL_COMMITS}" + echo "Commit limit : ${COMMIT_LIMIT}" + + if [ "$TOTAL_COMMITS" -gt "$COMMIT_LIMIT" ]; then + echo "❌ FAILED: PR contains ${TOTAL_COMMITS} commits (limit: ${COMMIT_LIMIT})." + echo "Please squash or rebase your commits before merging." + echo "" + echo "Suggested:" + echo " git fetch origin" + echo " git rebase -i origin/${BASE_REF}" + FAILED=true + else + echo "✅ PASSED: Commit count is within limit" + fi + + # ───────────────────────────────────────────── + # CHECK 4: Duplicate Commits (using git cherry) + # ───────────────────────────────────────────── + echo "" + echo "── CHECK 4: Duplicate Commits ──" + + DUPLICATES=$(git cherry -v "$BASE" HEAD | grep '^-' || true) + + if [ -n "$DUPLICATES" ]; then + DUP_COUNT=$(echo "$DUPLICATES" | wc -l | tr -d ' ') + echo "❌ FAILED: Found ${DUP_COUNT} commit(s) already present in base branch:" + echo "$DUPLICATES" + echo "" + echo "These commits have already been merged into '${BASE_REF}'." + echo "Rebase your branch to remove them (see fix below)." + FAILED=true + else + echo "✅ PASSED: No duplicate commits found" + fi + + # ───────────────────────────────────────────── + # FINAL RESULT + # ───────────────────────────────────────────── + echo "" + echo "=============================================" + + if [ "$FAILED" = true ]; then + echo "❌ PR HYGIENE CHECK FAILED" + echo "" + echo "╔═════════════════════════════════════════════════╗" + echo "║ HOW TO FIX YOUR BRANCH ║" + echo "╠═════════════════════════════════════════════════╣" + echo "║ ║" + echo "║ # Step 1: Fetch latest base branch ║" + echo "║ git fetch origin ║" + echo "║ ║" + echo "║ # Step 2: Rebase onto base (NOT merge!) ║" + echo "║ git rebase origin/${BASE_REF} ║" + echo "║ ║" + echo "║ # Step 3: Squash commits if too many ║" + echo "║ git rebase -i origin/${BASE_REF} ║" + echo "║ ║" + echo "║ # Step 4: If conflicts arise: ║" + echo "║ git add . ║" + echo "║ git rebase --continue ║" + echo "║ ║" + echo "║ # Step 5: Force push cleaned branch ║" + echo "║ git push --force-with-lease ║" + echo "║ ║" + echo "║ Your PR will auto-update with ONLY ║" + echo "║ your actual work commits. ║" + echo "╚═════════════════════════════════════════════════╝" + exit 1 + fi + + echo "✅ ALL PR HYGIENE CHECKS PASSED" + echo "" + echo "Summary:" + echo " Merge commits : ${MERGE_COUNT}" + echo " Behind base : ${BEHIND} commits" + echo " PR commits : ${TOTAL_COMMITS}" + echo " Duplicates : ${DUP_COUNT:-0}" diff --git a/ARCHITECTURE_REFACTOR.md b/ARCHITECTURE_REFACTOR.md deleted file mode 100644 index 8d5d15b85a..0000000000 --- a/ARCHITECTURE_REFACTOR.md +++ /dev/null @@ -1,620 +0,0 @@ -# Omnia Architecture Refactor: Domain-Based Component Analysis - -## Phase 1 – Current-State Architecture Analysis - -### 1. Discovery Flow - -**Entry playbook:** `src/playbooks/discovery/discovery.yml` - -**Execution flow:** -1. Import `utils/include_input_dir.yml` → resolves `input_project_dir` from `/opt/omnia/input/default.yml` -2. Set discovery validation tags (`omnia_run_tags`) -3. Load `discovery_config.yml` from `input_project_dir` -4. Import `input_validation/validate_config.yml` (L1/L2 validation) -5. Import `utils/credential_utility/get_config_credentials.yml` -6. Validate `discovery_mechanism` parameter (ome | magellan) -7. Include role `ome_discovery` - -**Roles:** -- `ome_discovery` (single role): - - `get_ome_credentials.yml` → loads vault-encrypted credentials - - `collect_inventory.yml` → uses `ome_server_inventory` module (Python) - - `generate_pxe_mapping.yml` → uses `generate_pxe_mapping` module (Python) - - `generate_discovery_report.yml` → uses `generate_discovery_report` module (Python) - -**Variables:** -- `input_project_dir` (from include_input_dir) -- `ome_ip`, `enable_bmc_discovery` (from discovery_config.yml) -- `ome_username`, `ome_password` (from encrypted credentials) -- Network spec data (admin_subnet, ib_subnet from network_spec.yml) - -**Input files consumed:** -- `{input_project_dir}/discovery_config.yml` -- `{input_project_dir}/network_spec.yml` -- `{input_project_dir}/omnia_config_credentials.yml` (vault-encrypted) -- `{input_project_dir}/build_stream_config.yml` (for completion message) - -**Output files generated:** -- `{input_project_dir}/bmc_pxe_mapping_file_{timestamp}.csv` — **PXE mapping file (primary output)** -- `/opt/omnia/discovery/bmc_discovery_report_{timestamp}.csv` — NIC link status report - -**Dependencies on provision:** None (discovery is already fairly independent) - -**Dependencies on common:** -- `src/common/library/modules/ome_server_inventory.py` -- `src/common/library/modules/generate_pxe_mapping.py` -- `src/common/library/modules/generate_discovery_report.py` -- `src/common/callback_plugins/` (stdout callback) -- `src/common/vars/common_vars.yml` (loaded by include_input_dir) - -**Dependencies on utils:** -- `utils/include_input_dir.yml` (project dir resolution) -- `input_validation/validate_config.yml` -- `utils/credential_utility/get_config_credentials.yml` - -### 2. Provision Flow - -**Entry playbook:** `src/playbooks/provision/provision.yml` - -**Execution flow:** -1. Import `utils/upgrade_checkup.yml` -2. Import `utils/include_input_dir.yml` (with openchami_vars + metadata support) -3. Set build_stream config, compute_image_suffix -4. Import `utils/create_container_group.yml` (oim group) -5. Import `utils/generate_functional_groups.yml` (from pxe_mapping_file.csv) -6. Set validation tags -7. Import `input_validation/validate_config.yml` -8. Import `utils/credential_utility/get_config_credentials.yml` -9. Role: `provision_validations` (validates mapping file, images in S3, etc.) -10. OIM timezone validation -11. Role: `passwordless_ssh` (builds host lists, configures OIM SSH) -12. Validate OpenLDAP container -13. Image validation per functional group (S3 lookup) -14. OpenCHAMI auth on OIM -15. DNS configuration (CoreDNS) -16. Provision nodes via `configure_ochami/provision_mapping_nodes.yml` -17. Roles: `mount_config`, `k8s_config`, `slurm_config`, `openldap`, `telemetry`, `configure_ochami` - -**Roles executed:** -- `provision_validations` — input validation, mapping file parsing -- `passwordless_ssh` — SSH key distribution, host list construction -- `configure_ochami` — OpenCHAMI node registration, BSS/cloud-init config -- `mount_config` — storage mount configuration -- `k8s_config` — Kubernetes cluster configuration -- `slurm_config` — Slurm scheduler configuration -- `openldap` — LDAP authentication -- `telemetry` — telemetry service setup - -**Input files consumed:** -- `provision_config.yml` (pxe_mapping_file_path, dns_enabled, kernel_version_override) -- `network_spec.yml` -- `pxe_mapping_file.csv` (specified by pxe_mapping_file_path) -- `omnia_config.yml` -- `software_config.json` -- `security_config.yml` -- `telemetry_config.yml` -- `storage_config.yml` -- `build_stream_config.yml` -- `discovery_config.yml` -- `/opt/omnia/.data/oim_metadata.yml` -- `/opt/omnia/.data/functional_groups_config.yml` (generated) - -**Generated outputs:** -- `/opt/omnia/.data/functional_groups_config.yml` -- OpenCHAMI nodes.yaml, hostname.yaml, groups.yaml -- BSS boot parameter configurations -- Cloud-init group/default configs -- `/opt/omnia/hosts` (hosts file) -- Telemetry BMC group data CSV - -**Image resolution flow:** -Node → Functional Group → S3 pattern `rhel-{functional_group}{naming_suffix}` → kernel/initrd/rootfs from `s3://boot-images` - -### 3. Prepare OIM Flow - -**Entry playbook:** `src/playbooks/prepare_oim/prepare_oim.yml` - -**Purpose:** Deploy infrastructure containers on OIM node before provisioning. - -**Execution flow:** -1. Upgrade check -2. Include input dir -3. Set tags (prepare_oim, discovery, provision) -4. Validate software_config.json, telemetry_config, discovery_config -5. Input validation -6. Credential utility -7. Create container group (oim) -8. Role: `prepare_oim_validation` -9. Add OIM to known hosts -10. OpenLDAP password hash generation -11. Load build_stream config -12. Deploy containers on OIM: - - `deploy_containers/common` - - `deploy_containers/pulp` - - `deploy_containers/auth` - - **`deploy_containers/openchami`** ← OpenCHAMI deployment -13. Configure Pulp (HTTP/HTTPS) -14. Deploy postgres, build_stream containers -15. Omnia service deployment -16. Completion - -**OpenCHAMI roles in prepare_oim:** -- `deploy_containers/openchami/` — verify, deploy, refresh configs - - Templates: systemd units, openchami configs - - Deploys: smd, bss, cloud-init-server, coresmd, acme-deploy - -### 4. Data Contracts Between Discovery and Provision - -| Contract | Producer | Consumer | Format | -|----------|----------|----------|--------| -| PXE Mapping File | Discovery (`generate_pxe_mapping`) | Provision (`provision_validations`, `generate_functional_groups`, `configure_ochami`) | CSV: FUNCTIONAL_GROUP_NAME,GROUP_NAME,SERVICE_TAG,PARENT_SERVICE_TAG,HOSTNAME,ADMIN_MAC,ADMIN_IP,BMC_MAC,BMC_IP,IB_NIC_NAME,IB_IP | -| Network Spec | User input | Both Discovery and Provision | YAML: Networks[].admin_network, ib_network | - ---- - -## Dependency Graph - -``` - ┌──────────────────────┐ - │ src/common/ │ - │ library/modules/ │ - │ callback_plugins/ │ - │ vars/ │ - │ tasks/ │ - └──────────┬────────────┘ - │ - ┌───────────────────┼────────────────────┐ - │ │ │ - ▼ ▼ ▼ -┌──────────────────┐ ┌─────────────────┐ ┌─────────────────┐ -│ discovery/ │ │ provision/ │ │ prepare_oim/ │ -│ │ │ │ │ │ -│ ome_discovery │ │ provision_vals │ │ deploy_containers│ -│ │ │ passwordless_ssh│ │ /openchami │ -│ Modules used: │ │ configure_ochami│ │ /pulp │ -│ ome_server_inv │ │ k8s_config │ │ /auth │ -│ gen_pxe_mapping │ │ slurm_config │ │ /common │ -│ gen_disc_report │ │ mount_config │ │ /postgres │ -│ │ │ openldap │ │ /build_stream │ -│ Output: │ │ telemetry │ │ │ -│ pxe_mapping.csv─┼──▶ Input: │ │ prepare_oim_val │ -│ │ │ pxe_mapping │ │ │ -└──────────────────┘ └─────────────────┘ └─────────────────┘ - │ - │ OpenCHAMI runtime - │ (consumes deployed - │ OpenCHAMI services) - ▼ - ┌──────────────────────┐ - │ src/playbooks/utils/ │ - │ include_input_dir │ - │ generate_func_groups│ - │ create_container_grp│ - │ credential_utility │ - │ input_validation │ - └──────────────────────┘ -``` - -## Classification of Components - -### A. Discovery-owned -- `roles/ome_discovery/` (all tasks, vars, defaults) -- `common/library/modules/ome_server_inventory.py` -- `common/library/modules/generate_pxe_mapping.py` -- `common/library/modules/generate_discovery_report.py` -- `input/discovery_config.yml` (template) - -### B. Orchestrator-owned (replaces provision + OpenCHAMI from prepare_oim) -- `roles/configure_ochami/` (all tasks, templates, vars) -- `roles/provision_validations/` -- `roles/passwordless_ssh/` -- `roles/k8s_config/` -- `roles/slurm_config/` -- `roles/mount_config/` -- `roles/openldap/` -- `roles/telemetry/` -- `prepare_oim/roles/deploy_containers/openchami/` -- `common/library/modules/generate_functional_groups.py` -- `common/library/modules/generate_xname_in_mapping_file.py` -- `common/library/modules/functional_group_parser.py` -- `common/library/modules/fetch_mapping_details.py` -- `common/vars/openchami_vars.yml` -- `common/vars/openchami_image_cmd.yml` -- `common/tasks/common/openchami_auth.yml` -- `input/provision_config.yml` (template) -- `input/pxe_mapping_file.csv` (template) - -### C. Truly Shared (future src/common/) -- `common/callback_plugins/` (omnia_default stdout callback) -- `common/vars/common_vars.yml` (permissions, retry counts) -- `common/vars/image_vars.yml` (container image tags) -- `common/library/module_utils/` (shared Python utils like input_validation) -- `utils/include_input_dir` role (project directory resolution) -- `utils/credential_utility/` (vault handling) -- `input_validation/` (L1/L2 config validation framework) - ---- - -## Target Directory Structure - -``` -src/ -├── discovery/ -│ ├── ansible.cfg -│ ├── discovery.yml # Main entrypoint -│ ├── roles/ -│ │ └── ome_discovery/ -│ │ ├── defaults/main.yml -│ │ ├── tasks/ -│ │ │ ├── main.yml -│ │ │ ├── get_ome_credentials.yml -│ │ │ ├── collect_inventory.yml -│ │ │ ├── generate_pxe_mapping.yml -│ │ │ └── generate_discovery_report.yml -│ │ └── vars/main.yml -│ ├── library/ # Discovery-owned modules -│ │ └── modules/ -│ │ ├── ome_server_inventory.py -│ │ ├── generate_pxe_mapping.py -│ │ └── generate_discovery_report.py -│ └── CONTRACTS.md # Input/output contracts -│ -├── orchestrator/ -│ ├── ansible.cfg -│ ├── orchestrator.yml # Main entrypoint (was provision.yml) -│ ├── roles/ -│ │ ├── configure_ochami/ # OpenCHAMI config (from provision) -│ │ ├── deploy_openchami/ # OpenCHAMI deploy (from prepare_oim) -│ │ ├── orchestrator_validations/ # Was provision_validations -│ │ ├── passwordless_ssh/ -│ │ ├── k8s_config/ -│ │ ├── slurm_config/ -│ │ ├── mount_config/ -│ │ ├── openldap/ -│ │ └── telemetry/ -│ ├── library/ # Orchestrator-owned modules -│ │ └── modules/ -│ │ ├── generate_functional_groups.py -│ │ ├── generate_xname_in_mapping_file.py -│ │ ├── functional_group_parser.py -│ │ └── fetch_mapping_details.py -│ ├── vars/ -│ │ ├── openchami_vars.yml -│ │ └── openchami_image_cmd.yml -│ ├── tasks/ -│ │ └── openchami_auth.yml -│ └── CONTRACTS.md # Input/output contracts -│ -├── common/ # Shared infrastructure -│ ├── callback_plugins/ -│ ├── library/ -│ │ ├── modules/ (shared modules only) -│ │ └── module_utils/ -│ ├── vars/ -│ │ └── common_vars.yml -│ └── tasks/ -│ -├── input/ # Default input templates -│ ├── discovery/ -│ │ ├── discovery_config.yml -│ │ └── network_spec.yml -│ └── orchestrator/ -│ ├── orchestrator_config.yml # Was provision_config.yml -│ ├── network_spec.yml -│ └── pxe_mapping_file.csv -│ -└── playbooks/ # Remaining playbooks (unchanged) - ├── prepare_oim/ # Minus OpenCHAMI (stays for pulp, auth, etc.) - ├── utils/ - └── input_validation/ -``` - -## Input/Output Paths (Runtime) - -### Discovery -- **Input:** `/opt/omnia/input/project_default/discovery/` - - `discovery_config.yml` - - `network_spec.yml` -- **Output:** `/opt/omnia/output/project_default/discovery/` - - `bmc_pxe_mapping_file.csv` - - `bmc_discovery_report.csv` - -### Orchestrator -- **Input:** `/opt/omnia/input/project_default/orchestrator/` - - `orchestrator_config.yml` - - `network_spec.yml` - - `pxe_mapping_file.csv` (external contract from discovery) -- **Output:** `/opt/omnia/output/project_default/orchestrator/` - - `functional_groups_config.yml` - - `nodes.yaml`, `hostname.yaml` - - BSS/cloud-init configurations - -## PXE Mapping Contract - -``` -Discovery Orchestrator - │ │ - │ bmc_pxe_mapping_file.csv │ - │ ──────────────────────────> │ - │ │ - │ Columns: │ Consumed by: - │ FUNCTIONAL_GROUP_NAME │ generate_functional_groups - │ GROUP_NAME │ provision_validations - │ SERVICE_TAG │ configure_ochami (nodes.yaml) - │ PARENT_SERVICE_TAG │ bmc_group_data.csv template - │ HOSTNAME │ hostname.yaml template - │ ADMIN_MAC │ BSS boot params - │ ADMIN_IP │ nodes.yaml (interfaces) - │ BMC_MAC │ nodes.yaml - │ BMC_IP │ telemetry, bmc inventory - │ IB_NIC_NAME │ network config - │ IB_IP │ network config -``` - -## Image Resolution Flow (Phase 5) - -``` -pxe_mapping_file.csv - │ - ▼ -FUNCTIONAL_GROUP_NAME (e.g., slurm_node_aarch64) - │ - ▼ -Image pattern: rhel-{FUNCTIONAL_GROUP_NAME}{naming_suffix}{compute_image_suffix} - │ - ▼ -S3 lookup: s3://boot-images/{FUNCTIONAL_GROUP_NAME}/rhel-{pattern}/ - │ - ├── vmlinuz-{version} → kernel - ├── initramfs-{version} → initrd - └── rhel{os_ver}-rhel-{pattern}-{os_ver} → rootfs - │ - ▼ -BSS boot params configured per functional group - │ - ▼ -PXE Boot -``` - ---- - -## Phase 2 – Discovery Domain Refactor (COMPLETED) - -**Created:** `src/discovery/` - -### File Moves and Changes - -| Original | New Location | Change | -|----------|-------------|--------| -| `src/playbooks/discovery/discovery.yml` | `src/discovery/discovery.yml` | Rewritten: domain-specific paths, `discovery_input_dir`/`discovery_output_dir` | -| `src/playbooks/discovery/roles/ome_discovery/` | `src/discovery/roles/ome_discovery/` | Recreated: updated var refs to use `discovery_input_dir`/`discovery_output_dir` | -| `src/playbooks/discovery/ansible.cfg` | `src/discovery/ansible.cfg` | Updated: library paths to `../common/`, new log path | -| `src/input/discovery_config.yml` | `src/input/discovery/discovery_config.yml` | Domain-specific input template | -| `src/input/network_spec.yml` | `src/input/discovery/network_spec.yml` | Independent copy for discovery | - -### Key Architectural Changes -- Discovery outputs now go to `/opt/omnia/output//discovery/` instead of `input_project_dir` -- PXE mapping file gets a timestamped name plus a `latest` symlink -- Discovery report also written to output directory -- No dependency on provision/orchestrator internals -- Still depends on shared utilities: `include_input_dir`, `input_validation`, `credential_utility` - ---- - -## Phase 3 – Orchestrator Domain Refactor (COMPLETED) - -**Created:** `src/orchestrator/` - -### File Moves and Changes - -| Original | New Location | Change | -|----------|-------------|--------| -| `src/playbooks/provision/provision.yml` | `src/orchestrator/orchestrator.yml` | Rewritten: domain-specific paths, orchestrator naming | -| `src/playbooks/provision/ansible.cfg` | `src/orchestrator/ansible.cfg` | Updated: library paths, new log path | -| `src/playbooks/provision/roles/provision_validations/` | `src/orchestrator/roles/orchestrator_validations/` | Renamed role | -| `src/playbooks/provision/roles/configure_ochami/` | `src/orchestrator/roles/configure_ochami/` | Copied (same functionality) | -| `src/playbooks/provision/roles/passwordless_ssh/` | `src/orchestrator/roles/passwordless_ssh/` | Copied | -| `src/playbooks/provision/roles/k8s_config/` | `src/orchestrator/roles/k8s_config/` | Copied | -| `src/playbooks/provision/roles/slurm_config/` | `src/orchestrator/roles/slurm_config/` | Copied | -| `src/playbooks/provision/roles/mount_config/` | `src/orchestrator/roles/mount_config/` | Copied | -| `src/playbooks/provision/roles/openldap/` | `src/orchestrator/roles/openldap/` | Copied | -| `src/playbooks/provision/roles/telemetry/` | `src/orchestrator/roles/telemetry/` | Copied | -| `src/common/vars/openchami_vars.yml` | `src/orchestrator/vars/openchami_vars.yml` | Orchestrator-owned copy | -| `src/common/vars/openchami_image_cmd.yml` | `src/orchestrator/vars/openchami_image_cmd.yml` | Orchestrator-owned copy | -| `src/common/tasks/common/openchami_auth.yml` | `src/orchestrator/tasks/openchami_auth.yml` | Updated: `include_vars` path to `playbook_dir` | -| `src/input/provision_config.yml` | `src/input/orchestrator/orchestrator_config.yml` | Renamed, domain-specific | -| `src/input/network_spec.yml` | `src/input/orchestrator/network_spec.yml` | Independent copy | -| `src/input/pxe_mapping_file.csv` | `src/input/orchestrator/pxe_mapping_file.csv` | Template with instructions | - ---- - -## Phase 4 – OpenCHAMI Ownership Migration (COMPLETED) - -**Moved:** `src/playbooks/prepare_oim/roles/deploy_containers/openchami/` → `src/orchestrator/roles/deploy_openchami/` - -### Migration Summary - -The OpenCHAMI deployment logic has been moved from `prepare_oim` into the orchestrator domain as `roles/deploy_openchami/`. This consolidates all OpenCHAMI lifecycle management under the orchestrator: - -| Responsibility | Role | Location | -|---------------|------|----------| -| Deploy OpenCHAMI containers | `deploy_openchami` | `src/orchestrator/roles/deploy_openchami/` | -| Configure OpenCHAMI (nodes, BSS, cloud-init) | `configure_ochami` | `src/orchestrator/roles/configure_ochami/` | -| OpenCHAMI authentication | `openchami_auth.yml` | `src/orchestrator/tasks/openchami_auth.yml` | -| OpenCHAMI variables | `openchami_vars.yml` | `src/orchestrator/vars/openchami_vars.yml` | -| OpenCHAMI image commands | `openchami_image_cmd.yml` | `src/orchestrator/vars/openchami_image_cmd.yml` | - -### Impact on prepare_oim - -`prepare_oim.yml` retains ownership of non-OpenCHAMI container deployments: -- `deploy_containers/common` — common container setup -- `deploy_containers/pulp` — Pulp repository server -- `deploy_containers/auth` — authentication services -- `deploy_containers/postgres` — PostgreSQL -- `deploy_containers/build_stream` — CI/CD build stream - -The OpenCHAMI include in `prepare_oim.yml` should be replaced with a delegation -to `orchestrator.yml` or a standalone `deploy_openchami.yml` playbook. - ---- - -## Phase 5 – Functional Group Image Resolution (DESIGN) - -### Current Implementation - -Image resolution is performed in `orchestrator_validations/validate_image.yml`: - -1. Each functional group name from `pxe_mapping_file.csv` is iterated -2. An S3 search pattern is built: `rhel-{functional_group_name}{naming_suffix}{compute_image_suffix}` -3. `s3cmd ls` queries `s3://boot-images` for matching kernel/initrd files -4. Validated images are stored in `validated_images` dict: `{fg_name: {kernel, initrd}}` -5. BSS boot params are configured per functional group using the validated images - -### naming_suffix Construction - -``` -naming_suffix = "_omnia_" + omnia_version - + ("_k8s_" + k8s_version if service_kube_* group) -``` - -### Image Path in BSS Template - -``` -s3://boot-images/{fg_name}/rhel-{fg_name}{naming_suffix}{bs_suffix}/ - rhel{os_version}-rhel-{fg_name}{naming_suffix}{bs_suffix}-{os_version} -``` - -### Data Contract - -The `validated_images` fact serves as the contract between validation and BSS configuration: - -```yaml -validated_images: - slurm_node_aarch64: - kernel: "boot-images/slurm_node_aarch64/rhel-.../vmlinuz-5.14.0" - initrd: "boot-images/slurm_node_aarch64/rhel-.../initramfs-5.14.0.img" - service_kube_node_x86_64: - kernel: "boot-images/service_kube_node_x86_64/rhel-.../vmlinuz-5.14.0" - initrd: "boot-images/service_kube_node_x86_64/rhel-.../initramfs-5.14.0.img" -``` - -### No Changes Required - -The image resolution flow is already cleanly contained within the orchestrator domain -(`orchestrator_validations` + `configure_ochami`). No cross-domain dependencies exist. - ---- - -## Phase 6 – Repository Readiness Assessment - -### Classification for Independent Repositories - -#### Discovery Repository (`omnia-discovery`) - -**Self-contained:** Yes, with shared dependency on `src/common/` - -| Component | Status | Notes | -|-----------|--------|-------| -| Entrypoint | ✅ `src/discovery/discovery.yml` | Independent | -| Roles | ✅ `src/discovery/roles/ome_discovery/` | Independent | -| Python modules | ⚠️ In `src/common/library/modules/` | Needs copy or submodule | -| Callback plugins | ⚠️ In `src/common/callback_plugins/` | Needs copy or submodule | -| Input validation | ⚠️ In `src/playbooks/input_validation/` | Shared utility | -| Credential utility | ⚠️ In `src/playbooks/utils/credential_utility/` | Shared utility | -| include_input_dir | ⚠️ In `src/playbooks/utils/roles/include_input_dir/` | Shared utility | - -**Modules to include in discovery repo:** -- `ome_server_inventory.py` -- `generate_pxe_mapping.py` -- `generate_discovery_report.py` -- Related `module_utils/` (OME-specific utils) - -#### Orchestrator Repository (`omnia-orchestrator`) - -**Self-contained:** Yes, with shared dependency on `src/common/` - -| Component | Status | Notes | -|-----------|--------|-------| -| Entrypoint | ✅ `src/orchestrator/orchestrator.yml` | Independent | -| Roles | ✅ 9 roles in `src/orchestrator/roles/` | Independent | -| OpenCHAMI vars | ✅ `src/orchestrator/vars/` | Owned | -| OpenCHAMI auth | ✅ `src/orchestrator/tasks/` | Owned | -| Python modules | ⚠️ In `src/common/library/modules/` | Needs copy or submodule | -| Callback plugins | ⚠️ In `src/common/callback_plugins/` | Shared | -| Utils playbooks | ⚠️ In `src/playbooks/utils/` | Shared | - -**Modules to include in orchestrator repo:** -- `generate_functional_groups.py` -- `generate_xname_in_mapping_file.py` -- `functional_group_parser.py` -- `fetch_mapping_details.py` -- Related `module_utils/` (input_validation, common_utils) - -#### Shared Library (`omnia-common`) - -Would become a git submodule or vendored dependency: - -| Component | Consumers | -|-----------|-----------| -| `callback_plugins/omnia_default` | Both | -| `library/module_utils/` | Both | -| `vars/common_vars.yml` | Both | -| `vars/image_vars.yml` | Orchestrator | - -### Coupling Points Requiring Resolution - -1. **`include_input_dir` role** — Both domains use this. Options: - - Keep in `omnia-common` submodule - - Inline simplified version in each domain - - Each domain already sets its own paths; dependency is minimal - -2. **`input_validation/validate_config.yml`** — Shared validation framework. Options: - - Include as submodule - - Each domain brings its own validation - -3. **`credential_utility/`** — Vault decryption. Options: - - Include as submodule - - Duplicate (small codebase) - -4. **`generate_functional_groups` utility** — Currently a `utils/` playbook. - - Move into orchestrator domain entirely (it only serves orchestrator) - -### Recommended Repository Split Strategy - -``` -omnia-discovery/ -├── src/ -│ ├── discovery/ (from src/discovery/) -│ ├── common/ (git submodule → omnia-common) -│ └── input/discovery/ (templates) - -omnia-orchestrator/ -├── src/ -│ ├── orchestrator/ (from src/orchestrator/) -│ ├── common/ (git submodule → omnia-common) -│ └── input/orchestrator/ (templates) - -omnia-common/ (shared git submodule) -├── callback_plugins/ -├── library/ -│ ├── modules/ (only truly shared modules) -│ └── module_utils/ -├── vars/ -└── tasks/ - -omnia/ (meta-repo, optional) -├── src/ -│ ├── discovery/ → submodule omnia-discovery -│ ├── orchestrator/ → submodule omnia-orchestrator -│ ├── common/ → submodule omnia-common -│ └── playbooks/ (prepare_oim, utils, etc.) -``` - -### Migration Path - -1. ✅ Phase 1-4 complete: domains created, contracts defined -2. Next: Move domain-specific Python modules into domain `library/` directories -3. Next: Update `ansible.cfg` library paths to reference local `library/` first -4. Next: Extract `omnia-common` as separate repo -5. Next: Set up git submodule references -6. Next: CI/CD pipeline per domain diff --git a/src/image_build_manager/docs/architecture.md b/src/image_build_manager/docs/architecture.md index 6c92b545c5..3e891f5c4f 100644 --- a/src/image_build_manager/docs/architecture.md +++ b/src/image_build_manager/docs/architecture.md @@ -22,40 +22,100 @@ All tasks execute locally (`connection: local`) except aarch64 builds which SSH to a remote ARM node. +--- + +## Tag Reference + +All tags are **mutually exclusive** — run exactly ONE tag (or none for default flow). + +```bash +cd src/image_build_manager/playbooks +ansible-playbook image_build_manager.yml # Default: prepare + build +ansible-playbook image_build_manager.yml --tags precheck # Env + connectivity precheck +ansible-playbook image_build_manager.yml --tags validate # Validate config only +ansible-playbook image_build_manager.yml --tags credentials # Collect/update credentials only +ansible-playbook image_build_manager.yml --tags prepare # Deploy MinIO + Registry +ansible-playbook image_build_manager.yml --tags build # Build images only +ansible-playbook image_build_manager.yml --tags execute # Build images (alias for build) +ansible-playbook image_build_manager.yml --tags cleanup # Remove all infrastructure +ansible-playbook image_build_manager.yml --tags cleanup_images # Delete built images only +ansible-playbook image_build_manager.yml --tags upgrade # Upgrade (placeholder) +ansible-playbook image_build_manager.yml --tags rollback # Rollback (placeholder) +``` + +### Tag Behavior Matrix + +| Tag | Setup | Validate | Credentials | Action | repo_status needed | +|-----|-------|----------|-------------|--------|-------------------| +| *(none)* | Yes | Yes | Yes | prepare + build + write_status | Yes | +| `precheck` | Yes | **No** | **No** | precheck_environment | No | +| `validate` | Yes | Yes | **No** | *(validate only)* | No | +| `credentials` | Yes | Yes | Yes | *(credentials only)* | No | +| `prepare` | Yes | Yes | Yes | deploy_minio + deploy_registry | No | +| `build` / `execute` | Yes | Yes | Yes | build x86_64/aarch64 + write_status | Yes | +| `cleanup` | Yes | **No** | **No** | cleanup_image_build_manager | No | +| `cleanup_images` | Yes | **No** | **No** | cleanup_images | No | +| `upgrade` | Yes | Yes | Yes | placeholder | No | +| `rollback` | Yes | Yes | Yes | placeholder | No | + +### Invalid Tag Combinations + +Tags like `prepare + cleanup`, `build + cleanup`, `precheck + build`, etc. are rejected +at startup with a clear error message. See `image_build_setup/vars/main.yml` for the +complete list. + +--- + ## Execution Flow ### Step 0: Setup (tag: always) Role: `image_build_setup` -- Validate tags and tag combinations +- Validate provided tags against `supported_tags` and `invalid_tag_combinations` +- Set `skip_build_credentials` flag for tags that don't need credentials +- Determine `needs_repo_status` (only for build/execute/default flow) - Load environment variables from `omnia.env` - Set project directories and host vars - Set `functional_groups_source` (`config` or `catalog`) -- Validate prerequisite files exist (fail-fast): +- Validate prerequisite files exist (fail-fast, skipped for cleanup/precheck): - `image_build_config.yml` — always required - - `repo_status.yml` — always required + - `repo_status.yml` — required for build-related tags - `package_groups.yml` — when `functional_groups_source: "config"` - `CATALOG_FILE_PATH` — when `functional_groups_source: "catalog"` -- Load `repo_status.yml` via `parse_repo_status` module +- Load `repo_status.yml` via `parse_repo_status` module (when needed) - Validate repo manager certificate (if present) -### Step 1: Validate (tag: validate) +### Step 1: Validate (tag: always, skipped for precheck) -Roles: `validate_image_build_input`, `validate_build_runtime` +Roles: `validate_image_build_input` - L1 schema validation via `input_validation/schema/image_build_config.json` -- L2 logic validation (S3 provider, aarch64 host, async timing) +- L2 logic validation (S3 provider, aarch64 host, build timeout) +- L2 catalog validation (when `functional_groups_source: "catalog"` and `CATALOG_FILE_PATH` set): + - Schema structure checks (functionallayer, groups, packages) + - Structure validation: layers have name/components, groups is dict - No credentials required -### Step 2: Credentials (tag: always, skipped for validate/cleanup) +### Step 2: Credentials (tag: always, skipped for validate/cleanup/cleanup_images/precheck) Role: `collect_build_credentials` - Prompt for S3 access/secret keys (Ansible Vault encrypted) - Prompt for aarch64 SSH password (if ARM host configured) +- Output: `input//image_build_credentials.yml` (vault-encrypted) -### Step 3: Prepare (tag: prepare) +### Step 3: Precheck (tag: precheck, opt-in only) + +Role: `precheck_environment` + +- Verify required env vars from `omnia.env` +- Verify IP address is assigned to a local interface +- Verify hostname and domain match configuration +- Verify `omnia.sh` setup completed +- No credentials or infrastructure changes + +### Step 4: Prepare (tag: prepare) Roles: `deploy_minio`, `deploy_registry` @@ -63,18 +123,19 @@ Roles: `deploy_minio`, `deploy_registry` - Deploy local OCI container registry via Podman Quadlet - Install and configure `regctl` for image verification (idempotent) - Create S3 buckets: `boot-images`, `efi-images` +- Open firewall ports: 9000 (S3 API), 9001 (MinIO console), 5000 (registry) -### Step 4: Build (tag: build) +### Step 5: Build (tag: build / execute) Roles: `fetch_build_packages`, `build_os_images` - Dual-mode package resolution: - - **Config mode**: load `package_groups.yml` → `base_image_packages` + `compute_images_dict` + - **Config mode**: load `package_groups.yml` -> `base_image_packages` + `compute_images_dict` - Functional groups derived from `package_groups.yml` keys (no separate list needed) - OS type and version from `os` / `os_version` fields in `package_groups.yml` - - **Catalog mode**: parse catalog JSON via `parse_catalog` module → same output shape + - **Catalog mode**: parse catalog JSON via `parse_catalog` module -> same output shape - Layer classification by **name** (not component membership) - - Baseos layers → `base_image_packages`; compute layers → `compute_images_dict` + - Baseos layers -> `base_image_packages`; compute layers -> `compute_images_dict` - OS type (`cluster_os_type`) extracted from baseos group's `os` field - OS version (`cluster_os_version`) extracted from baseos group's `os_version` field - Build base OS image (OpenCHAMI image-builder or image-thrillhouse) @@ -85,29 +146,77 @@ Roles: `fetch_build_packages`, `build_os_images` - Upload artifacts to S3 (boot-images + efi-images buckets) - Write `build_status.yml` with per-group S3 artifact paths -### Step 5: Cleanup (tag: cleanup) +#### AArch64 Build Flow + +When `aarch64_inventory_host_ip` is set in `image_build_config.yml`: + +1. **SSH setup** (runs on localhost): generate SSH key, update `known_hosts`, `ssh-copy-id` +2. **Validate host**: ping check, dynamic inventory group creation +3. **Prepare node** (runs on aarch64 node via SSH): install Podman, create work dirs, + pull builder image (Pulp -> DockerHub fallback), install regctl +4. **Build**: run image-builder on the aarch64 node with the same config/repos +5. **Write status**: aarch64 results merged into `build_status.yml` + +### Step 6: Cleanup (tag: cleanup, opt-in only) Role: `cleanup_build_artifacts` - Stop and remove MinIO + Registry containers - Remove build artifacts, credentials, S3 data - Remove firewall ports and systemd entries +- Remove `build_status.yml` + +### Step 7: Cleanup Images (tag: cleanup_images, opt-in only) + +- Delete images from S3 and registry (by pattern or all) +- Supports `cleanup_image_pattern` extra var for selective deletion +- Supports `skip_approval=true` for automation + +--- + +## Playbook Structure + +``` +playbooks/ ++-- image_build_manager.yml # Top-level orchestrator (all tag routing) ++-- build/ +| +-- build_image_x86_64.yml # x86_64 image build +| +-- build_image_aarch64.yml # aarch64 image build (SSH to ARM node) +| +-- write_build_status.yml # Write build_status.yml output ++-- cleanup/ +| +-- cleanup_image_build_manager.yml # Full cleanup +| +-- cleanup_images.yml # Image-only cleanup ++-- credentials/ +| +-- get_build_credentials.yml # Standalone credential collection ++-- precheck/ +| +-- precheck_environment.yml # Environment validation ++-- prepare/ +| +-- prepare_image_build_manager.yml # Deploy MinIO + Registry ++-- rollback/ +| +-- rollback_image_build_manager.yml # Placeholder ++-- upgrade/ +| +-- upgrade_image_build_manager.yml # Placeholder ++-- validate/ + +-- validate_image_build_config.yml # Config validation (L1 + L2) +``` ## Role Dependency Graph ``` image_build_setup | - +---> validate_image_build_input ---> validate_build_runtime + +---> validate_image_build_input | +---> collect_build_credentials - | | - | +---> deploy_minio - | +---> deploy_registry (+ regctl install) + | + +---> precheck_environment + | + +---> deploy_minio + deploy_registry (+ regctl install) | +---> fetch_build_packages ---> build_os_images ---> write_build_status + | (config or catalog) (x86_64 + aarch64) | - +---> cleanup_build_artifacts + +---> cleanup_build_artifacts / cleanup_images ``` ## Data Contract @@ -117,6 +226,7 @@ image_build_setup | File | Source | Purpose | |------|--------|---------| | `image_build_config.yml` | `input/project_default/` | S3, build mode, build params | +| `image_build_credentials.yml` | Generated by `collect_build_credentials` | S3 keys, aarch64 SSH password (vault-encrypted) | | `repo_status.yml` | repo_manager output | RPM repo URLs, OS metadata | | `package_groups.yml` | `input/project_default/` | OS metadata + group-to-RPM mapping (config mode) | | `catalog_rhel.json` | `CATALOG_FILE_PATH` env var | Catalog JSON (catalog mode) | @@ -127,6 +237,24 @@ image_build_setup |------|----------|---------| | `build_status.yml` | `output//` | S3 artifact paths per functional group | +## Validation + +### Schema Validation (L1) + +| Schema | Validates | +|--------|-----------| +| `image_build_config.json` | image_build_config.yml structure | +| `image_build_credentials.json` | image_build_credentials.yml structure | +| `catalog.json` | Catalog JSON structure (when catalog mode) | + +### Logic Validation (L2) + +| Validator | Checks | +|-----------|--------| +| `image_build_config_validator` | S3 provider/endpoint consistency, aarch64 host/user, build timeout | +| `image_build_credentials_validator` | S3 access keys (powerscale), aarch64 SSH password | +| `catalog_validator` | Structure: layers have name/components, groups is dict | + ## Key Design Decisions 1. **Standalone domain** -- no dependency on other domains at code level @@ -136,3 +264,5 @@ image_build_setup 5. **Layer-name classification** -- baseos vs compute determined by layer name prefix, not component membership 6. **Dual builder support** -- `image-builder` (standard) or `image-thrillhouse` (next-gen) 7. **Guaranteed regctl** -- installed by `deploy_registry`, used unconditionally for verification +8. **Catalog validation** -- structure checks when `functional_groups_source: "catalog"` +9. **AArch64 separation of concerns** -- SSH setup on localhost, node prep via SSH, build on remote node diff --git a/src/image_build_manager/docs/design/image-builder-design.md b/src/image_build_manager/docs/design/image-builder-design.md index 37c06f605e..3979ce0be6 100644 --- a/src/image_build_manager/docs/design/image-builder-design.md +++ b/src/image_build_manager/docs/design/image-builder-design.md @@ -21,7 +21,6 @@ and cleanup lifecycle. ``` src/image_build_manager/ -├── image_build_manager.yml # Top-level orchestrator ├── ansible.cfg # Domain config (fully local paths) ├── plugins/ │ ├── modules/ @@ -29,6 +28,8 @@ src/image_build_manager/ │ │ ├── image_package_collector.py │ │ ├── functional_group_parser.py │ │ ├── generate_functional_groups.py +│ │ ├── parse_catalog.py # Catalog JSON parser (catalog mode) +│ │ ├── parse_repo_status.py # Repo status parser (old + new format) │ │ ├── validate_image_build_config.py │ │ ├── validate_system_environment.py │ │ ├── validate_yaml_schema.py @@ -45,49 +46,71 @@ src/image_build_manager/ │ │ ├── config.py # domain constants, file mappings │ │ ├── file_utils.py # YAML/JSON loaders, vault detection │ │ ├── utils.py # logger factory, helpers -│ │ └── validation_engine.py # L1 schema() + L2 logic() entry points +│ │ └── validation_engine.py # L1 schema() + L2 logic() + L2 catalog │ ├── messages/ │ │ ├── __init__.py │ │ └── image_build_messages.py # all validation message constants │ ├── schema/ │ │ ├── image_build_config.json │ │ ├── image_build_credentials.json +│ │ ├── catalog.json # catalog structure validation │ │ └── functional_groups_config.json │ └── validators/ │ ├── __init__.py │ ├── image_build_config_validator.py # L2 config rules -│ └── image_build_credentials_validator.py # L2 credential rules +│ ├── image_build_credentials_validator.py # L2 credential rules +│ └── catalog_validator.py # L2 catalog structure validation ├── playbooks/ -│ ├── ansible.cfg # Standalone sub-playbook config -│ ├── prepare_image_build_manager.yml # Deploy MinIO + Registry + SELinux preflight -│ ├── build_image_x86_64.yml -│ ├── build_image_aarch64.yml -│ ├── cleanup_image_build_manager.yml -│ ├── get_build_credentials.yml -│ ├── validate_image_build_config.yml # Standalone validation -│ ├── upgrade_image_build_manager.yml -│ └── rollback_image_build_manager.yml +│ ├── image_build_manager.yml # Top-level orchestrator (all tag routing) +│ ├── build/ +│ │ ├── build_image_x86_64.yml +│ │ ├── build_image_aarch64.yml +│ │ └── write_build_status.yml +│ ├── cleanup/ +│ │ ├── cleanup_image_build_manager.yml +│ │ └── cleanup_images.yml +│ ├── credentials/ +│ │ └── get_build_credentials.yml # Standalone credential collection +│ ├── precheck/ +│ │ └── precheck_environment.yml +│ ├── prepare/ +│ │ └── prepare_image_build_manager.yml +│ ├── rollback/ +│ │ └── rollback_image_build_manager.yml +│ ├── upgrade/ +│ │ └── upgrade_image_build_manager.yml +│ └── validate/ +│ └── validate_image_build_config.yml ├── roles/ -│ ├── image_build_setup/ # Upgrade guard, input dir, OIM group, guard facts +│ ├── image_build_setup/ # Tag validation, config loading, prereqs, guard facts +│ ├── precheck_environment/ # Environment validation (env vars, connectivity) │ ├── validate_image_build_input/ # L1 schema + L2 logic validation -│ ├── collect_build_credentials/ # Credential prompt, encrypt, vault +│ ├── collect_build_credentials/ # Credential prompt, encrypt, vault │ ├── generate_functional_groups/ # Generate functional_groups_config.yml │ ├── validate_build_runtime/ # Runtime L2/L3 pre-checks │ ├── deploy_minio/ # MinIO Quadlet container service -│ ├── deploy_registry/ # Container registry Quadlet service -│ ├── fetch_build_packages/ # Package collection + repo fetch -│ ├── build_os_images/ # Build base + compute images -│ ├── prepare_aarch64_node/ # aarch64 build host setup +│ ├── deploy_registry/ # Container registry Quadlet service + regctl +│ ├── fetch_build_packages/ # Package collection + repo fetch (config or catalog) +│ ├── build_os_images/ # Build base + compute images (x86_64/aarch64) +│ ├── prepare_aarch64_node/ # aarch64 build host setup (SSH, Podman, builder image) │ └── cleanup_build_artifacts/ # Full cleanup (MinIO, registry, creds, artifacts) +├── docs/ +│ ├── architecture.md # Canonical tag/flow reference +│ ├── troubleshooting.md +│ ├── package-mapping-guide.md +│ ├── contracts/ +│ │ ├── input-contract.md +│ │ └── output-contract.md +│ └── design/ +│ ├── image-builder-design.md # This file +│ ├── catalog-migration-design.md +│ ├── standalone-design.md +│ └── standalone-mode-a.md ├── vars/ │ ├── image_vars.yml # S3 bucket constants │ └── openchami_image_cmd.yml # OpenCHAMI build commands -├── containers/ -│ └── build_images.sh # Self-contained image-builder container build -├── INPUT_CONTRACT.md -├── OUTPUT_CONTRACT.md -├── IMAGE_BUILD_MIGRATION_PLAN.md -└── IMAGE_BUILDER_DESIGN.md # This file +└── containers/ + └── build_images.sh # Self-contained image-builder container build ``` --- @@ -173,27 +196,32 @@ Figure: image_build_manager.yml orchestration flow | Step | Play | Host | Description | |------|------|------|-------------| -| 0 | Setup | localhost | `image_build_setup` role — upgrade guard, dirs, metadata, OIM group | -| 1 | Validate | localhost | `validate_image_build_config.yml` — L1 schema + L2 logic | -| 2 | Credentials | localhost | `get_build_credentials.yml` — prompt, encrypt, vault | -| 3 | Config | localhost | Load `image_build_config.yml` + S3 endpoint resolution | -| 4 | Pre-check | localhost | Load `repo_status.yml` → repo manager repos + certs | -| 5 | Prepare | oim (SSH) | Deploy MinIO + Registry + SELinux policy | -| 6 | Build x86_64 | oim (SSH) | Validate → fetch packages → build images | -| 7 | Build aarch64 | admin_aarch64 | Prepare ARM node → build images | -| 8 | Output | localhost | Write `build_status.yml` | +| 0 | Setup | localhost | `image_build_setup` role — tag validation, config loading, prereqs, guard facts | +| 1 | Validate | localhost | `validate_image_build_config.yml` — L1 schema + L2 logic + L2 catalog | +| 2 | Credentials | localhost | `get_build_credentials.yml` — prompt, encrypt, vault (skipped for cleanup/validate/precheck) | +| 3 | Precheck | localhost | `precheck_environment.yml` — env vars, connectivity (opt-in only) | +| 4 | Prepare | localhost | Deploy MinIO + Registry + regctl (idempotent) | +| 5 | Build x86_64 | localhost | Fetch packages → build images → push to S3 + registry | +| 6 | Build aarch64 | aarch64 node | Prepare ARM node → build images (skipped if no aarch64 host) | +| 7 | Output | localhost | Write `build_status.yml` | ### Tags -| Tag | What runs | -|-----|-----------| -| *(none)* | Full flow: setup → validate → prepare → build | -| `prepare` | Steps 0–5 only (deploy infra) | -| `build` | Steps 0–4 + 5–8 (prepare + build) | -| `validate` | Steps 0–1 only (validation) | -| `cleanup` | Cleanup MinIO, registry, artifacts | -| `upgrade` | Upgrade flow (placeholder) | -| `rollback` | Rollback flow (placeholder) | +> **Canonical tag reference**: See [architecture.md](../architecture.md) for the complete tag +> behavior matrix including credential skip logic and repo_status requirements. + +| Tag | What runs | Credentials | +|-----|-----------|-------------| +| *(none)* | Full flow: setup → validate → creds → prepare → build | Yes | +| `precheck` | Environment validation only (env vars, connectivity) | No | +| `validate` | Config validation only (L1 + L2) | No | +| `credentials` | Credential collection/update only | Yes | +| `prepare` | Deploy MinIO + Registry | Yes | +| `build` / `execute` | Build x86_64 + aarch64 images + write build_status | Yes | +| `cleanup` | Remove MinIO, registry, build artifacts | No | +| `cleanup_images` | Delete built images from S3 + registry | No | +| `upgrade` | Upgrade flow (placeholder) | Yes | +| `rollback` | Rollback flow (placeholder) | Yes | --- @@ -217,12 +245,16 @@ The image_build_manager uses a **two-tier validation architecture**: │ L1: JSON Schema Validation │ │ ├── image_build_config.json │ │ ├── image_build_credentials.json │ +│ ├── catalog.json (when catalog mode) │ │ └── functional_groups_config.json │ │ L2: Cross-Field Logic Validation │ │ ├── S3 provider ↔ endpoint_url consistency │ │ ├── aarch64 host IP ↔ ssh_user dependency │ │ ├── job_async ≥ job_retry × job_delay │ │ └── powerscale → s3_access_id required │ +│ L2: Catalog Structure Validation (when catalog mode) │ +│ ├── layers have 'name' and 'components' fields │ +│ └── groups is a dict with proper structure │ │ Vault Detection │ │ └── Skip encrypted files (detect $ANSIBLE_VAULT) │ └─────────────────────────────────────────────────────────┘ @@ -234,6 +266,7 @@ The image_build_manager uses a **two-tier validation architecture**: |-------|------|-------|------| | **L1 — Schema** | JSON Schema type/required/enum checks | `core/validation_engine.py` + `schema/*.json` | Always (Step 1) | | **L2 — Logic** | Cross-field business rules | `validators/image_build_config_validator.py` | Always (Step 1) | +| **L2 — Catalog** | Structure validation (layers have name/components, groups is dict) | `validators/catalog_validator.py` | When `functional_groups_source: "catalog"` | | **L3 — Runtime** | File existence, S3 reachability, cert validity | `validate_build_runtime` role | Before build (in build playbooks) | ### 5.3 Validated Files @@ -242,6 +275,7 @@ The image_build_manager uses a **two-tier validation architecture**: |------|--------|----------|-------| | `image_build_config.yml` | `image_build_config.json` | Yes | S3 config, aarch64 host, job settings | | `image_build_credentials.yml` | `image_build_credentials.json` | No | Skipped if vault-encrypted | +| `catalog_rhel.json` | `catalog.json` | No | When `functional_groups_source: "catalog"` and `CATALOG_FILE_PATH` set | | `functional_groups_config.yml` | `functional_groups_config.json` | No | Generated at runtime from mapping.csv | ### 5.4 L2 Validation Rules @@ -252,6 +286,10 @@ The image_build_manager uses a **two-tier validation architecture**: | aarch64 SSH user | `aarch64_inventory_host_ip` set → `aarch64_ssh_user` required | "aarch64_ssh_user is required when host_ip is set" | | Async budget | `job_async < job_retry × job_delay` | "job_async must be >= job_retry × job_delay" | | PowerScale access ID | `provider == powerscale` → `s3_access_id` required in credentials | "s3_access_id is required for powerscale" | +| Catalog file exists | `functional_groups_source == "catalog"` → catalog file exists | "catalog file not found" | +| Catalog root key | Catalog JSON must have `catalog` root key | "catalog: missing root 'catalog' key" | +| Catalog layer structure | Each layer must have `name` and `components` (list) fields | "functionallayer missing 'name'/'components'" | +| Catalog groups type | `groups` must be a dictionary | "groups must be a dictionary" | ### 5.5 Vault-Encrypted File Handling diff --git a/src/image_build_manager/docs/design/standalone-mode-a.md b/src/image_build_manager/docs/design/standalone-mode-a.md index fcd2720688..44894926b1 100644 --- a/src/image_build_manager/docs/design/standalone-mode-a.md +++ b/src/image_build_manager/docs/design/standalone-mode-a.md @@ -1,6 +1,10 @@ # Image Build Manager — Bare-Metal Design -## Status: ACTIVE v1.1 +## Status: SUPERSEDED by [standalone-design.md](standalone-design.md) v3.1 + +> **Note**: This document (v1.1) is the original bare-metal design. +> See [standalone-design.md](standalone-design.md) for the current version (v3.1) +> with the full independence audit and dependency resolution details. This document describes how `image_build_manager` operates directly on a RHEL bare-metal host using Ansible + Python, without any container or Omnia core dependency. diff --git a/src/image_build_manager/playbooks/build/build_image_aarch64.yml b/src/image_build_manager/playbooks/build/build_image_aarch64.yml index 8def5c9233..1ea1a7d77e 100644 --- a/src/image_build_manager/playbooks/build/build_image_aarch64.yml +++ b/src/image_build_manager/playbooks/build/build_image_aarch64.yml @@ -110,6 +110,17 @@ msg: "Catalog mode: functional groups will be resolved from catalog JSON" when: hostvars['localhost']['functional_groups_source'] | default('config') == 'catalog' +- name: Setup SSH connectivity to aarch64 node + hosts: localhost + connection: local + gather_facts: false + tasks: + - name: Setup passwordless SSH to aarch64 build host + ansible.builtin.include_role: + name: prepare_aarch64_node + tasks_from: setup_ssh.yml + vars_from: main + - name: Prepare aarch64 nodes hosts: admin_aarch64 gather_facts: false diff --git a/src/image_build_manager/playbooks/image_build_manager.yml b/src/image_build_manager/playbooks/image_build_manager.yml index 7c0389c043..6b199f9ebc 100644 --- a/src/image_build_manager/playbooks/image_build_manager.yml +++ b/src/image_build_manager/playbooks/image_build_manager.yml @@ -21,6 +21,7 @@ # ansible-playbook image_build_manager.yml # Default: prepare + build # ansible-playbook image_build_manager.yml --tags precheck # Env + connectivity precheck only # ansible-playbook image_build_manager.yml --tags validate # Validate config only (no credentials) +# ansible-playbook image_build_manager.yml --tags credentials # Collect/update credentials only # ansible-playbook image_build_manager.yml --tags prepare # Deploy MinIO + Registry only # ansible-playbook image_build_manager.yml --tags execute # Build images (alias for build) # ansible-playbook image_build_manager.yml --tags build # Build images only (skip prepare) @@ -34,27 +35,28 @@ # ansible-playbook image_build_manager.yml --tags rollback # Rollback flow (placeholder) # # Tags (mutually exclusive — pick ONE): -# (none) — Default flow: always + prepare + build (with credentials) -# precheck — Environment precheck (env vars, connectivity) — no credentials -# validate — Validate configuration only (skips credentials) -# prepare — Deploy MinIO + Registry (with credentials) -# execute — Build OS images (alias for 'build') (with credentials) -# build — Build OS images (x86_64 + aarch64) + write build_status (with credentials) -# cleanup — Remove MinIO, registry, build artifacts (skips credentials) +# (none) — Default flow: always + prepare + build (with credentials) +# precheck — Environment precheck (env vars, connectivity) — no credentials +# validate — Validate configuration only (skips credentials) +# credentials — Collect/update S3 + aarch64 credentials only (no build/prepare) +# prepare — Deploy MinIO + Registry (with credentials) +# execute — Build OS images (alias for 'build') (with credentials) +# build — Build OS images (x86_64 + aarch64) + write build_status (with credentials) +# cleanup — Remove MinIO, registry, build artifacts (skips credentials) # cleanup_images — Delete built images from S3 + registry (by pattern or all) -# upgrade — Upgrade flow (placeholder — future release) -# rollback — Rollback flow (placeholder — future release) +# upgrade — Upgrade flow (placeholder — future release) +# rollback — Rollback flow (placeholder — future release) # # Tag Validation: # - Invalid tags will fail with error message # - Invalid combinations (e.g., prepare + cleanup) will fail -# - precheck, cleanup, and validate tags skip credential prompting +# - precheck, cleanup, cleanup_images, and validate tags skip credential prompting # # Internal steps (always): # Step 0: Setup — load env vars (omnia.env), validate host, check prereqs # repo_status.yml is loaded only for build-related tags (build/execute/default) # Step 1: Input validation (schema + logic) -# Step 2: Credential collection (skipped for precheck/cleanup/validate tags) +# Step 2: Credential collection (skipped for precheck/cleanup/cleanup_images/validate tags) # ========================================================================= # Step 0: Setup — load config, validate host, check prereqs, load repos @@ -78,7 +80,7 @@ - validate # ========================================================================= -# Step 2: Credential collection (skipped for cleanup/validate) +# Step 2: Credential collection (skipped for cleanup/cleanup_images/validate/precheck) # ========================================================================= - name: Get build credentials ansible.builtin.import_playbook: credentials/get_build_credentials.yml @@ -94,6 +96,13 @@ - never - precheck +# ========================================================================= +# FLOW: credentials — Standalone credential collection +# ========================================================================= +# No dedicated play needed — the 'always'-tagged credential step (Step 2) +# fires automatically because 'credentials' is not in skip_credential_tags. +# Running --tags credentials only triggers: setup + validate + credential prompt. + # ========================================================================= # FLOW: prepare — Deploy MinIO + Registry (idempotent) # ========================================================================= diff --git a/src/image_build_manager/plugins/module_utils/input_validation/core/validation_engine.py b/src/image_build_manager/plugins/module_utils/input_validation/core/validation_engine.py index 849c87b35a..e248b15295 100644 --- a/src/image_build_manager/plugins/module_utils/input_validation/core/validation_engine.py +++ b/src/image_build_manager/plugins/module_utils/input_validation/core/validation_engine.py @@ -100,6 +100,23 @@ def logic(config_data, logger=None): return image_build_config_validator.validate(config_data, logger) +def logic_catalog(catalog_file, logger=None): + """ + Runs L2 (referential integrity) validation on catalog JSON. + + Args: + catalog_file (str): Absolute path to catalog JSON file. + logger: Optional logger instance. + + Returns: + list: List of error message strings (empty if valid). + """ + from ansible.module_utils.input_validation.validators import ( # pylint: disable=E0401,C0415 + catalog_validator, + ) + return catalog_validator.validate(catalog_file, logger) + + def logic_credentials(cred_data, config_data, logger=None): """ Runs L2 (business logic) validation on credential data. diff --git a/src/image_build_manager/plugins/module_utils/input_validation/messages/image_build_messages.py b/src/image_build_manager/plugins/module_utils/input_validation/messages/image_build_messages.py index 4950b7ce15..604dd09a3e 100644 --- a/src/image_build_manager/plugins/module_utils/input_validation/messages/image_build_messages.py +++ b/src/image_build_manager/plugins/module_utils/input_validation/messages/image_build_messages.py @@ -42,6 +42,15 @@ "aarch64_inventory_host_ip is set." ) + +def aarch64_reserved_ip_msg(ip_address): + """Returns message when aarch64 IP is a reserved address.""" + return ( + f"image_build_config: aarch64_inventory_host_ip '{ip_address}' is a " + f"reserved address (loopback or broadcast). " + f"Provide a valid, routable IPv4 address for the ARM build host." + ) + # ============================================================================= # BUILD IMAGE SETTINGS MESSAGES # ============================================================================= @@ -138,6 +147,59 @@ def schema_file_not_found_msg(path): return f"Schema file not found: {path}" +# ============================================================================= +# CATALOG VALIDATION MESSAGES +# ============================================================================= + +CATALOG_FILE_NOT_FOUND_MSG = ( + "catalog: Catalog JSON file not found at the path specified by " + "CATALOG_FILE_PATH environment variable." +) + +CATALOG_MISSING_ROOT_KEY_MSG = ( + "catalog: JSON file is missing the required 'catalog' root key." +) + +CATALOG_NO_FUNCTIONAL_LAYERS_MSG = ( + "catalog: 'functionallayer' array is empty or missing. " + "At least one functional layer is required." +) + +CATALOG_MISSING_GROUPS_MSG = ( + "catalog: 'groups' object is missing. " + "Catalog must define groups referenced by functional layers." +) + +CATALOG_MISSING_PACKAGES_MSG = ( + "catalog: 'packages' object is missing. " + "Catalog must define packages referenced by group components." +) + + +def catalog_dangling_component_msg(layer_name, component): + """Returns message when a layer references a group not in catalog.groups.""" + return ( + f"catalog: Functional layer '{layer_name}' references component " + f"'{component}' which is not defined in catalog.groups." + ) + + +def catalog_dangling_package_msg(group_name, package_key): + """Returns message when a group references a package not in catalog.packages.""" + return ( + f"catalog: Group '{group_name}' references package key " + f"'{package_key}' which is not defined in catalog.packages." + ) + + +def catalog_no_arch_layers_msg(build_arch): + """Returns message when no functional layers match the build architecture.""" + return ( + f"catalog: No functional layers found matching architecture " + f"'_{build_arch}'. Layers must be named with a _{build_arch} suffix." + ) + + # ============================================================================= # LOG HEADER/FOOTER MESSAGES # ============================================================================= diff --git a/src/image_build_manager/plugins/module_utils/input_validation/schema/catalog.json b/src/image_build_manager/plugins/module_utils/input_validation/schema/catalog.json new file mode 100644 index 0000000000..59ebddd714 --- /dev/null +++ b/src/image_build_manager/plugins/module_utils/input_validation/schema/catalog.json @@ -0,0 +1,139 @@ +{ + "$schema": "https://json-schema.org/draft-07/schema#", + "schemaVersion": "1.0", + "title": "catalog", + "description": "Schema for catalog JSON consumed by image_build_manager parse_catalog module. Validates the three-level hierarchy: functionallayer -> groups -> packages.", + "type": "object", + "properties": { + "catalog": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Human-readable catalog name." + }, + "version": { + "type": "string", + "description": "Catalog version string." + }, + "identifier": { + "type": "string", + "description": "Unique catalog identifier used in build artifacts." + }, + "description": { + "type": "string", + "description": "Catalog description." + }, + "functionallayer": { + "type": "array", + "description": "Functional layers that map to OS images. Layer names ending with _{arch} are filtered by build_arch.", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Layer name (e.g., slurm_node_rhel_10_0_x86_64)." + }, + "components": { + "type": "array", + "items": {"type": "string"}, + "description": "List of group names referenced by this layer." + } + }, + "required": ["name", "components"] + }, + "minItems": 1 + }, + "groups": { + "type": "object", + "description": "Named groups of package references. Groups with type=base_os provide OS metadata.", + "additionalProperties": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Group display name." + }, + "type": { + "type": "string", + "description": "Group type: 'base_os' for OS base packages, 'group' for functional groups." + }, + "os_version": { + "type": "string", + "description": "OS version (only for base_os groups, e.g., '10.0')." + }, + "os": { + "type": "string", + "description": "OS type (only for base_os groups, e.g., 'rhel')." + }, + "description": { + "type": "string", + "description": "Group description." + }, + "components": { + "type": "array", + "items": {"type": "string"}, + "description": "List of package keys in the packages dict." + } + }, + "required": ["name", "components"] + } + }, + "packages": { + "type": "object", + "description": "Package definitions keyed by package key. Each entry has name, packagetype, and sources.", + "additionalProperties": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Actual RPM package name (e.g., 'systemd-udev')." + }, + "packagetype": { + "type": "string", + "description": "Package type (e.g., 'rpm')." + }, + "sources": { + "type": "array", + "items": { + "type": "object", + "properties": { + "architecture": { + "type": "string", + "description": "Target architecture (e.g., 'x86_64', 'aarch64')." + }, + "reponame": { + "type": "string", + "description": "Repository name (e.g., 'baseos', 'appstream')." + }, + "name": { + "type": "string", + "description": "OS name (e.g., 'rhel')." + }, + "version": { + "type": "array", + "items": {"type": "string"}, + "description": "Supported OS versions." + } + }, + "required": ["architecture"] + }, + "description": "Package source definitions with architecture filtering." + } + }, + "required": ["name", "packagetype"] + } + } + }, + "required": [ + "name", + "version", + "identifier", + "functionallayer", + "groups", + "packages" + ] + } + }, + "required": ["catalog"] +} diff --git a/src/image_build_manager/plugins/module_utils/input_validation/validators/catalog_validator.py b/src/image_build_manager/plugins/module_utils/input_validation/validators/catalog_validator.py new file mode 100644 index 0000000000..76c3fa817f --- /dev/null +++ b/src/image_build_manager/plugins/module_utils/input_validation/validators/catalog_validator.py @@ -0,0 +1,116 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +Catalog JSON validator for image_build_manager. + +Validates catalog JSON **structure** when functional_groups_source is set +to 'catalog'. Checks: +- Valid JSON with 'catalog' root key +- Required sections present (functionallayer, groups, packages) +- Layers have 'name' and 'components' fields +- Groups have 'components' field (list) + +Does NOT validate referential integrity (e.g. whether every package key +referenced by a group exists in catalog.packages). The catalog is produced +by repo_manager and may contain forward references or optional components +that are resolved at build time by parse_catalog.py. +""" +import json +import os + +from ansible.module_utils.input_validation.messages import ( # pylint: disable=E0401 + image_build_messages as msg, +) + + +def validate(catalog_file, logger=None): + """ + Run L2 validation on a catalog JSON file. + + Args: + catalog_file (str): Absolute path to catalog JSON file. + logger: Optional logger instance. + + Returns: + list: List of error message strings (empty if valid). + """ + errors = [] + + if not catalog_file or not os.path.isfile(catalog_file): + errors.append(msg.CATALOG_FILE_NOT_FOUND_MSG) + if logger: + logger.error(msg.CATALOG_FILE_NOT_FOUND_MSG) + return errors + + try: + with open(catalog_file, "r", encoding="utf-8") as fh: + raw = json.load(fh) + except (json.JSONDecodeError, OSError) as exc: + err = f"catalog: Failed to parse catalog JSON: {exc}" + errors.append(err) + if logger: + logger.error(err) + return errors + + if "catalog" not in raw: + errors.append(msg.CATALOG_MISSING_ROOT_KEY_MSG) + if logger: + logger.error(msg.CATALOG_MISSING_ROOT_KEY_MSG) + return errors + + catalog = raw["catalog"] + + # Check required sections + layers = catalog.get("functionallayer", []) + if not layers: + errors.append(msg.CATALOG_NO_FUNCTIONAL_LAYERS_MSG) + if logger: + logger.error(msg.CATALOG_NO_FUNCTIONAL_LAYERS_MSG) + + groups = catalog.get("groups") + if groups is None: + errors.append(msg.CATALOG_MISSING_GROUPS_MSG) + if logger: + logger.error(msg.CATALOG_MISSING_GROUPS_MSG) + groups = {} + + packages = catalog.get("packages") + if packages is None: + errors.append(msg.CATALOG_MISSING_PACKAGES_MSG) + if logger: + logger.error(msg.CATALOG_MISSING_PACKAGES_MSG) + packages = {} + + # Structural check: each layer should have 'name' and 'components' + for idx, layer in enumerate(layers): + if "name" not in layer: + err = f"catalog: functionallayer[{idx}] is missing 'name' field." + errors.append(err) + if logger: + logger.error(err) + if "components" not in layer or not isinstance(layer.get("components"), list): + layer_name = layer.get("name", f"") + err = f"catalog: functionallayer '{layer_name}' is missing or has non-list 'components' field." + errors.append(err) + if logger: + logger.error(err) + + # Structural check: groups should be a dict, each group should have 'components' list + if not isinstance(groups, dict): + err = "catalog: 'groups' must be a dictionary." + errors.append(err) + if logger: + logger.error(err) + + return errors diff --git a/src/image_build_manager/plugins/module_utils/input_validation/validators/image_build_config_validator.py b/src/image_build_manager/plugins/module_utils/input_validation/validators/image_build_config_validator.py index 2b2f54cb5b..05ca8df384 100644 --- a/src/image_build_manager/plugins/module_utils/input_validation/validators/image_build_config_validator.py +++ b/src/image_build_manager/plugins/module_utils/input_validation/validators/image_build_config_validator.py @@ -55,15 +55,26 @@ def _validate_aarch64_config(config_data, errors, logger=None): Rules: - If aarch64_inventory_host_ip is set, aarch64_ssh_user must also be set. + - aarch64_inventory_host_ip must not be a reserved address (loopback, + unspecified, or broadcast). - aarch64_inventory_host_ip format is already validated by L1 schema regex. """ host_ip = config_data.get("aarch64_inventory_host_ip", "") ssh_user = config_data.get("aarch64_ssh_user", "") - if host_ip and not ssh_user: - errors.append(msg.AARCH64_SSH_USER_REQUIRED_MSG) - if logger: - logger.error(msg.AARCH64_SSH_USER_REQUIRED_MSG) + if host_ip and host_ip.strip(): + # Check for reserved/unusable IPs + reserved_ips = {"127.0.0.1", "255.255.255.255"} + if host_ip.strip() in reserved_ips: + error = msg.aarch64_reserved_ip_msg(host_ip.strip()) + errors.append(error) + if logger: + logger.error(error) + + if not ssh_user: + errors.append(msg.AARCH64_SSH_USER_REQUIRED_MSG) + if logger: + logger.error(msg.AARCH64_SSH_USER_REQUIRED_MSG) def _validate_build_image_settings(config_data, errors, logger=None): diff --git a/src/image_build_manager/plugins/modules/validate_image_build_config.py b/src/image_build_manager/plugins/modules/validate_image_build_config.py index de77fe77d4..4e63c01521 100644 --- a/src/image_build_manager/plugins/modules/validate_image_build_config.py +++ b/src/image_build_manager/plugins/modules/validate_image_build_config.py @@ -45,6 +45,7 @@ from ansible.module_utils.input_validation.core.utils import create_logger from ansible.module_utils.input_validation.core.validation_engine import ( logic as validate_image_build_config, + logic_catalog as validate_catalog_logic, logic_credentials as validate_credentials_logic_new, schema as validate_against_schema, ) @@ -207,6 +208,21 @@ def run_module(): all_errors.extend(l2_errors) logger.error(f"L2 validation errors: {l2_errors}") + # Catalog validation (when functional_groups_source == 'catalog') + fg_source = config_data.get("functional_groups_source", "config") + if fg_source == "catalog": + catalog_file = os.environ.get("CATALOG_FILE_PATH", "") + if catalog_file: + catalog_errors = validate_catalog_logic(catalog_file, logger) + if catalog_errors: + all_errors.extend(catalog_errors) + logger.error(f"Catalog validation errors: {catalog_errors}") + else: + logger.info( + "Catalog mode enabled but CATALOG_FILE_PATH not set — " + "catalog validation deferred to runtime." + ) + # Cross-validate credentials against config if both exist and decrypted cred_path = os.path.join( input_project_dir, "image_build_manager/image_build_credentials.yml" diff --git a/src/image_build_manager/requirements.txt b/src/image_build_manager/requirements.txt index 1174f608bf..13f51a223d 100644 --- a/src/image_build_manager/requirements.txt +++ b/src/image_build_manager/requirements.txt @@ -17,3 +17,4 @@ jsonschema>=4.17 cryptography>=48.0.0 netaddr>=1.3.0 requests>=2.32.5 +pexpect>=4.9.0 diff --git a/src/image_build_manager/roles/build_os_images/tasks/build_base_image_aarch64.yml b/src/image_build_manager/roles/build_os_images/tasks/build_base_image_aarch64.yml index 6f0f128d54..486f203be6 100644 --- a/src/image_build_manager/roles/build_os_images/tasks/build_base_image_aarch64.yml +++ b/src/image_build_manager/roles/build_os_images/tasks/build_base_image_aarch64.yml @@ -1,4 +1,4 @@ -# Copyright 2025 Dell Inc. or its subsidiaries. All Rights Reserved. +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/src/image_build_manager/roles/build_os_images/tasks/build_base_image_x86_64.yml b/src/image_build_manager/roles/build_os_images/tasks/build_base_image_x86_64.yml index 939cadbdf0..5b1677abb8 100644 --- a/src/image_build_manager/roles/build_os_images/tasks/build_base_image_x86_64.yml +++ b/src/image_build_manager/roles/build_os_images/tasks/build_base_image_x86_64.yml @@ -1,4 +1,4 @@ -# Copyright 2025 Dell Inc. or its subsidiaries. All Rights Reserved. +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/src/image_build_manager/roles/build_os_images/tasks/build_compute_image_aarch64.yml b/src/image_build_manager/roles/build_os_images/tasks/build_compute_image_aarch64.yml index ed46190369..bbc89c1abd 100644 --- a/src/image_build_manager/roles/build_os_images/tasks/build_compute_image_aarch64.yml +++ b/src/image_build_manager/roles/build_os_images/tasks/build_compute_image_aarch64.yml @@ -250,15 +250,22 @@ ansible.builtin.debug: msg: "{{ verify_compute_osimages.stdout_lines }}" + # Registry verification uses prefix matching to handle catalog ID changes. + # When a build is cached (packages unchanged), old images remain in the + # registry with the previous catalog suffix. Exact-name matching would + # fail because compute_image_suffix reflects the *current* catalog ID. + # Prefix match: host_name/rhel-{group_key}_omnia_{version} covers any suffix. - name: Verify each aarch64 compute image exists in registry ansible.builtin.fail: msg: >- - Compute image not found in registry: - {{ host_name }}/rhel-{{ item.key }}{{ omnia_suffix }}{{ compute_image_suffix | default('') }}{{ _build_type_suffix }}. - Check log: {{ image_build_log_dir }}/{{ item.key }}{{ compute_image_suffix | default('') }}_compute_image.log + Compute image not found in registry for group {{ item.key }}. + Expected prefix: {{ host_name }}/rhel-{{ item.key }}{{ omnia_suffix }}. + Registry repos: {{ verify_compute_osimages.stdout_lines | default([]) }}. + Check logs: ls {{ image_build_log_dir }}/{{ item.key }}*_compute_image.log when: >- - (host_name + '/rhel-' + item.key + omnia_suffix + (compute_image_suffix | default('')) + _build_type_suffix) - not in verify_compute_osimages.stdout_lines + verify_compute_osimages.stdout_lines | default([]) + | select('match', '^' + host_name + '/rhel-' + item.key + omnia_suffix + '.*' + _build_type_suffix + '$') + | list | length == 0 loop: "{{ compute_images_dict | dict2items }}" loop_control: loop_var: item @@ -283,8 +290,8 @@ ansible.builtin.fail: msg: >- Compute image {{ host_name }}/rhel-{{ item.item.key }}{{ omnia_suffix }}{{ compute_image_suffix | default('') }}{{ _build_type_suffix }} - exists in registry but has no tags. Check log: - {{ image_build_log_dir }}/{{ item.item.key }}{{ compute_image_suffix | default('') }}_compute_image.log + exists in registry but has no tags. + Check logs: ls {{ image_build_log_dir }}/{{ item.item.key }}*_compute_image.log when: item.stdout_lines | default([]) | length == 0 loop: "{{ _compute_tag_check.results | default([]) }}" loop_control: @@ -314,7 +321,7 @@ msg: >- Compute image for group {{ item.item.key }} not found in s3://{{ s3_configurations.bucket | default('boot-images') }}/{{ item.item.key }}/. - Check log: {{ image_build_log_dir }}/{{ item.item.key }}{{ compute_image_suffix | default('') }}_compute_image.log + Check logs: ls {{ image_build_log_dir }}/{{ item.item.key }}*_compute_image.log when: (item.stdout | default('0') | trim | int) == 0 loop: "{{ _compute_s3_check.results | default([]) }}" loop_control: diff --git a/src/image_build_manager/roles/build_os_images/tasks/build_compute_image_x86_64.yml b/src/image_build_manager/roles/build_os_images/tasks/build_compute_image_x86_64.yml index 9c6642cc4c..189d6e1a66 100644 --- a/src/image_build_manager/roles/build_os_images/tasks/build_compute_image_x86_64.yml +++ b/src/image_build_manager/roles/build_os_images/tasks/build_compute_image_x86_64.yml @@ -247,15 +247,22 @@ ansible.builtin.debug: msg: "{{ verify_compute_osimages.stdout_lines }}" + # Registry verification uses prefix matching to handle catalog ID changes. + # When a build is cached (packages unchanged), old images remain in the + # registry with the previous catalog suffix. Exact-name matching would + # fail because compute_image_suffix reflects the *current* catalog ID. + # Prefix match: host_name/rhel-{group_key}_omnia_{version} covers any suffix. - name: Verify each x86_64 compute image exists in registry ansible.builtin.fail: msg: >- - Compute image not found in registry: - {{ host_name }}/rhel-{{ item.key }}{{ omnia_suffix }}{{ compute_image_suffix | default('') }}{{ _build_type_suffix }}. - Check log: {{ image_build_log_dir }}/{{ item.key }}{{ compute_image_suffix | default('') }}_compute_image.log + Compute image not found in registry for group {{ item.key }}. + Expected prefix: {{ host_name }}/rhel-{{ item.key }}{{ omnia_suffix }}. + Registry repos: {{ verify_compute_osimages.stdout_lines | default([]) }}. + Check logs: ls {{ image_build_log_dir }}/{{ item.key }}*_compute_image.log when: >- - (host_name + '/rhel-' + item.key + omnia_suffix + (compute_image_suffix | default('')) + _build_type_suffix) - not in verify_compute_osimages.stdout_lines + verify_compute_osimages.stdout_lines | default([]) + | select('match', '^' + host_name + '/rhel-' + item.key + omnia_suffix + '.*' + _build_type_suffix + '$') + | list | length == 0 loop: "{{ compute_images_dict | dict2items }}" loop_control: loop_var: item @@ -278,8 +285,8 @@ ansible.builtin.fail: msg: >- Compute image {{ host_name }}/rhel-{{ item.item.key }}{{ omnia_suffix }}{{ compute_image_suffix | default('') }}{{ _build_type_suffix }} - exists in registry but has no tags. Check log: - {{ image_build_log_dir }}/{{ item.item.key }}{{ compute_image_suffix | default('') }}_compute_image.log + exists in registry but has no tags. + Check logs: ls {{ image_build_log_dir }}/{{ item.item.key }}*_compute_image.log when: item.stdout_lines | default([]) | length == 0 loop: "{{ _compute_tag_check.results | default([]) }}" loop_control: @@ -307,7 +314,7 @@ msg: >- Compute image for group {{ item.item.key }} not found in s3://{{ s3_configurations.bucket | default('boot-images') }}/{{ item.item.key }}/. - Check log: {{ image_build_log_dir }}/{{ item.item.key }}{{ compute_image_suffix | default('') }}_compute_image.log + Check logs: ls {{ image_build_log_dir }}/{{ item.item.key }}*_compute_image.log when: (item.stdout | default('0') | trim | int) == 0 loop: "{{ _compute_s3_check.results | default([]) }}" loop_control: diff --git a/src/image_build_manager/roles/build_os_images/tasks/main.yml b/src/image_build_manager/roles/build_os_images/tasks/main.yml index cc2312924c..21e28a9cb4 100644 --- a/src/image_build_manager/roles/build_os_images/tasks/main.yml +++ b/src/image_build_manager/roles/build_os_images/tasks/main.yml @@ -1,4 +1,4 @@ -# Copyright 2025 Dell Inc. or its subsidiaries. All Rights Reserved. +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -54,22 +54,48 @@ # The build_status manifest must reference the actual S3 object keys so # downstream consumers (BSS, provision) can fetch them by exact path. # --------------------------------------------------------------------------- +# S3 artifact discovery uses a two-stage lookup: +# 1. Try the current image name (with current catalog suffix) +# 2. If empty, fall back to prefix match (finds images from previous catalog IDs) +# This handles catalog ID changes where builds were cached and S3 still has old names. - name: Discover actual S3 artifact filenames per functional group ansible.builtin.shell: | set -o pipefail _s3_bucket="{{ s3_configurations.bucket | default('boot-images') }}" _image_name="rhel-{{ item.key }}{{ omnia_suffix }}{{ compute_image_suffix | default('') }}{{ _build_type_suffix }}" + _image_prefix="rhel-{{ item.key }}{{ omnia_suffix }}" - # EFI artifacts (kernel + initrd) + # EFI artifacts (kernel + initrd) — try exact name first, then prefix _efi_prefix="s3://${_s3_bucket}/efi-images/{{ item.key }}/${_image_name}/" kernel=$(s3cmd ls "${_efi_prefix}" 2>/dev/null | awk '{print $NF}' | xargs -I{} basename {} | grep -E '^vmlinuz' | head -1) initrd=$(s3cmd ls "${_efi_prefix}" 2>/dev/null | awk '{print $NF}' | xargs -I{} basename {} | grep -E '^initramfs' | head -1) - # Rootfs image + # Fallback: prefix match for EFI (handles catalog ID change) + if [ -z "$kernel" ] || [ -z "$initrd" ]; then + _efi_fallback="s3://${_s3_bucket}/efi-images/{{ item.key }}/" + _found_dir=$(s3cmd ls "${_efi_fallback}" 2>/dev/null | awk '{print $NF}' | grep "${_image_prefix}" | head -1) + if [ -n "$_found_dir" ]; then + [ -z "$kernel" ] && kernel=$(s3cmd ls "${_found_dir}" 2>/dev/null | awk '{print $NF}' | xargs -I{} basename {} | grep -E '^vmlinuz' | head -1) + [ -z "$initrd" ] && initrd=$(s3cmd ls "${_found_dir}" 2>/dev/null | awk '{print $NF}' | xargs -I{} basename {} | grep -E '^initramfs' | head -1) + # Update _image_name to the actual directory name for rootfs lookup + _image_name=$(basename "$_found_dir") + fi + fi + + # Rootfs image — try exact name first, then prefix _img_prefix="s3://${_s3_bucket}/{{ item.key }}/${_image_name}/" rootfs=$(s3cmd ls "${_img_prefix}" 2>/dev/null | awk '{print $NF}' | xargs -I{} basename {} | grep -vE '^$' | head -1) - echo "{{ item.key }}|${kernel:-}|${initrd:-}|${rootfs:-}" + if [ -z "$rootfs" ]; then + _img_fallback="s3://${_s3_bucket}/{{ item.key }}/" + _found_dir=$(s3cmd ls "${_img_fallback}" 2>/dev/null | awk '{print $NF}' | grep "${_image_prefix}" | head -1) + if [ -n "$_found_dir" ]; then + rootfs=$(s3cmd ls "${_found_dir}" 2>/dev/null | awk '{print $NF}' | xargs -I{} basename {} | grep -vE '^$' | head -1) + _image_name=$(basename "$_found_dir") + fi + fi + + echo "{{ item.key }}|${kernel:-}|${initrd:-}|${rootfs:-}|${_image_name}" args: executable: /bin/bash register: _s3_artifact_discovery @@ -80,29 +106,40 @@ loop_var: item label: "{{ item.key }}" +# Parse S3 discovery output: group|kernel|initrd|rootfs|actual_image_name +# The 5th field is the resolved image name (may differ from current suffix +# when catalog ID changed and builds were cached). - name: Build artifact filename lookup from S3 discovery ansible.builtin.set_fact: _s3_artifacts: >- {%- set result = {} -%} {%- for r in _s3_artifact_discovery.results -%} {%- set parts = r.stdout.split('|') -%} - {%- if parts | length == 4 -%} - {%- set _ = result.update({parts[0]: {'kernel': parts[1], 'initrd': parts[2], 'rootfs': parts[3]}}) -%} + {%- if parts | length >= 4 -%} + {%- set _ = result.update({parts[0]: { + 'kernel': parts[1], + 'initrd': parts[2], + 'rootfs': parts[3], + 'image_name': parts[4] | default(parts[0], true) + }}) -%} {%- endif -%} {%- endfor -%} {{ result }} - name: Build image list entries (using actual S3 filenames) vars: - _image_name: "rhel-{{ item.key }}{{ omnia_suffix }}{{ compute_image_suffix | default('') }}{{ _build_type_suffix }}" + _resolved_name: >- + {{ _s3_artifacts[item.key].image_name + | default('rhel-' + item.key + omnia_suffix + + (compute_image_suffix | default('')) + _build_type_suffix) }} _kernel_file: "{{ _s3_artifacts[item.key].kernel | default('') }}" _initrd_file: "{{ _s3_artifacts[item.key].initrd | default('') }}" _rootfs_file: "{{ _s3_artifacts[item.key].rootfs | default('') }}" _current_image: functional_group: "{{ item.key }}" - kernel: "boot-images/efi-images/{{ item.key }}/{{ _image_name }}/{{ _kernel_file }}" - initrd: "boot-images/efi-images/{{ item.key }}/{{ _image_name }}/{{ _initrd_file }}" - image: "boot-images/{{ item.key }}/{{ _image_name }}{{ ('/' + _rootfs_file) if (_rootfs_file | length > 0) else '' }}" + kernel: "boot-images/efi-images/{{ item.key }}/{{ _resolved_name }}/{{ _kernel_file }}" + initrd: "boot-images/efi-images/{{ item.key }}/{{ _resolved_name }}/{{ _initrd_file }}" + image: "boot-images/{{ item.key }}/{{ _resolved_name }}{{ ('/' + _rootfs_file) if (_rootfs_file | length > 0) else '' }}" ansible.builtin.set_fact: _new_images: "{{ (_new_images | default([])) + [_current_image] }}" loop: "{{ compute_images_dict | dict2items }}" diff --git a/src/image_build_manager/roles/fetch_build_packages/tasks/build_image_completion.yml b/src/image_build_manager/roles/fetch_build_packages/tasks/build_image_completion.yml index 73751b6a71..2e847e7bad 100644 --- a/src/image_build_manager/roles/fetch_build_packages/tasks/build_image_completion.yml +++ b/src/image_build_manager/roles/fetch_build_packages/tasks/build_image_completion.yml @@ -1,4 +1,4 @@ -# Copyright 2025 Dell Inc. or its subsidiaries. All Rights Reserved. +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/src/image_build_manager/roles/fetch_build_packages/tasks/check_functional_group.yml b/src/image_build_manager/roles/fetch_build_packages/tasks/check_functional_group.yml index 82584a36d6..77e5336d1c 100644 --- a/src/image_build_manager/roles/fetch_build_packages/tasks/check_functional_group.yml +++ b/src/image_build_manager/roles/fetch_build_packages/tasks/check_functional_group.yml @@ -1,4 +1,4 @@ -# Copyright 2025 Dell Inc. or its subsidiaries. All Rights Reserved. +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/src/image_build_manager/roles/image_build_setup/vars/main.yml b/src/image_build_manager/roles/image_build_setup/vars/main.yml index 4cb28a8953..8da313c4f9 100644 --- a/src/image_build_manager/roles/image_build_setup/vars/main.yml +++ b/src/image_build_manager/roles/image_build_setup/vars/main.yml @@ -31,6 +31,7 @@ file_permissions_400: "0400" supported_tags: - precheck - validate + - credentials - prepare - execute - build @@ -75,6 +76,14 @@ invalid_tag_combinations: - [precheck, cleanup_images] - [precheck, upgrade] - [precheck, rollback] + - [credentials, prepare] + - [credentials, build] + - [credentials, execute] + - [credentials, cleanup] + - [credentials, cleanup_images] + - [credentials, upgrade] + - [credentials, rollback] + - [credentials, precheck] # --- Tag validation messages --- tag_validation_fail_msg: | Invalid tag usage detected. diff --git a/src/image_build_manager/roles/prepare_aarch64_node/README.md b/src/image_build_manager/roles/prepare_aarch64_node/README.md index a65e1b0ce4..deff9d11f2 100644 --- a/src/image_build_manager/roles/prepare_aarch64_node/README.md +++ b/src/image_build_manager/roles/prepare_aarch64_node/README.md @@ -2,25 +2,79 @@ Prepares ARM64 (aarch64) remote build hosts for cross-architecture image building via SSH. +## Architecture + +This role runs on the OIM (localhost) and delegates tasks to the aarch64 build host. +No NFS mount is required — all work directories are created locally on the aarch64 node +at `/opt/omnia/image_build_manager`. + +### Prerequisites (handled before this role) + +- **Validation** — `validate_aarch64_host.yml` (in `validate_build_runtime`) runs on + localhost: checks IP is configured, pings the host, creates `admin_aarch64` inventory group. + Fails early if host is unreachable. +- **SSH setup** — `setup_ssh.yml` (in this role) runs on localhost: generates SSH keypair + if missing, adds host to known_hosts, runs `ssh-copy-id` with credential password, + verifies passwordless SSH works. Called from a localhost play in the playbook. + +### Task files + +| File | Runs on | Purpose | +|------|---------|---------| +| `setup_ssh.yml` | localhost | SSH keygen + known_hosts + ssh-copy-id + verify | +| `gather_oim_data.yml` | localhost | Inventory checks + OIM network facts | +| `main.yml` | admin_aarch64 | Node preparation (arch check, dirs, images, regctl, registry) | + +### Phases (main.yml) + +1. **Architecture validation** — Verifies the remote host is actually aarch64. +2. **OIM hostname resolution** — Adds OIM PXE IP + hostname to `/etc/hosts` on the aarch64 node + so repo manager (Pulp) is reachable by name. +3. **Local work directories** — Creates `/opt/omnia/image_build_manager/` tree on the aarch64 node + (replaces the former NFS mount requirement). +4. **Repo configuration** — Generates `repo_manager.repo` from `rpm_repos_aarch64` dict + and copies the Pulp CA certificate (if configured). +5. **Builder image pull** — Pulls the builder container image using a two-tier strategy: + - Try repo manager (Pulp) first: `:/` + - Fall back to upstream registry (DockerHub/GHCR) if Pulp fails +6. **regctl installation** — Installs the `regctl` binary on the aarch64 node using a two-tier strategy: + - Try copying from OIM localhost (`/usr/local/bin/regctl`) + - Fall back to downloading from GitHub releases if copy fails +7. **Registry configuration** — Configures regctl to use HTTP for the local OCI registry. + ## Requirements -- SSH access to the aarch64 build host +- SSH access to the aarch64 build host (passwordless or password-based) - Podman installed on the remote host -- Network connectivity between OIM and build host +- Network connectivity: OIM must reach the aarch64 node (admin NIC or routable path) +- Either: repo manager (Pulp) accessible from aarch64 node, OR internet access for DockerHub fallback ## Role Variables -See `vars/main.yml` for the full list. Key variable: `aarch64_inventory_host_ip` in `image_build_config.yml`. +See `vars/main.yml` for the full list. Key variables in `image_build_config.yml`: + +| Variable | Required | Description | +|----------|----------|-------------| +| `aarch64_inventory_host_ip` | Yes | IPv4 address of the ARM build host | +| `aarch64_ssh_user` | Yes | SSH user (default: `root`) | + +Key variables in `image_build_credentials.yml`: + +| Variable | Required | Description | +|----------|----------|-------------| +| `aarch64_ssh_password` | Yes* | SSH password for initial key setup (*not needed if passwordless SSH is pre-configured) | ## Dependencies - `image_build_setup` — environment and config loading - `collect_build_credentials` — aarch64 SSH credentials +- `validate_build_runtime` — aarch64 host validation and dynamic inventory group creation ## Example ```yaml -- hosts: localhost +- hosts: admin_aarch64 + gather_facts: false roles: - prepare_aarch64_node ``` diff --git a/src/image_build_manager/roles/prepare_aarch64_node/tasks/gather_oim_data.yml b/src/image_build_manager/roles/prepare_aarch64_node/tasks/gather_oim_data.yml index b45c3f71de..2da993a278 100644 --- a/src/image_build_manager/roles/prepare_aarch64_node/tasks/gather_oim_data.yml +++ b/src/image_build_manager/roles/prepare_aarch64_node/tasks/gather_oim_data.yml @@ -1,4 +1,4 @@ -# Copyright 2025 Dell Inc. or its subsidiaries. All Rights Reserved. +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -12,6 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. --- +# gather_oim_data.yml — Validate admin_aarch64 inventory and set OIM +# network facts for the aarch64 build flow. Runs on localhost. # Inventory Validation - name: Fail if no inventory provided @@ -29,16 +31,6 @@ msg: "{{ admin_aarch64_count_error_msg }}" when: groups['admin_aarch64'] | length != 1 -# # Validate share option -# - name: Set share option fact -# ansible.builtin.set_fact: -# omnia_share_option: "{{ hostvars['localhost']['omnia_share_option'] }}" - -# - name: Fail if share option is not NFS -# ansible.builtin.fail: -# msg: "{{ nfs_not_configured_msg }}" -# when: omnia_share_option != "NFS" - # Set OIM PXE IP and hostname from localhost facts - name: Set OIM network facts ansible.builtin.set_fact: @@ -49,8 +41,8 @@ else hostvars['localhost']['host_name'] + '.' + hostvars['localhost']['domain_name'] }} cacheable: true -- name: Create aarch64 directory if not exists +- name: Ensure aarch64 openchami directory exists on localhost ansible.builtin.file: path: "{{ ochami_aarch_64_dir }}" state: directory - mode: "{{ hostvars['localhost']['dir_permissions_755'] }}" + mode: "0755" diff --git a/src/image_build_manager/roles/prepare_aarch64_node/tasks/main.yml b/src/image_build_manager/roles/prepare_aarch64_node/tasks/main.yml index f07249ea49..0c7795c515 100644 --- a/src/image_build_manager/roles/prepare_aarch64_node/tasks/main.yml +++ b/src/image_build_manager/roles/prepare_aarch64_node/tasks/main.yml @@ -1,4 +1,4 @@ -# Copyright 2025 Dell Inc. or its subsidiaries. All Rights Reserved. +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -12,51 +12,33 @@ # See the License for the specific language governing permissions and # limitations under the License. --- +# prepare_aarch64_node/tasks/main.yml — Prepare a remote aarch64 build host. +# No NFS dependency — uses local work directories on the aarch64 node. +# Image pull: tries repo manager (Pulp) first, falls back to DockerHub/upstream. +# regctl: copies from OIM localhost first, downloads from GitHub if copy fails. +# +# Prerequisites (completed before this role runs): +# - validate_aarch64_host.yml: ping check, admin_aarch64 group creation +# - setup_ssh.yml: SSH keygen, known_hosts, ssh-copy-id, verify -- name: Add target host to known_hosts - ansible.builtin.known_hosts: - name: "{{ inventory_hostname }}" - key: "{{ lookup('pipe', 'ssh-keyscan -H ' + inventory_hostname) }}" - delegate_to: localhost - -- name: Check if passwordless SSH is enabled - ansible.builtin.command: - cmd: ssh -o BatchMode=yes -o ConnectTimeout=5 root@{{ inventory_hostname }} 'echo OK' - register: ssh_check - ignore_errors: true - changed_when: false - delegate_to: localhost - -# Set up passwordless SSH from localhost if not already enabled -- name: Setup passwordless SSH from localhost - ansible.builtin.expect: - command: "ssh-copy-id -i /root/.ssh/id_rsa.pub root@{{ inventory_hostname }}" - responses: - "password:": "{{ hostvars['localhost']['aarch64_ssh_password'] }}" - when: ssh_check.failed - delegate_to: localhost - no_log: true - -- name: Verify passwordless SSH - ansible.builtin.command: - cmd: ssh -o BatchMode=yes root@{{ inventory_hostname }} 'echo OK' - register: ssh_verify - failed_when: ssh_verify.stdout != "OK" - changed_when: false - delegate_to: localhost +# ========================================================================= +# Phase 1: Architecture validation +# ========================================================================= -# Check the machine architecture of the target host - name: Check machine architecture ansible.builtin.command: uname -m register: arch_result changed_when: false -# Fail the play if the target machine is not aarch64 - name: Fail if machine is not aarch64 ansible.builtin.fail: msg: "{{ not_aarch64_error_msg }}" when: arch_result.stdout != "aarch64" +# ========================================================================= +# Phase 2: OIM hostname resolution on aarch64 node +# ========================================================================= + - name: Remove any existing entries for OIM hostname in /etc/hosts ansible.builtin.lineinfile: path: /etc/hosts @@ -69,10 +51,9 @@ path: /etc/hosts line: "{{ hostvars['localhost']['oim_pxe_ip'] }} {{ hostvars['localhost']['oim_hostname'] }}" state: present - mode: "{{ hostvars['localhost']['file_permissions_644'] }}" + mode: "{{ file_permissions_644 }}" create: true -# Verify the entry exists in /etc/hosts - name: Verify OIM PXE IP and hostname in /etc/hosts ansible.builtin.command: cmd: "grep {{ hostvars['localhost']['oim_pxe_ip'] }} /etc/hosts" @@ -88,31 +69,39 @@ ansible.builtin.raw: "ping -c 2 {{ hostvars['localhost']['oim_hostname'] }}" register: ping_result changed_when: false - failed_when: ping_result.rc != 0 + failed_when: false -- name: Show ping result +- name: Warn if OIM is not reachable from aarch64 node ansible.builtin.debug: - msg: "{{ ping_result.stdout }}" + msg: > + WARNING: Cannot ping OIM ({{ hostvars['localhost']['oim_hostname'] }}) from aarch64 node. + Image pull from repo manager will likely fail. DockerHub fallback will be attempted. + when: ping_result.rc | default(1) != 0 -# Register NFS details -- name: Set NFS info fact - ansible.builtin.set_fact: - nfs_info: - server_ip: "{{ hostvars['localhost']['nfs_server_ip'] }}" - server_share_path: "{{ hostvars['localhost']['nfs_server_share_path'] }}" - shared_path: "{{ hostvars['localhost']['shared_path'] }}" +# ========================================================================= +# Phase 3: Create local work directories (replaces NFS mount) +# ========================================================================= -- name: Ensure NFS mount point directory exists +- name: Ensure aarch64 work directories exist ansible.builtin.file: - path: "{{ nfs_info.shared_path }}" + path: "{{ item }}" state: directory - mode: "{{ hostvars['localhost']['dir_permissions_755'] }}" + mode: "{{ dir_permissions_755 }}" become: true + loop: + - "{{ aarch64_work_dir }}" + - "{{ ochami_aarch_64_dir }}" + - "{{ aarch64_work_dir }}/workdir" + - "{{ aarch64_work_dir }}/log" + +# ========================================================================= +# Phase 4: Repo manager configuration (optional — for Pulp-based pulls) +# ========================================================================= - name: Generate repo_manager.repo from rpm_repos.aarch64 ansible.builtin.copy: dest: "{{ repo_file_path }}" - mode: "{{ hostvars['localhost']['file_permissions_644'] }}" + mode: "{{ file_permissions_644 }}" content: | {% for repo_name, repo_url in (hostvars['localhost']['rpm_repos_aarch64'] | default({})).items() %} [{{ repo_name }}] @@ -122,12 +111,13 @@ gpgcheck=0 sslverify=1 {% endfor %} + when: (hostvars['localhost']['rpm_repos_aarch64'] | default({})) | length > 0 - name: Copy repo manager certificate to target host ansible.builtin.copy: src: "{{ repo_cert_path }}" dest: "{{ anchors_path }}" - mode: "{{ hostvars['localhost']['file_permissions_644'] }}" + mode: "{{ file_permissions_644 }}" become: true when: repo_cert_path | default('') | length > 0 @@ -137,51 +127,24 @@ changed_when: false when: repo_cert_path | default('') | length > 0 -- name: Check if NFS is mounted - ansible.builtin.command: - cmd: "mountpoint -q {{ nfs_info.shared_path }}" - register: nfs_mounted - ignore_errors: true - changed_when: false - -# Install NFS client package -- name: Install NFS client package - ansible.builtin.dnf: - name: nfs-utils - state: present - when: nfs_mounted.rc != 0 - become: true - -# Mount NFS share if not mounted -- name: Mount NFS share - ansible.builtin.mount: - path: "{{ nfs_info.shared_path }}" - src: "{{ nfs_info.server_ip }}:{{ nfs_info.server_share_path }}" - fstype: nfs - opts: defaults - state: mounted - when: nfs_mounted.rc != 0 - become: true +# ========================================================================= +# Phase 5: Pull builder container image (Pulp first, DockerHub fallback) +# ========================================================================= -# Verify the mount -- name: Verify NFS mount - ansible.builtin.command: - cmd: "mountpoint -q {{ nfs_info.shared_path }}" - register: verify_nfs - failed_when: verify_nfs.rc != 0 - changed_when: false - -- name: Display NFS mount status - ansible.builtin.debug: - msg: "NFS share {{ nfs_info.server_ip }}:{{ nfs_info.server_share_path }} is mounted on {{ nfs_info.shared_path }}" - -- name: Build full Podman image path +- name: Build repo manager image path for aarch64 ansible.builtin.set_fact: - builder_image_path: "{{ hostvars['localhost']['oim_pxe_ip'] }}:{{ hostvars['localhost']['repo_port'] | default(2225) }}/{{ repo_builder_image }}" + builder_image_path: >- + {{ hostvars['localhost']['oim_pxe_ip'] }}:{{ hostvars['localhost']['repo_port'] | default(2225) }}/{{ repo_builder_image }} + +- name: Check if builder image already exists locally + containers.podman.podman_image_info: + name: "{{ local_tag }}" + register: local_image_info -- name: Pull and tag aarch64 image +- name: Pull and tag aarch64 builder image + when: local_image_info.images | length == 0 block: - - name: Pull aarch64 image using Podman + - name: Pull aarch64 image from repo manager registry (Pulp) containers.podman.podman_image: name: "{{ builder_image_path }}" state: present @@ -191,7 +154,7 @@ until: podman_pull_result is not failed changed_when: false - - name: Tag pulled image + - name: Tag pulled image for aarch64 build containers.podman.podman_tag: image: "{{ builder_image_path }}" target_names: @@ -199,34 +162,112 @@ changed_when: false rescue: - - name: Fail if Podman pull failed - ansible.builtin.fail: - msg: "Failed to pull image {{ builder_image_path }}" + - name: Attempt direct pull from upstream registry (internet fallback) + block: + - name: Display fallback notice + ansible.builtin.debug: + msg: "{{ pull_image_fallback_msg.format(repo_path=builder_image_path, upstream_image=repo_builder_image) }}" + + - name: Pull image directly from upstream registry + containers.podman.podman_image: + name: "{{ repo_builder_image }}" + state: present + register: direct_pull_result + retries: "{{ pull_image_retries }}" + delay: "{{ pull_image_delay }}" + until: direct_pull_result is not failed + changed_when: false + + - name: Tag directly pulled image for aarch64 build + containers.podman.podman_tag: + image: "{{ repo_builder_image }}" + target_names: + - "{{ local_tag }}" + changed_when: false + + - name: Display direct pull success + ansible.builtin.debug: + msg: "{{ pull_image_success_msg.format(upstream_image=repo_builder_image) }}" + verbosity: 1 -- name: Check if regctl binary exists + rescue: + - name: Fail if both pull methods failed + ansible.builtin.fail: + msg: "{{ aarch64_image_fail_msg }}" + +# ========================================================================= +# Phase 6: Install regctl (SCP from localhost first, download fallback) +# ========================================================================= + +- name: Check if regctl already exists on aarch64 node ansible.builtin.stat: - path: "{{ ochami_aarch_64_dir }}/regctl" - register: regctl_stat - delegate_to: localhost + path: "{{ regctl_bin_path }}" + register: _regctl_remote_stat -- name: Fail if regctl binary not found - ansible.builtin.fail: - msg: "{{ regctl_not_found_msg }}" - when: not regctl_stat.stat.exists +- name: Install regctl on aarch64 node + when: not _regctl_remote_stat.stat.exists + block: + # --- Try 1: Copy from OIM localhost --- + - name: Check if regctl exists on OIM localhost + ansible.builtin.stat: + path: /usr/local/bin/regctl + register: _regctl_localhost_stat + delegate_to: localhost -- name: Copy regctl binary to /usr/local/bin on target host - ansible.builtin.copy: - src: "{{ ochami_aarch_64_dir }}/regctl" - dest: "{{ regctl_bin_path }}" - mode: "{{ hostvars['localhost']['dir_permissions_755'] }}" - become: true + - name: Copy regctl from OIM localhost to aarch64 node + ansible.builtin.copy: + src: /usr/local/bin/regctl + dest: "{{ regctl_bin_path }}" + mode: "{{ dir_permissions_755 }}" + become: true + when: _regctl_localhost_stat.stat.exists + register: _regctl_copy_result + failed_when: false + + # --- Try 2: Download from GitHub releases --- + - name: Download regctl from GitHub (fallback) + when: >- + (not _regctl_localhost_stat.stat.exists) or + (_regctl_copy_result is defined and _regctl_copy_result is failed) + block: + - name: Display regctl download notice + ansible.builtin.debug: + msg: "regctl not available from OIM localhost. Downloading {{ regctl_download_url }}" + + - name: Download regctl binary for aarch64 + ansible.builtin.get_url: + url: "{{ regctl_download_url }}" + dest: "{{ regctl_bin_path }}" + mode: "{{ dir_permissions_755 }}" + timeout: 60 + become: true + register: _regctl_download_result + + rescue: + - name: Fail if regctl cannot be obtained + ansible.builtin.fail: + msg: "{{ regctl_not_found_msg }}" + +- name: Verify regctl binary is functional + ansible.builtin.command: "{{ regctl_bin_path }} version" + register: _regctl_version + changed_when: false + failed_when: _regctl_version.rc != 0 + +- name: Display regctl version + ansible.builtin.debug: + msg: "regctl version: {{ _regctl_version.stdout | trim }}" + +# ========================================================================= +# Phase 7: Configure registry access on aarch64 node +# ========================================================================= - name: Compute aarch64 registry host ansible.builtin.set_fact: _aarch64_registry_host: >- {{ hostvars['localhost']['admin_nic_ip'] if hostvars['localhost']['standalone_mode'] | default(false) | bool - else oim_hostname }} + else hostvars['localhost']['oim_hostname'] }} - name: Configure regctl to use HTTP for local registry ansible.builtin.command: "{{ regctl_bin_path }} registry set --tls disabled {{ _aarch64_registry_host }}:5000" diff --git a/src/image_build_manager/roles/prepare_aarch64_node/tasks/setup_ssh.yml b/src/image_build_manager/roles/prepare_aarch64_node/tasks/setup_ssh.yml new file mode 100644 index 0000000000..b047ea553a --- /dev/null +++ b/src/image_build_manager/roles/prepare_aarch64_node/tasks/setup_ssh.yml @@ -0,0 +1,111 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +--- +# setup_ssh.yml — Establish passwordless SSH from OIM (localhost) to aarch64 node. +# Called from a localhost play in build_image_aarch64.yml BEFORE the +# hosts: admin_aarch64 play, so that Ansible can connect to the remote node. +# +# Flow: +# 1. Generate SSH keypair if missing +# 2. Add aarch64 host to known_hosts +# 3. Check if passwordless SSH already works +# 4. If not, run ssh-copy-id with the credential password +# 5. Verify passwordless SSH works + +# ========================================================================= +# Phase 1: Ensure SSH keypair exists on OIM +# ========================================================================= + +- name: Check if SSH keypair exists + ansible.builtin.stat: + path: /root/.ssh/id_rsa + register: _ssh_key_stat + +- name: Generate SSH keypair + when: not _ssh_key_stat.stat.exists + ansible.builtin.command: + cmd: ssh-keygen -t rsa -b 4096 -f /root/.ssh/id_rsa -N "" + creates: /root/.ssh/id_rsa + register: _ssh_keygen + changed_when: _ssh_keygen.rc == 0 + +# ========================================================================= +# Phase 2: Add aarch64 host to known_hosts +# ========================================================================= + +- name: Add aarch64 host to known_hosts + ansible.builtin.known_hosts: + name: "{{ aarch64_inventory_host_ip }}" + key: "{{ lookup('pipe', 'ssh-keyscan -H ' + aarch64_inventory_host_ip) }}" + +# ========================================================================= +# Phase 3: Check if passwordless SSH already works +# ========================================================================= + +- name: Check if passwordless SSH is already enabled + ansible.builtin.command: >- + ssh -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=no + {{ aarch64_ssh_user | default('root') }}@{{ aarch64_inventory_host_ip }} 'echo OK' + register: _ssh_check + changed_when: false + failed_when: false + +- name: Set SSH passwordless status + ansible.builtin.set_fact: + aarch64_ssh_passwordless: "{{ (_ssh_check.rc | default(1) == 0) | bool }}" + cacheable: true + +# ========================================================================= +# Phase 4: Setup passwordless SSH via ssh-copy-id (if needed) +# ========================================================================= + +- name: Setup passwordless SSH via ssh-copy-id + when: not (aarch64_ssh_passwordless | bool) + ansible.builtin.expect: + command: >- + ssh-copy-id -o StrictHostKeyChecking=no + -i /root/.ssh/id_rsa.pub + {{ aarch64_ssh_user | default('root') }}@{{ aarch64_inventory_host_ip }} + responses: + "(?i)password:": "{{ aarch64_ssh_password | default('') }}" + no_log: true + register: _ssh_copy_id_result + failed_when: false + +# ========================================================================= +# Phase 5: Verify passwordless SSH works +# ========================================================================= + +- name: Verify passwordless SSH is working + when: not (aarch64_ssh_passwordless | bool) + ansible.builtin.command: >- + ssh -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=no + {{ aarch64_ssh_user | default('root') }}@{{ aarch64_inventory_host_ip }} 'echo OK' + register: _ssh_verify + changed_when: false + failed_when: false + +- name: Fail if SSH setup failed + when: + - not (aarch64_ssh_passwordless | bool) + - _ssh_verify.rc | default(1) != 0 + ansible.builtin.fail: + msg: "{{ ssh_setup_fail_msg }}" + +- name: Display SSH status + ansible.builtin.debug: + msg: >- + SSH to {{ aarch64_inventory_host_ip }}: + {{ 'already configured (passwordless)' if (aarch64_ssh_passwordless | bool) + else 'configured via ssh-copy-id' }} diff --git a/src/image_build_manager/roles/prepare_aarch64_node/vars/main.yml b/src/image_build_manager/roles/prepare_aarch64_node/vars/main.yml index 62d2995480..423fafb8e6 100644 --- a/src/image_build_manager/roles/prepare_aarch64_node/vars/main.yml +++ b/src/image_build_manager/roles/prepare_aarch64_node/vars/main.yml @@ -16,35 +16,73 @@ # input files input_project_dir: "{{ hostvars['localhost']['input_project_dir'] }}" +# --------------------------------------------------------------------------- +# Local work directory on the aarch64 node (no NFS mount required) +# --------------------------------------------------------------------------- +aarch64_work_dir: "/opt/omnia/image_build_manager" +ochami_aarch_64_dir: "{{ aarch64_work_dir }}/openchami/aarch64" + +# --------------------------------------------------------------------------- # Build type switching for aarch64 +# --------------------------------------------------------------------------- _image_build_type: "{{ hostvars['localhost']['image_build_type'] | default('image-builder') }}" _is_thrillhouse: "{{ _image_build_type == 'image-thrillhouse' }}" -_builder_aarch64_image: "dellhpcomniaaisolution/image-build-aarch64:1.1" +_builder_aarch64_image: "docker.io/dellhpcomniaaisolution/image-build-aarch64:1.2" _thrillhouse_aarch64_image: "ghcr.io/openchami/image-thrillhouse:latest" repo_builder_image: "{{ (_is_thrillhouse | bool) | ternary(_thrillhouse_aarch64_image, _builder_aarch64_image) }}" local_tag: "{{ (_is_thrillhouse | bool) | ternary('aarch64-image-thrillhouse/ochami', 'aarch64-image-builder/ochami') }}" pull_image_retries: "5" pull_image_delay: "10" -ochami_aarch_64_dir: "{{ hostvars['localhost']['shared_path'] }}/omnia/openchami/aarch64" + +# --------------------------------------------------------------------------- +# File paths on aarch64 node +# --------------------------------------------------------------------------- repo_file_path: "/etc/yum.repos.d/repo_manager.repo" repo_cert_path: "{{ hostvars['localhost']['repo_cert_path'] | default('') }}" anchors_path: "/etc/pki/ca-trust/source/anchors/pulp_webserver.crt" regctl_bin_path: "/usr/local/bin/regctl" -# Error messages +# regctl download URL for aarch64 (fallback when SCP from localhost fails) +regctl_version: "v0.7.1" +regctl_download_url: "https://github.com/regclient/regclient/releases/download/{{ regctl_version }}/regctl-linux-arm64" + +# Permissions constants +dir_permissions_755: "0755" +file_permissions_644: "0644" + +# --------------------------------------------------------------------------- +# Error / status messages +# --------------------------------------------------------------------------- no_inventory_error_msg: "No inventory provided. Please specify an inventory with -i option." admin_aarch64_empty_error_msg: "The inventory group 'admin_aarch64' does not exist or has no hosts." admin_aarch64_count_error_msg: "The inventory group 'admin_aarch64' must have exactly one host." repo_file_missing_error_msg: "repo_manager.repo file not found. Please run local_repo.yml playbook to create a repo file." not_aarch64_error_msg: "This is not an aarch64 machine. Only ARM nodes can be used to build the image." repo_not_found_error_msg: "The baseos repo section is not available in repo_manager.repo" -nfs_not_configured_msg: > - To build aarch64 images on an ARM node, the NFS server must be configured on the OIM. - Please run oim_cleanup.yml and reinstall the omnia_core container with the NFS option. + aarch64_image_fail_msg: > - Unable to pull the Ochami aarch64 image builder image. - Make sure the aarch64 image builder image is available in your container registry. - Verify image_build_config.yml and catalog/package_groups configuration are correct. + Unable to pull the aarch64 image builder image from both repo manager and upstream registry. + Tried: 1) Repo manager (Pulp): {{ builder_image_path | default('N/A') }} + 2) Upstream registry: {{ repo_builder_image }} + Ensure either the repo manager has synced the image or the aarch64 node has internet access. + +pull_image_fallback_msg: >- + Pull from repo manager ({repo_path}) failed. Attempting direct pull from {upstream_image}... + +pull_image_success_msg: >- + Successfully pulled {upstream_image} directly from upstream registry on aarch64 node. + regctl_not_found_msg: > - regctl binary not found at {{ ochami_aarch_64_dir }}/regctl. - Please run prepare_oim.yml playbook to download the regctl binary. + regctl binary could not be obtained for the aarch64 node. + Tried: 1) Copy from OIM localhost + 2) Download from {{ regctl_download_url }} + Ensure the OIM has regctl installed or the aarch64 node has internet access. + +ssh_setup_fail_msg: > + Failed to establish passwordless SSH to {{ aarch64_inventory_host_ip }}. + Possible causes: + 1) SSH keypair missing — check /root/.ssh/id_rsa exists on OIM + 2) aarch64_ssh_password is incorrect in image_build_credentials.yml + 3) SSH service not running on aarch64 node + 4) Firewall blocking port 22 + Manual fix: ssh-keygen -t rsa -b 4096 && ssh-copy-id {{ aarch64_ssh_user | default('root') }}@{{ aarch64_inventory_host_ip }} diff --git a/src/image_build_manager/roles/validate_build_runtime/tasks/validate_aarch64_host.yml b/src/image_build_manager/roles/validate_build_runtime/tasks/validate_aarch64_host.yml index 349b6f5b9a..0d0898f564 100644 --- a/src/image_build_manager/roles/validate_build_runtime/tasks/validate_aarch64_host.yml +++ b/src/image_build_manager/roles/validate_build_runtime/tasks/validate_aarch64_host.yml @@ -12,83 +12,55 @@ # See the License for the specific language governing permissions and # limitations under the License. --- -# validate_aarch64_host.yml — Validate aarch64_inventory_host_ip from -# image_build_config.yml and dynamically create admin_aarch64 group. -# Modeled after gitlab/roles/hosted_gitlab/tasks/validate_prerequisites.yml +# validate_aarch64_host.yml — Validate aarch64 build host and create dynamic +# inventory group. This file does VALIDATION ONLY — no SSH setup. +# +# Checks performed: +# 1. Is aarch64_inventory_host_ip configured? +# 2. Is the host reachable (ping)? +# 3. Create admin_aarch64 dynamic inventory group +# +# SSH setup (keygen, known_hosts, ssh-copy-id) is handled separately by +# prepare_aarch64_node/tasks/setup_ssh.yml, called from a localhost play +# in the playbook BEFORE the hosts: admin_aarch64 play. # --- 1. Check if aarch64 IP is configured --- -- name: Check if aarch64_inventory_host_ip is set +- name: Check if aarch64 IP is configured ansible.builtin.set_fact: - aarch64_build_enabled: "{{ (aarch64_inventory_host_ip | default('') | length) > 0 }}" + aarch64_build_enabled: >- + {{ (aarch64_inventory_host_ip | default('') | length > 0) | bool }} + cacheable: true - name: Skip aarch64 validation if IP not configured ansible.builtin.debug: msg: "aarch64_inventory_host_ip is not set — aarch64 image build will be skipped." - when: not aarch64_build_enabled | bool - -# --- 2. Validate IP format --- -- name: Validate aarch64 host IP format - when: aarch64_build_enabled | bool - ansible.builtin.assert: - that: - - aarch64_inventory_host_ip | ansible.utils.ipv4 - fail_msg: > - Invalid IP address format for aarch64_inventory_host_ip: '{{ aarch64_inventory_host_ip }}'. - Please provide a valid IPv4 address in image_build_config.yml. - quiet: true + when: not (aarch64_build_enabled | bool) -# --- 3. Check IP is not loopback or broadcast --- -- name: Validate IP is not loopback or broadcast +# --- 2. Ping check — verify host is reachable --- +- name: Ping aarch64 build host when: aarch64_build_enabled | bool - ansible.builtin.assert: - that: - - aarch64_inventory_host_ip != '127.0.0.1' - - aarch64_inventory_host_ip != '0.0.0.0' - - aarch64_inventory_host_ip != '255.255.255.255' - fail_msg: > - aarch64_inventory_host_ip cannot be loopback (127.0.0.1), unspecified (0.0.0.0), - or broadcast (255.255.255.255). Got: '{{ aarch64_inventory_host_ip }}'. - quiet: true - -# --- 4. Check connectivity (ping) --- -- name: Check connectivity to aarch64 build host - when: aarch64_build_enabled | bool - ansible.builtin.command: ping -c 3 -W 2 {{ aarch64_inventory_host_ip }} - register: aarch64_host_ping + ansible.builtin.command: >- + ping -c 2 -W 3 {{ aarch64_inventory_host_ip }} + register: _aarch64_ping changed_when: false failed_when: false -- name: Validate aarch64 build host is reachable - when: aarch64_build_enabled | bool - ansible.builtin.assert: - that: - - aarch64_host_ping.rc == 0 - fail_msg: > +- name: Fail if aarch64 build host is unreachable + when: + - aarch64_build_enabled | bool + - _aarch64_ping.rc | default(1) != 0 + ansible.builtin.fail: + msg: > Cannot reach aarch64 build host at {{ aarch64_inventory_host_ip }}. - Verify the IP is correct and the host is powered on and accessible. - Ping output: {{ aarch64_host_ping.stderr | default('') }} - quiet: true + Verify the IP is correct and the host is powered on and network-reachable. + Ping stderr: {{ _aarch64_ping.stderr | default('no output') }} -# --- 5. Check SSH connectivity --- -- name: Check SSH connectivity to aarch64 build host +- name: Display aarch64 host reachability when: aarch64_build_enabled | bool - ansible.builtin.command: > - ssh -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=no - {{ aarch64_ssh_user | default('root') }}@{{ aarch64_inventory_host_ip }} 'echo OK' - register: aarch64_ssh_check - changed_when: false - failed_when: false - -- name: Warn if SSH passwordless access not yet configured - when: - - aarch64_build_enabled | bool - - aarch64_ssh_check.rc | default(1) != 0 ansible.builtin.debug: - msg: > - SSH passwordless access to {{ aarch64_inventory_host_ip }} is not yet configured. - The prepare_aarch64_node role will attempt ssh-copy-id using aarch64_ssh_password. + msg: "aarch64 host {{ aarch64_inventory_host_ip }} is reachable (ping OK)" -# --- 6. Dynamically create admin_aarch64 inventory group --- +# --- 3. Dynamically create admin_aarch64 inventory group --- - name: Add aarch64 host to admin_aarch64 group dynamically when: aarch64_build_enabled | bool ansible.builtin.add_host: diff --git a/src/image_build_manager/vars/openchami_image_cmd.yml b/src/image_build_manager/vars/openchami_image_cmd.yml index 59d1f7d1fe..9b9ae472d7 100644 --- a/src/image_build_manager/vars/openchami_image_cmd.yml +++ b/src/image_build_manager/vars/openchami_image_cmd.yml @@ -1,4 +1,4 @@ -# Copyright 2025 Dell Inc. or its subsidiaries. All Rights Reserved. +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/test/image_build_manager/README.md b/test/image_build_manager/README.md index f4204f8bb2..c5682e180d 100644 --- a/test/image_build_manager/README.md +++ b/test/image_build_manager/README.md @@ -340,11 +340,11 @@ See [`fvt/README.md`](fvt/README.md) for the complete test case registry. | precheck | TC_PC_ | 6 | 001–006 | | validate | TC_VL_ | 4 | 001–004 (includes repo_ssl_verify_config) | | prepare | TC_PR_ | 8 | 001–008 | -| build | TC_BD_ | 16 | 001–016 (007–011 naming, 012–015 aarch64+packages, 016 repo_ssl_verify) | +| build | TC_BD_ | 21 | 001–021 (007–011 naming, 012–015 aarch64+packages, 016 repo_ssl_verify, 017–021 aarch64 infra) | | cleanup | TC_CL_ | 8 | 001–008 | | cleanup_images | TC_CI_ | 3 | 001–003 | | nft | NFT_ | 4 | | -| **Total** | | **50** | Plus TC_IB_001 (full-stack deploy) | +| **Total** | | **55** | Plus TC_IB_001 (full-stack deploy) | ### Build-type naming convention tests (TC_BD_007 – TC_BD_011) diff --git a/test/image_build_manager/fvt/README.md b/test/image_build_manager/fvt/README.md index bfc0808e05..a7579dd72f 100644 --- a/test/image_build_manager/fvt/README.md +++ b/test/image_build_manager/fvt/README.md @@ -60,6 +60,20 @@ All test case IDs follow the format `TC__`. | TC_BD_015 | `test_image_packages_aarch64` | image_verification/ | 14 | aarch64, sanity | Verify packages installed in aarch64 S3 images | | TC_BD_016 | `test_repo_ssl_verify_applied` | *(validate/status)* | 4 | x86_64, functional | Verify repo_ssl_verify is applied in build templates | +### AArch64 infrastructure + +These tests verify that the aarch64 build node was correctly prepared by +`build_image_aarch64.yml` (SSH setup, work dirs, builder image, regctl). +All auto-skip when `aarch64_inventory_host_ip` is not configured. + +| TC ID | Test | Suite | Order | Markers | Description | +|-------|------|-------|-------|---------|-------------| +| TC_BD_017 | `test_aarch64_ssh_connectivity` | aarch64/ | 10 | aarch64, sanity | Verify passwordless SSH to aarch64 node | +| TC_BD_018 | `test_aarch64_work_dirs` | aarch64/ | 11 | aarch64, sanity | Verify aarch64 work directories exist | +| TC_BD_019 | `test_aarch64_builder_image` | aarch64/ | 12 | aarch64, functional | Verify builder image on aarch64 node | +| TC_BD_020 | `test_aarch64_regctl_installed` | aarch64/ | 13 | aarch64, functional | Verify regctl installed on aarch64 node | +| TC_BD_021 | `test_aarch64_architecture` | aarch64/ | 14 | aarch64, functional | Verify aarch64 node is ARM architecture | + ### Build-type naming convention These cases verify that the `-imgbld` / `-imgth` artifact suffix is applied correctly @@ -116,10 +130,10 @@ so the two build engines never overwrite each other's registry images or S3 obje | precheck | TC_PC_ | 6 (001–006) | | | validate | TC_VL_ | 4 (001–004) | 004 = repo_ssl_verify_config | | prepare | TC_PR_ | 8 (001–008) | | -| build | TC_BD_ | 16 (001–016) | 007–011 naming, 012–015 aarch64+packages, 016 repo_ssl_verify | +| build | TC_BD_ | 21 (001–021) | 007–011 naming, 012–015 aarch64+packages, 016 repo_ssl_verify, 017–021 aarch64 infra | | cleanup | TC_CL_ | 8 (001–008) | | | cleanup_images | TC_CI_ | 3 (001–003) | | -| **Total** | | **45** | Plus TC_IB_001 (full-stack deploy) | +| **Total** | | **50** | Plus TC_IB_001 (full-stack deploy) | ### Naming Convention Test Matrix diff --git a/test/image_build_manager/fvt/build/aarch64/__init__.py b/test/image_build_manager/fvt/build/aarch64/__init__.py new file mode 100644 index 0000000000..54b309aafe --- /dev/null +++ b/test/image_build_manager/fvt/build/aarch64/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/test/image_build_manager/fvt/build/aarch64/test_aarch64_build.py b/test/image_build_manager/fvt/build/aarch64/test_aarch64_build.py new file mode 100644 index 0000000000..58c632e212 --- /dev/null +++ b/test/image_build_manager/fvt/build/aarch64/test_aarch64_build.py @@ -0,0 +1,215 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Image Build Build — AArch64-specific Verification. + +Verifies aarch64 build infrastructure: SSH connectivity, node preparation, +work directory creation, builder image pull, and regctl installation. +These tests run on the OIM (localhost) and validate the state of the +remote aarch64 node after build_image_aarch64.yml completes. +""" + +import pytest + +from library.functions import ( + TestLogger, + load_test_config, +) +from library.vars import TEST_CASES as TC +from library.vars.common_vars import CMDS, SHARED_PATH + + +# ============================================================================= +# HELPERS +# ============================================================================= + +def _get_aarch64_ip(host): + """Read aarch64_inventory_host_ip from image_build_config.yml on target.""" + config = load_test_config() + project = config.get("project_name", "project_default") + data_path = host.check_output( + "echo $OMNIA_DATA_PATH" + ).strip() or "/opt/omnia" + cfg_path = ( + f"{data_path}/image_build_manager/input/{project}" + f"/image_build_config.yml" + ) + result = host.run(CMDS["cat_file"].format(path=cfg_path)) + if result.rc != 0: + return "" + for line in result.stdout.splitlines(): + stripped = line.strip() + if stripped.startswith("aarch64_inventory_host_ip:"): + val = stripped.split(":", 1)[1].strip().strip('"').strip("'") + return val + return "" + + +def _skip_if_no_aarch64(host): + """Skip test if aarch64 is not configured.""" + ip = _get_aarch64_ip(host) + if not ip: + pytest.skip("aarch64_inventory_host_ip not configured — skipping") + return ip + + +# ============================================================================= +# TESTS +# ============================================================================= + +@pytest.mark.aarch64 +@pytest.mark.sanity +@pytest.mark.order(10) +def test_aarch64_ssh_connectivity(host): + """Verify passwordless SSH from OIM to aarch64 node works.""" + tc = TC["aarch64_ssh_connectivity"] + tl = TestLogger(tc["title"], tc["id"]) + ip = _skip_if_no_aarch64(host) + + result = host.run( + f"ssh -o BatchMode=yes -o ConnectTimeout=10 " + f"-o StrictHostKeyChecking=no root@{ip} 'echo OK'" + ) + + if result.rc == 0 and "OK" in result.stdout: + tl.passed(f"Passwordless SSH to {ip} works") + else: + tl.failed( + f"SSH to {ip} failed (rc={result.rc})", + result.stderr, + ) + + assert result.rc == 0 and "OK" in result.stdout, ( + f"Passwordless SSH to aarch64 node {ip} failed. " + f"rc={result.rc}, stderr={result.stderr}" + ) + + +@pytest.mark.aarch64 +@pytest.mark.sanity +@pytest.mark.order(11) +def test_aarch64_work_dirs(host): + """Verify aarch64 work directories exist on the remote node.""" + tc = TC["aarch64_work_dirs"] + tl = TestLogger(tc["title"], tc["id"]) + ip = _skip_if_no_aarch64(host) + + work_dir = "/opt/omnia/image_build_manager" + dirs = [ + work_dir, + f"{work_dir}/openchami/aarch64", + f"{work_dir}/workdir", + f"{work_dir}/log", + ] + + missing = [] + for d in dirs: + result = host.run( + f"ssh -o BatchMode=yes -o ConnectTimeout=10 " + f"-o StrictHostKeyChecking=no root@{ip} " + f"'test -d {d} && echo exists'" + ) + if "exists" not in result.stdout: + missing.append(d) + + if not missing: + tl.passed(f"All {len(dirs)} work directories exist on {ip}") + else: + tl.failed(f"{len(missing)} work directories missing on {ip}") + + assert not missing, ( + f"Missing aarch64 work directories on {ip}: {missing}" + ) + + +@pytest.mark.aarch64 +@pytest.mark.functional +@pytest.mark.order(12) +def test_aarch64_builder_image(host): + """Verify builder container image exists on aarch64 node.""" + tc = TC["aarch64_builder_image"] + tl = TestLogger(tc["title"], tc["id"]) + ip = _skip_if_no_aarch64(host) + + result = host.run( + f"ssh -o BatchMode=yes -o ConnectTimeout=10 " + f"-o StrictHostKeyChecking=no root@{ip} " + f"'podman images --format \"{{{{.Repository}}}}:{{{{.Tag}}}}\" " + f"| grep -E \"aarch64-image-(builder|thrillhouse)\"'" + ) + + if result.rc == 0 and result.stdout.strip(): + images = result.stdout.strip().splitlines() + tl.passed( + f"Builder image found on {ip}: {images[0]}" + ) + else: + tl.failed(f"No aarch64 builder image on {ip}") + + assert result.rc == 0 and result.stdout.strip(), ( + f"aarch64 builder image not found on {ip}. " + f"Ensure prepare_aarch64_node pulled the image successfully." + ) + + +@pytest.mark.aarch64 +@pytest.mark.functional +@pytest.mark.order(13) +def test_aarch64_regctl_installed(host): + """Verify regctl binary is installed and functional on aarch64 node.""" + tc = TC["aarch64_regctl_installed"] + tl = TestLogger(tc["title"], tc["id"]) + ip = _skip_if_no_aarch64(host) + + result = host.run( + f"ssh -o BatchMode=yes -o ConnectTimeout=10 " + f"-o StrictHostKeyChecking=no root@{ip} " + f"'/usr/local/bin/regctl version'" + ) + + if result.rc == 0 and result.stdout.strip(): + tl.passed(f"regctl on {ip}: {result.stdout.strip()}") + else: + tl.failed(f"regctl not functional on {ip}") + + assert result.rc == 0, ( + f"regctl not installed or not functional on aarch64 node {ip}. " + f"rc={result.rc}, stderr={result.stderr}" + ) + + +@pytest.mark.aarch64 +@pytest.mark.functional +@pytest.mark.order(14) +def test_aarch64_architecture(host): + """Verify the aarch64 node is actually running ARM architecture.""" + tc = TC["aarch64_architecture"] + tl = TestLogger(tc["title"], tc["id"]) + ip = _skip_if_no_aarch64(host) + + result = host.run( + f"ssh -o BatchMode=yes -o ConnectTimeout=10 " + f"-o StrictHostKeyChecking=no root@{ip} 'uname -m'" + ) + + arch = result.stdout.strip() + if result.rc == 0 and arch == "aarch64": + tl.passed(f"Node {ip} architecture: {arch}") + else: + tl.failed(f"Node {ip} architecture: {arch} (expected aarch64)") + + assert result.rc == 0 and arch == "aarch64", ( + f"aarch64 node {ip} reports architecture '{arch}', expected 'aarch64'" + ) diff --git a/test/image_build_manager/library/vars/test_case_vars.py b/test/image_build_manager/library/vars/test_case_vars.py index e4e2c61eb4..eb4951794b 100644 --- a/test/image_build_manager/library/vars/test_case_vars.py +++ b/test/image_build_manager/library/vars/test_case_vars.py @@ -128,6 +128,28 @@ "title": "Verify packages installed in aarch64 S3 images", }, + # ── Build — aarch64 infrastructure (TC_BD_017-021) ───────────────────── + "aarch64_ssh_connectivity": { + "id": "TC_BD_017", + "title": "Verify passwordless SSH to aarch64 node", + }, + "aarch64_work_dirs": { + "id": "TC_BD_018", + "title": "Verify aarch64 work directories exist", + }, + "aarch64_builder_image": { + "id": "TC_BD_019", + "title": "Verify builder image on aarch64 node", + }, + "aarch64_regctl_installed": { + "id": "TC_BD_020", + "title": "Verify regctl installed on aarch64 node", + }, + "aarch64_architecture": { + "id": "TC_BD_021", + "title": "Verify aarch64 node is ARM architecture", + }, + # ── Build — naming convention (TC_BD_007-011) ───────────────────────── "registry_naming_ib_x86_64": { "id": "TC_BD_007", diff --git a/test/image_build_manager/test_creds.yml b/test/image_build_manager/test_creds.yml index 72bf2df11a..e7e53fc2af 100644 --- a/test/image_build_manager/test_creds.yml +++ b/test/image_build_manager/test_creds.yml @@ -1,113 +1,49 @@ -$ANSIBLE_VAULT;1.1;AES256 -61643831323664653832326161616563376137363030643665633832336362346431303339376632 -6335623938396263333162323231643461646264393161380a303738376136336536386539393036 -33356564323963623132366230376234383034323363353138633935303865366232646639326664 -6236636164376334650a363062653833393066336434323034353233303337626338326366303630 -35366334636463323363616133356565333861636365623262343035303462376163663366353566 -62643634643134376666636430613461333230656161313537666234636562386361383361616630 -34336636336633646235336463643063333239363635363134326637386537633537323239356336 -64643732653037353438373231313266306334613135376661316634326537346439313433643261 -63336538666666373438663532613466653039663537316337306438666631383037366466383933 -34373061623964656235383334376439313039663762376135313036383630346431613965303163 -34383064623565303732343966373632373531666633633239333932366138633664333338613265 -34383563616662663838633335393861663364326237333735656337636562623439626136313938 -38636265316637356335643061646361663634303562303461363631613437666532363434373962 -61323136626233376233616662346136613165396466323831383934643437633064336163623961 -38643435636164316439303566303463376465316333653365613133323439326664363038626635 -31366338376664323437636334613338316562366263643665363937626230353264323632643061 -37613161343136346436373535666637616230663866356664623635353333633837626639383764 -62646534343461303631316638623938316533623664353231313466643161633232393232363265 -63643435306563623763653730326430336134663762313436303336613361343763633763313431 -32356332626562663464383631303531363766383562303730613563663833376265353363656164 -63393262373638656666636132626336613838333237393430316233663439383131386634373033 -30363635353562623062343464643431306230393666366138346666303830373964306430616434 -30333931616330306266333165303061376133363966623336333333353137663630313465353837 -36396365656563643862616666636634316465666631623865356337396337356261666564623465 -61363961323064643938356638656262653766643164383538613135626636633231633633643365 -33656662643539616631396536663561356663643232343638356461623931303761666539323562 -31623138323731386564616335396564613039646330623332326336393732353761363333363337 -33633933326439376166356230646464313466303332656662613261616663343234386462646138 -38363339643965356436623731643838306364343034303836613062646563636164623066383239 -61616362333632303533343737643561643534653539303966653839306536303365313362353566 -30333034333438643538313636326261666633326162353235323131306561373934666234653733 -37353732353266343039323339393666643637643661623466386335643439383736643137363534 -66636536623332636532656330363336303833646161653739343432633765343030393163346339 -32376138343631636631656237663861663861353263376635636461373465303531363834373865 -37303633353834303639313737383066626632326362313665663635303137323030633133313332 -62363532626263313236366161333330383439653337656137353462363464646564333063356234 -35663138373537626335326163336230363039643035633163343166386463363862646639316330 -35633361353936386634393266663139323934343264666362633862653338643961643334343362 -64343862383065623630323661343634303730303030373236666534623434623331643234343966 -32363163333736396330376463343336356362636533363363306262626466333935343665666365 -64616334313465623333626333623865376263346465616332356131656563396238323762613964 -38346366613037353837343039326432346531643139613736383164653232326635376161353038 -36383739356161346138643330343936343636623939653162303364396464303330333035383437 -34656164383336383839643936623965653535366331303766323032663737653931326630616338 -31616437646130333731326264353336363335363637326163646466643965623437666638376533 -37626532346535643139373964306339343130613266393235663339343835613230366364323565 -34306531316263646130653038633237353364623838323963346330626338633865633663336466 -30346664343734396337316663316336396531303731663636333433386261643438626438333235 -32333862626433373666393339643437633062663163343330356335616466386134373662333234 -61316632636462623831316466643364653964306165633134323833636434626238323337393364 -34373364376461633736353263373931396432356165303236306162633066623863623961383230 -38346430346139313561373461373833613064383261623738306138653634656337333134623830 -34643365383030323063376665646335363339363132633061356236623830396235643962373233 -30306563356133303363643938343539313337386464656430353230656161393464386433386462 -38643162376533316539613832363832333461336532373836363135353533363162303536653539 -37336266386166373636323065346365303331623931666562366636333962336435386234396638 -35363731663734613866313261363338646365626465383837633765366565653162376161303165 -66663139373032386565653032663365633137666338353630626333376263313764323337306662 -32343565633664643933643430353661316664613135396438313837316664353565396563366138 -39633331343139333837343131653937316161396266656231613164393365303934313335646639 -65643632343366613236363565626163633463313931363462373433316632383732323165356438 -61383939623532373236313637363136626637643139303564343266636630333936363765383730 -37623634356631376466356133363365326331323364346536313466613261343364333534386638 -61663731383638643161323531663130663638333566633637663137643532306664383438323737 -39313630646363613465633934343935346232386462366461643863663962393261333162343930 -39353933633830393262363763663633313734646561383730366335376439326531343137393265 -64643731303061646235306663623163663162653130613131393039663761646636616465363038 -33363938316665326633326631356366316262613164383330366530656638303636376166303738 -34656536386163353064626362373537613930353439313962343332336262623264353133316665 -31333761626636316564643762636136373163363461356664623161353138663465663666663631 -38643230373032656133303163336661346439663261303863343634393366623731663232353064 -36393637613839356563393533396333663834313635363265373463643330343138656338303035 -33333636393062613536653032666263343837313333346164393765653064326165656139373765 -30363932663061376430643361396661393539386333353734656561633633333034373562666532 -38623531663664373638646136316237363862633938346439373733383738346634633362633932 -34373332333061373132356533623732623164663939326435316330343234393761383365316438 -32333232333539333332643632303566666235666534373864626365366164353834613835646239 -39363966646639646138656664333831313936656234343234316131336330616165323265393362 -62393766323037326631633766373064643963373735633430663465353833303862653933303465 -63303730363364373432623637313137313661376562366234326139346462626632633038656437 -35613363623865346631396164336562383062336433643238646266323362636133643738313537 -62303963656334343136313334316633613964353464636139353837306635363565366233356634 -34636162313236626265626335653130336336346463373534646464346432323134616139326638 -33303664306137366563656165663035356639333165343331356266663637623738633032393461 -30613065616539643331633363363636353465616339316461663363643865633736323330646431 -65633666376633303835623263633130323435633737393364343130663439613932386132303266 -31626435316233363338313635633636363466393934616161353263316235346564613665633264 -34313138366635646638303736306438383431656461336364643535346561373335306337326438 -38346539353232396136333864343265663133613930336366663361666234363436363633303239 -30383661613961343563333635366438356437383035613661366237636533613034633231363262 -66353035616363353832376238623166393930306430313439333530396233666462313839366437 -61383337376330346232653830616336646533626465616337653161636234616136303537366430 -63396366393234646433633565626165643134636464363030613336393763323431646430396465 -64643836363730366530303134393832636432383461363436303138366631306563383435363130 -64613561373465346534643034306430393662656636306164303238306431316633323832313665 -38366666643334373738633532336634393133323036383334353739306666613066343464653234 -63666639663539393966386333623233623033323864663536303161663130313161636332373138 -64656339376237396235313233636137393763656330646333613665623730346265386665643033 -62646665383462666435333430633765656133643934666135316266633364353337613339343034 -38313537643339373034636463323831356539303564653939333563373962323865633039303463 -38613339383838613433613063326262663166353134326261323062373038646637363639666434 -65343463663665343535356532613736376233633937363338373238343965363664373663653866 -30336430373338656336346265343837633665666434353761663630633632393639353861383965 -32356233626264623630313536623766636637303761353935366463303332383962373936333131 -31393163623132336530316164363934383738343061363965633166316636623439643236313261 -35393463633236393030356538613635666238376234636634396533373739643562313638623163 -34633932363236326663623936313338663363343464356435633038313837353532656464356565 -36343036373165393232656133303864323335623938303830333438366333386632373666666231 -38313062663365386239666136316531393538663164373763316233303139366463393663653730 -62636637393836613533326166613864386662386138663530353763633439383164653338356534 -39633133303263346239383631386238383939393132316332346633636134336431626239353937 -65633464363765653630 +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# ============================================================================= +# Image Build Manager — Test Credentials +# ============================================================================= +# This file is auto-encrypted with Ansible Vault on first test run. +# Use setup_env.sh to fill in values — never edit the encrypted form directly. +# +# MANAGED BY setup_env.sh: +# SSH creds: bash setup_env.sh --set-password +# Build creds: bash setup_env.sh --set-build-creds +# +# FIELDS: +# oim_password — SSH password for the remote OIM server (oim_server_ip). +# Leave empty for key-based authentication. +# +# s3_access_id — MinIO / S3 access key ID. +# Used to populate image_build_credentials.yml on the target. +# Required when the image build playbook writes images to S3. +# +# s3_secret_key — MinIO / S3 secret key. +# Stored encrypted; never committed in plain text. +# +# aarch64_ssh_password — SSH password for the aarch64 cross-compile build host. +# Required only when aarch64_inventory_host_ip is set in +# image_build_config.yml. Leave empty otherwise. +# ============================================================================= + +--- +oim_password: "" + +# Image build credentials (S3 / MinIO) +s3_access_id: "" +s3_secret_key: "" + +# aarch64 build host (optional — leave empty if not building aarch64 images) +aarch64_ssh_password: "" diff --git a/test/image_build_manager/ut/test_catalog_validation.py b/test/image_build_manager/ut/test_catalog_validation.py new file mode 100644 index 0000000000..e289fa4993 --- /dev/null +++ b/test/image_build_manager/ut/test_catalog_validation.py @@ -0,0 +1,180 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Tests for catalog JSON schema and validation logic.""" + +import json +import pathlib +import sys +import tempfile + + +# ut/test_catalog... -> ut/ -> image_build_manager/ -> test/ -> omnia-bsm/ +REPO_ROOT = pathlib.Path(__file__).resolve().parents[3] + +SCHEMA_DIR = ( + REPO_ROOT / "src" / "image_build_manager" / "plugins" + / "module_utils" / "input_validation" / "schema" +) + +SAMPLE_CATALOG = ( + REPO_ROOT / "src" / "main" / "samples" / "catalog_rhel.json" +) + +# Add the src path so we can import the validator directly +_SRC_PLUGINS = ( + REPO_ROOT / "src" / "image_build_manager" / "plugins" + / "module_utils" +) + +# Mock the ansible import path for direct testing +_MOCK_PATH = str(REPO_ROOT / "src" / "image_build_manager" / "plugins") +if _MOCK_PATH not in sys.path: + sys.path.insert(0, _MOCK_PATH) + + +class TestCatalogSchemaFile: + """Validate the catalog.json schema file exists and is valid.""" + + def test_schema_file_exists(self): + """catalog.json schema must exist.""" + schema_file = SCHEMA_DIR / "catalog.json" + assert schema_file.exists(), f"Schema not found at {schema_file}" + + def test_schema_is_valid_json(self): + """Schema file must be valid JSON.""" + schema_file = SCHEMA_DIR / "catalog.json" + with open(schema_file, "r", encoding="utf-8") as f: + schema = json.load(f) + assert "properties" in schema + assert "required" in schema + + def test_schema_requires_catalog_root(self): + """Schema must require 'catalog' root key.""" + schema_file = SCHEMA_DIR / "catalog.json" + with open(schema_file, "r", encoding="utf-8") as f: + schema = json.load(f) + assert "catalog" in schema["required"] + + def test_schema_requires_functionallayer(self): + """Schema must require 'functionallayer' in catalog object.""" + schema_file = SCHEMA_DIR / "catalog.json" + with open(schema_file, "r", encoding="utf-8") as f: + schema = json.load(f) + catalog_props = schema["properties"]["catalog"] + assert "functionallayer" in catalog_props["required"] + + def test_schema_requires_groups(self): + """Schema must require 'groups' in catalog object.""" + schema_file = SCHEMA_DIR / "catalog.json" + with open(schema_file, "r", encoding="utf-8") as f: + schema = json.load(f) + catalog_props = schema["properties"]["catalog"] + assert "groups" in catalog_props["required"] + + def test_schema_requires_packages(self): + """Schema must require 'packages' in catalog object.""" + schema_file = SCHEMA_DIR / "catalog.json" + with open(schema_file, "r", encoding="utf-8") as f: + schema = json.load(f) + catalog_props = schema["properties"]["catalog"] + assert "packages" in catalog_props["required"] + + +class TestSampleCatalogStructure: + """Validate the sample catalog JSON structure.""" + + def test_sample_catalog_exists(self): + """Sample catalog must exist.""" + assert SAMPLE_CATALOG.exists(), ( + f"Sample catalog not found at {SAMPLE_CATALOG}" + ) + + def test_sample_catalog_is_valid_json(self): + """Sample catalog must be valid JSON.""" + with open(SAMPLE_CATALOG, "r", encoding="utf-8") as f: + data = json.load(f) + assert "catalog" in data + + def test_sample_has_required_keys(self): + """Sample catalog must have name, version, identifier, functionallayer, groups, packages.""" + with open(SAMPLE_CATALOG, "r", encoding="utf-8") as f: + catalog = json.load(f)["catalog"] + for key in ("name", "version", "identifier", "functionallayer", "groups", "packages"): + assert key in catalog, f"Sample catalog missing required key: {key}" + + def test_sample_functionallayer_not_empty(self): + """Sample catalog must have at least one functional layer.""" + with open(SAMPLE_CATALOG, "r", encoding="utf-8") as f: + catalog = json.load(f)["catalog"] + assert len(catalog["functionallayer"]) > 0 + + def test_sample_groups_reference_valid_packages(self): + """All package keys in groups.components must exist in packages. + + Known gap: ldms_group references ovis_ldms which is not yet + defined in the sample catalog packages section. + """ + # Known dangling references in the sample catalog (tracked for fix) + known_gaps = {"ovis_ldms"} + + with open(SAMPLE_CATALOG, "r", encoding="utf-8") as f: + catalog = json.load(f)["catalog"] + groups = catalog.get("groups", {}) + packages = catalog.get("packages", {}) + dangling = [] + for group_name, group_data in groups.items(): + for pkg_key in group_data.get("components", []): + if pkg_key not in packages and pkg_key not in known_gaps: + dangling.append(f"{group_name} -> {pkg_key}") + assert not dangling, ( + f"Dangling package references in sample catalog: {dangling}" + ) + + def test_sample_layers_reference_valid_groups(self): + """All component names in functionallayer.components must exist in groups.""" + with open(SAMPLE_CATALOG, "r", encoding="utf-8") as f: + catalog = json.load(f)["catalog"] + groups = catalog.get("groups", {}) + dangling = [] + for layer in catalog.get("functionallayer", []): + for comp in layer.get("components", []): + if comp not in groups: + dangling.append(f"{layer['name']} -> {comp}") + assert not dangling, ( + f"Dangling group references in sample catalog: {dangling}" + ) + + def test_sample_has_baseos_group(self): + """Sample catalog must have at least one base_os group.""" + with open(SAMPLE_CATALOG, "r", encoding="utf-8") as f: + catalog = json.load(f)["catalog"] + groups = catalog.get("groups", {}) + baseos = [ + name for name, data in groups.items() + if data.get("type") == "base_os" + ] + assert len(baseos) > 0, "No base_os groups found in sample catalog" + + def test_sample_packages_have_sources(self): + """Each package must have a sources array with architecture.""" + with open(SAMPLE_CATALOG, "r", encoding="utf-8") as f: + catalog = json.load(f)["catalog"] + packages = catalog.get("packages", {}) + no_sources = [ + key for key, pkg in packages.items() + if not pkg.get("sources") + ] + assert not no_sources, ( + f"Packages without sources: {no_sources[:10]}" + )