From 0c3c3c49b7a2ca155a2fa406aae1c8ac86d3e5a0 Mon Sep 17 00:00:00 2001 From: Osotechie Date: Mon, 22 Jun 2026 19:38:55 +1200 Subject: [PATCH 1/3] feature/initial_commit --- .github/workflows/provision.yml | 235 +++++++++++++++++ .github/workflows/updates.yml | 171 +++++++++++++ .github/workflows/validation.yml | 157 ++++++++++++ .gitignore | 19 ++ README.md | 238 +++++++++++++++++- ansible/ansible.cfg | 4 + ansible/inventory/environments.yml | 12 + ansible/inventory/group_vars/production.yml | 116 +++++++++ ansible/inventory/group_vars/test.yml | 116 +++++++++ ansible/playbooks/provision.yml | 104 ++++++++ .../files/gasket-dkms_1.0-18_all.deb | Bin 0 -> 48994 bytes .../roles/owendemooy.coraltpu/meta/main.yml | 53 ++++ .../roles/owendemooy.coraltpu/tasks/main.yml | 32 +++ .../roles/owendemooy.docker/meta/main.yml | 53 ++++ .../roles/owendemooy.docker/tasks/main.yml | 18 ++ .../owendemooy.nas-backup/defaults/main.yml | 8 + .../owendemooy.nas-backup/files/backup.sh | 59 +++++ .../owendemooy.nas-backup/files/restore.sh | 14 ++ .../roles/owendemooy.nas-backup/meta/main.yml | 53 ++++ .../owendemooy.nas-backup/tasks/main.yml | 29 +++ .../owendemooy.nut-client/defaults/main.yml | 12 + .../owendemooy.nut-client/handlers/main.yml | 6 + .../roles/owendemooy.nut-client/meta/main.yml | 53 ++++ .../owendemooy.nut-client/tasks/main.yml | 35 +++ .../roles/owendemooy.samba/defaults/main.yml | 4 + .../roles/owendemooy.samba/handlers/main.yml | 17 ++ .../roles/owendemooy.samba/meta/main.yml | 53 ++++ .../roles/owendemooy.samba/tasks/main.yml | 44 ++++ .../roles/owendemooy.storage/meta/main.yml | 53 ++++ .../roles/owendemooy.storage/tasks/main.yml | 28 +++ ansible/playbooks/updates.yml | 30 +++ 31 files changed, 1824 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/provision.yml create mode 100644 .github/workflows/updates.yml create mode 100644 .github/workflows/validation.yml create mode 100644 .gitignore create mode 100644 ansible/ansible.cfg create mode 100644 ansible/inventory/environments.yml create mode 100644 ansible/inventory/group_vars/production.yml create mode 100644 ansible/inventory/group_vars/test.yml create mode 100644 ansible/playbooks/provision.yml create mode 100644 ansible/playbooks/roles/owendemooy.coraltpu/files/gasket-dkms_1.0-18_all.deb create mode 100644 ansible/playbooks/roles/owendemooy.coraltpu/meta/main.yml create mode 100644 ansible/playbooks/roles/owendemooy.coraltpu/tasks/main.yml create mode 100644 ansible/playbooks/roles/owendemooy.docker/meta/main.yml create mode 100644 ansible/playbooks/roles/owendemooy.docker/tasks/main.yml create mode 100644 ansible/playbooks/roles/owendemooy.nas-backup/defaults/main.yml create mode 100644 ansible/playbooks/roles/owendemooy.nas-backup/files/backup.sh create mode 100644 ansible/playbooks/roles/owendemooy.nas-backup/files/restore.sh create mode 100644 ansible/playbooks/roles/owendemooy.nas-backup/meta/main.yml create mode 100644 ansible/playbooks/roles/owendemooy.nas-backup/tasks/main.yml create mode 100644 ansible/playbooks/roles/owendemooy.nut-client/defaults/main.yml create mode 100644 ansible/playbooks/roles/owendemooy.nut-client/handlers/main.yml create mode 100644 ansible/playbooks/roles/owendemooy.nut-client/meta/main.yml create mode 100644 ansible/playbooks/roles/owendemooy.nut-client/tasks/main.yml create mode 100644 ansible/playbooks/roles/owendemooy.samba/defaults/main.yml create mode 100644 ansible/playbooks/roles/owendemooy.samba/handlers/main.yml create mode 100644 ansible/playbooks/roles/owendemooy.samba/meta/main.yml create mode 100644 ansible/playbooks/roles/owendemooy.samba/tasks/main.yml create mode 100644 ansible/playbooks/roles/owendemooy.storage/meta/main.yml create mode 100644 ansible/playbooks/roles/owendemooy.storage/tasks/main.yml create mode 100644 ansible/playbooks/updates.yml diff --git a/.github/workflows/provision.yml b/.github/workflows/provision.yml new file mode 100644 index 0000000..7e2b6b6 --- /dev/null +++ b/.github/workflows/provision.yml @@ -0,0 +1,235 @@ +# This is a basic workflow to help you get started with Actions + +name: Provision + +# Controls when the workflow will run +on: + push: + branches: + - main + paths-ignore: + - '**/*.md' + +permissions: + contents: read + +# A workflow run is made up of one or more jobs that can run sequentially or in parallel +jobs: + + deploy: + runs-on: ubuntu-latest + strategy: + fail-fast: true + max-parallel: 1 + matrix: + environment: [test, production] + environment: ${{ matrix.environment }} + defaults: + run: + working-directory: ansible + + steps: + + # STEP - Check-out Rep + # ==================== + # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it + - name: Check-out Repo + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + # STEP - Connect to Azure + # ======================= + # This step is used to connect to Azure to allow reading of Azure KeyVault in later step + - name: Connect to Azure + uses: azure/login@532459ea530d8321f2fb9bb10d1e0bcf23869a43 # v3.0.0 + with: + creds: ${{ secrets.AZURE_SPN_CREDENTIALS }} + + # STEP - Get Azure KeyVault Secrets + # ================================= + # List all secrets in Azure KeyVault, retrieve each value, mask it, + # and export as an environment variable for the Replace Tokens step. + # Secret names are uppercased with hyphens replaced by underscores + # (e.g. 'my-secret' becomes 'MY_SECRET'). + - name: Get Azure KeyVault Secrets + run: | + SECRET_NAMES=$(az keyvault secret list --vault-name ${{ secrets.AZURE_KEYVAULT_NAME }} --query "[].name" -o tsv) + for SECRET_NAME in $SECRET_NAMES; do + SECRET_VALUE=$(az keyvault secret show --name "$SECRET_NAME" --vault-name ${{ secrets.AZURE_KEYVAULT_NAME }} --query value -o tsv) + echo "::add-mask::$SECRET_VALUE" + ENV_NAME=$(echo "$SECRET_NAME" | tr '[:lower:]-' '[:upper:]_') + echo "$ENV_NAME=$SECRET_VALUE" >> $GITHUB_ENV + done + + # STEP - Replace tokens in .yml files + # =================================== + # Replaces #{TOKEN}# patterns in files with the matching + # environment variable. Uses Python for safe handling of special + # characters in secret values (no sed/regex escaping issues). + # Change TOKEN_FILES to target different file types (e.g. '**/*.env'). + - name: Replace tokens in .yml files + env: + TOKEN_FILES: '**/*.yml' + run: | + python3 << 'EOF' + import os, glob, re, sys + + pattern = os.environ.get('TOKEN_FILES', '**/*.yml') + count = 0 + errors = 0 + + for path in glob.glob(pattern, recursive=True): + try: + with open(path, 'r') as f: + content = f.read() + + replaced = re.sub( + r'#\{([^}]+)\}#', + lambda m: os.environ.get(m.group(1), m.group(0)), + content + ) + + if replaced != content: + with open(path, 'w') as f: + f.write(replaced) + count += 1 + print(f'\u2714 {path}') + except Exception: + errors += 1 + print(f'\u2716 {path} — failed to process') + + print(f'Done — {count} file(s) updated, {errors} error(s)') + if errors > 0: + sys.exit(1) + EOF + + # STEP - Connect to Wireguard + # =========================== + # Install and connect to Wireguard + - name: Connect to Wireguard + run: | + sudo apt-get update -qq + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends resolvconf wireguard + echo "${{ secrets.WG_CONFIG_FILE }}" > wg0.conf + sudo chmod 600 wg0.conf + sudo wg-quick up ./wg0.conf + + # STEP - Add Route to NAS + # ======================== + # Add route to NAS over Wireguard connection + - name: Add Routes + run: | + sudo ip route add ${{ vars.HOST_IP }} dev wg0 || true + ip route show + ping ${{ vars.HOST_IP }} -c 5 + + # STEP - Add SSH Keys + # ==================== + # Add SSH Key used to connect to NAS, and pin the NAS's host keys + # so we refuse to talk to anything else (replaces the previous + # trust-on-first-use ssh-keyscan step, which would silently accept + # whatever host key the network handed us each run). + # NOTE: If the NAS is ever rebuilt and its /etc/ssh/ssh_host_* keys + # regenerated, update the NAS_HOST_KEY secret (environment 'nas') + # with the new fingerprints or this step will refuse to connect. + - name: Add SSH Key + run: | + mkdir -p ~/.ssh + echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_rsa + chmod 600 ~/.ssh/id_rsa + echo "${{ secrets.HOST_FINGERPRINT }}" > ~/.ssh/known_hosts + chmod 644 ~/.ssh/known_hosts + + # STEP - Configure ssh for Docker + # ================================ + # Add the following ssh configs as per docker suggestion https://docs.docker.com/engine/security/protect-access/#ssh-tips + - name: Configure SSH + run: | + { + echo "Host *" + echo " ServerAliveInterval 30" + echo " ServerAliveCountMax 10" + echo " TCPKeepAlive yes" + echo " ConnectTimeout 30" + echo " ControlMaster auto" + echo " ControlPath ~/.ssh/control-%C" + echo " ControlPersist 10m" + } >> ~/.ssh/config + chmod 600 ~/.ssh/config + + # STEP - Run Ansible Playbook + # =========================== + # Run Ansible Playbook to deploy to NAS + - name: Run Ansible Playbook + id: ansible + env: + ANSIBLE_STDOUT_CALLBACK: default + ANSIBLE_CALLBACK_RESULT_FORMAT: yaml + ANSIBLE_FORCE_COLOR: "false" + run: | + set -o pipefail + ansible-playbook playbooks/provision.yml --limit ${{ vars.ENV_NAME }} 2>&1 | tee ansible-output.txt + echo "ansible_exit=0" >> $GITHUB_OUTPUT + continue-on-error: true + + # STEP - Generate deployment report + # ================================== + # Parse Ansible output into a readable GitHub Actions Job Summary + - name: Generate deployment report + if: always() + run: | + EXIT_CODE=${{ steps.ansible.outcome == 'success' && '0' || '1' }} + + echo "## 🚀 Provision — \`${{ vars.ENV_NAME }}\`" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + if [ "$EXIT_CODE" = "0" ]; then + echo "### ✅ Result: **SUCCESS**" >> $GITHUB_STEP_SUMMARY + else + echo "### ❌ Result: **FAILED**" >> $GITHUB_STEP_SUMMARY + fi + echo "" >> $GITHUB_STEP_SUMMARY + + # Extract and format the PLAY RECAP into a table + if grep -q "PLAY RECAP" ansible-output.txt; then + echo "### Play Recap" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| Host | OK | Changed | Unreachable | Failed | Skipped | Rescued | Ignored |" >> $GITHUB_STEP_SUMMARY + echo "|------|----:|--------:|------------:|-------:|--------:|--------:|--------:|" >> $GITHUB_STEP_SUMMARY + sed -n '/PLAY RECAP/,/^$/p' ansible-output.txt | grep -v "PLAY RECAP" | grep -v "^$" | while read -r line; do + HOST=$(echo "$line" | awk -F: '{print $1}' | xargs) + OK=$(echo "$line" | grep -oP 'ok=\K[0-9]+' || echo "0") + CHANGED=$(echo "$line" | grep -oP 'changed=\K[0-9]+' || echo "0") + UNREACH=$(echo "$line" | grep -oP 'unreachable=\K[0-9]+' || echo "0") + FAILED=$(echo "$line" | grep -oP 'failed=\K[0-9]+' || echo "0") + SKIPPED=$(echo "$line" | grep -oP 'skipped=\K[0-9]+' || echo "0") + RESCUED=$(echo "$line" | grep -oP 'rescued=\K[0-9]+' || echo "0") + IGNORED=$(echo "$line" | grep -oP 'ignored=\K[0-9]+' || echo "0") + echo "| \`$HOST\` | $OK | $CHANGED | $UNREACH | $FAILED | $SKIPPED | $RESCUED | $IGNORED |" >> $GITHUB_STEP_SUMMARY + done + echo "" >> $GITHUB_STEP_SUMMARY + fi + + # Full log in a collapsible section + echo "
📋 Full Ansible Output" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo '```yaml' >> $GITHUB_STEP_SUMMARY + cat ansible-output.txt >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + echo "
" >> $GITHUB_STEP_SUMMARY + + # STEP - Fail on playbook error + # ============================== + # Ensure the workflow fails if the playbook had errors + - name: Fail on playbook error + if: steps.ansible.outcome != 'success' + run: | + echo "::error::Ansible playbook failed. See the job summary for details." + exit 1 + + # STEP - Discconect from Wireguard + # ================================ + # Discconect from Wireguard + - name: Disconnect from Wireguard + if: always() + run: | + sudo ip link delete wg0 diff --git a/.github/workflows/updates.yml b/.github/workflows/updates.yml new file mode 100644 index 0000000..4b84561 --- /dev/null +++ b/.github/workflows/updates.yml @@ -0,0 +1,171 @@ +# This is a basic workflow to help you get started with Actions + +name: Updates + +# Controls when the workflow will run +on: + schedule: + - cron: '0 2 * * 2' + + # manually + workflow_dispatch: + +permissions: + contents: read + +# A workflow run is made up of one or more jobs that can run sequentially or in parallel +jobs: + + deploy: + runs-on: ubuntu-latest + strategy: + fail-fast: true + max-parallel: 1 + matrix: + environment: [test] + environment: ${{ matrix.environment }} + defaults: + run: + working-directory: ansible + + steps: + + # STEP - Check-out Rep + # ==================== + # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it + - name: Check-out Repo + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + # STEP - Connect to Azure + # ======================= + # This step is used to connect to Azure to allow reading of Azure KeyVault in later step + - name: Connect to Azure + uses: azure/login@532459ea530d8321f2fb9bb10d1e0bcf23869a43 # v3.0.0 + with: + creds: ${{ secrets.AZURE_SPN_CREDENTIALS }} + + # STEP - Get Azure KeyVault Secrets + # ================================= + # List all secrets in Azure KeyVault, retrieve each value, mask it, + # and export as an environment variable for the Replace Tokens step. + # Secret names are uppercased with hyphens replaced by underscores + # (e.g. 'my-secret' becomes 'MY_SECRET'). + - name: Get Azure KeyVault Secrets + run: | + SECRET_NAMES=$(az keyvault secret list --vault-name ${{ secrets.AZURE_KEYVAULT_NAME }} --query "[].name" -o tsv) + for SECRET_NAME in $SECRET_NAMES; do + SECRET_VALUE=$(az keyvault secret show --name "$SECRET_NAME" --vault-name ${{ secrets.AZURE_KEYVAULT_NAME }} --query value -o tsv) + echo "::add-mask::$SECRET_VALUE" + ENV_NAME=$(echo "$SECRET_NAME" | tr '[:lower:]-' '[:upper:]_') + echo "$ENV_NAME=$SECRET_VALUE" >> $GITHUB_ENV + done + + # STEP - Replace tokens in .yml files + # =================================== + # Replaces #{TOKEN}# patterns in files with the matching + # environment variable. Uses Python for safe handling of special + # characters in secret values (no sed/regex escaping issues). + # Change TOKEN_FILES to target different file types (e.g. '**/*.env'). + - name: Replace tokens in .yml files + env: + TOKEN_FILES: '**/*.yml' + run: | + python3 << 'EOF' + import os, glob, re, sys + + pattern = os.environ.get('TOKEN_FILES', '**/*.yml') + count = 0 + errors = 0 + + for path in glob.glob(pattern, recursive=True): + try: + with open(path, 'r') as f: + content = f.read() + + replaced = re.sub( + r'#\{([^}]+)\}#', + lambda m: os.environ.get(m.group(1), m.group(0)), + content + ) + + if replaced != content: + with open(path, 'w') as f: + f.write(replaced) + count += 1 + print(f'\u2714 {path}') + except Exception: + errors += 1 + print(f'\u2716 {path} — failed to process') + + print(f'Done — {count} file(s) updated, {errors} error(s)') + if errors > 0: + sys.exit(1) + EOF + + # STEP - Connect to Wireguard + # =========================== + # Install and connect to Wireguard + - name: Connect to Wireguard + run: | + sudo apt-get update -qq + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends resolvconf wireguard + echo "${{ secrets.WG_CONFIG_FILE }}" > wg0.conf + sudo chmod 600 wg0.conf + sudo wg-quick up ./wg0.conf + + # STEP - Add Route to NAS + # ======================== + # Add route to NAS over Wireguard connection + - name: Add Routes + run: | + sudo ip route add ${{ vars.HOST_IP }} dev wg0 || true + ip route show + ping ${{ vars.HOST_IP }} -c 5 + + # STEP - Add SSH Keys + # ==================== + # Add SSH Key used to connect to NAS, and pin the NAS's host keys + # so we refuse to talk to anything else (replaces the previous + # trust-on-first-use ssh-keyscan step, which would silently accept + # whatever host key the network handed us each run). + # NOTE: If the NAS is ever rebuilt and its /etc/ssh/ssh_host_* keys + # regenerated, update the NAS_HOST_KEY secret (environment 'nas') + # with the new fingerprints or this step will refuse to connect. + - name: Add SSH Key + run: | + mkdir -p ~/.ssh + echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_rsa + chmod 600 ~/.ssh/id_rsa + echo "${{ secrets.HOST_FINGERPRINT }}" > ~/.ssh/known_hosts + chmod 644 ~/.ssh/known_hosts + + # STEP - Configure ssh for Docker + # ================================ + # Add the following ssh configs as per docker suggestion https://docs.docker.com/engine/security/protect-access/#ssh-tips + - name: Configure SSH + run: | + { + echo "Host *" + echo " ServerAliveInterval 30" + echo " ServerAliveCountMax 10" + echo " TCPKeepAlive yes" + echo " ConnectTimeout 30" + echo " ControlMaster auto" + echo " ControlPath ~/.ssh/control-%C" + echo " ControlPersist 10m" + } >> ~/.ssh/config + chmod 600 ~/.ssh/config + + # STEP - Run Ansible Playbook + # =========================== + # Run Ansible Playbook to deploy to NAS + - name: Run Ansible Playbook + run: "ansible-playbook playbooks/updates.yml --limit ${{ vars.ENV_NAME}}" + + # STEP - Discconect from Wireguard + # ================================ + # Discconect from Wireguard + - name: Disconnect from Wireguard + if: always() + run: | + sudo ip link delete wg0 diff --git a/.github/workflows/validation.yml b/.github/workflows/validation.yml new file mode 100644 index 0000000..d061966 --- /dev/null +++ b/.github/workflows/validation.yml @@ -0,0 +1,157 @@ +# This is a basic workflow to help you get started with Actions + +name: Validation + +# Controls when the workflow will run +on: + push: + branches-ignore: + - main + + # manually + workflow_dispatch: + +permissions: + contents: read + checks: write + actions: read + security-events: write + +# A workflow run is made up of one or more jobs that can run sequentially or in parallel +jobs: + + validate: + runs-on: ubuntu-latest + defaults: + run: + working-directory: ansible + + steps: + + # STEP - Check-out Repo + # ===================== + # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it + - name: Check-out Repo + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + # STEP - Install dependencies + # =========================== + # Installs dependencies required for the validation steps + - name: Install dependencies + run: | + pip install ansible-dev-tools + ansible-galaxy collection install ansible.posix + + # STEP - YAML syntax check + # ======================== + # Performs a syntax check on all Ansible playbooks to ensure they are correctly formatted + - name: YAML syntax check + id: syntax + run: | + echo "## Syntax Check" >> $GITHUB_STEP_SUMMARY + if ansible-playbook playbooks/*.yml --syntax-check 2>&1 | tee syntax-output.txt; then + echo "syntax_result=success" >> $GITHUB_OUTPUT + echo ":white_check_mark: All playbooks passed syntax validation." >> $GITHUB_STEP_SUMMARY + else + echo "syntax_result=failure" >> $GITHUB_OUTPUT + echo ":x: Syntax errors found:" >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + cat syntax-output.txt >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + fi + echo "" >> $GITHUB_STEP_SUMMARY + + # STEP - Run ansible-lint (JSON for report) + # ========================================== + # Runs ansible-lint and captures JSON output for the summary report + - name: Run ansible-lint + id: lint + run: | + set -o pipefail + ansible-lint playbooks/ -f codeclimate --nocolor 2>/dev/null > lint-results.json || true + ansible-lint playbooks/ -f sarif --nocolor 2>/dev/null > lint-results.sarif || true + ansible-lint playbooks/ --nocolor 2>&1 | tee lint-output.txt && LINT_EXIT=0 || LINT_EXIT=$? + echo "lint_exit=$LINT_EXIT" >> $GITHUB_OUTPUT + + # STEP - Generate test report + # ============================ + # Parses lint results and writes a markdown summary to the GitHub Actions job summary + - name: Generate test report + if: always() + run: | + echo "## Ansible Lint Report" >> $GITHUB_STEP_SUMMARY + + TOTAL=$(jq 'length' lint-results.json 2>/dev/null || echo "0") + ERRORS=$(jq '[.[] | select(.severity == "major" or .severity == "critical" or .severity == "blocker")] | length' lint-results.json 2>/dev/null || echo "0") + WARNINGS=$(jq '[.[] | select(.severity == "minor")] | length' lint-results.json 2>/dev/null || echo "0") + INFO=$(jq '[.[] | select(.severity == "info")] | length' lint-results.json 2>/dev/null || echo "0") + + if [ "$TOTAL" -eq 0 ]; then + echo ":white_check_mark: **No issues found** — all checks passed!" >> $GITHUB_STEP_SUMMARY + else + echo "| Severity | Count |" >> $GITHUB_STEP_SUMMARY + echo "|----------|-------|" >> $GITHUB_STEP_SUMMARY + echo "| :x: Errors | $ERRORS |" >> $GITHUB_STEP_SUMMARY + echo "| :warning: Warnings | $WARNINGS |" >> $GITHUB_STEP_SUMMARY + echo "| :information_source: Info | $INFO |" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + echo "
:mag: View all findings ($TOTAL)" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| Rule | Severity | File | Message |" >> $GITHUB_STEP_SUMMARY + echo "|------|----------|------|---------|" >> $GITHUB_STEP_SUMMARY + jq -r '.[] | "| `\(.check_name)` | \(.severity) | `\(.location.path):\(.location.lines.begin)` | \(.description | split("\n")[0]) |"' lint-results.json >> $GITHUB_STEP_SUMMARY 2>/dev/null || true + echo "" >> $GITHUB_STEP_SUMMARY + echo "
" >> $GITHUB_STEP_SUMMARY + fi + echo "" >> $GITHUB_STEP_SUMMARY + + # Overall status badge + echo "---" >> $GITHUB_STEP_SUMMARY + SYNTAX="${{ steps.syntax.outputs.syntax_result }}" + LINT_EXIT="${{ steps.lint.outputs.lint_exit }}" + if [ "$SYNTAX" = "success" ] && [ "$LINT_EXIT" = "0" ]; then + echo "### :rocket: Overall: **PASSED**" >> $GITHUB_STEP_SUMMARY + else + echo "### :no_entry: Overall: **FAILED**" >> $GITHUB_STEP_SUMMARY + fi + + # STEP - Upload SARIF to GitHub Code Scanning + # ============================================= + # Uploads ansible-lint SARIF results so findings appear as code scanning alerts and inline PR annotations + - name: Upload SARIF to Code Scanning + if: always() + uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v3 + with: + sarif_file: ansible/lint-results.sarif + category: ansible-lint + continue-on-error: true + + # STEP - Fail if lint errors + # =========================== + # Ensures the workflow fails if ansible-lint found issues + - name: Fail on lint errors + if: steps.lint.outputs.lint_exit != '0' + run: | + echo "::error::ansible-lint found issues. See the job summary for details." + exit 1 + + # STEP - Microsoft Security DevOps + # ================================ + # Run a security check for potential misconfigurations and vulnerabilities + - name: Run Microsoft Security DevOps + uses: microsoft/security-devops-action@08976cb623803b1b36d7112d4ff9f59eae704de0 # v1.12.0 + id: msdo + with: + tools: checkov, trivy + + # STEP - Upload Microsoft Security DevOps Results + # =============================================== + # Upload the results of the Microsoft Security DevOps to the repo + - name: Upload Security SARIF + if: always() + uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v3 + with: + sarif_file: ${{ steps.msdo.outputs.sarifFile }} + category: security-devops + continue-on-error: true \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a6f4d1c --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +# Editor/IDE per-machine settings +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS files +.DS_Store +Thumbs.db +desktop.ini + +# Local WireGuard / SSH artifacts (never commit) +wg0.conf +*.pem +id_rsa +id_rsa.pub +id_ed25519 +id_ed25519.pub \ No newline at end of file diff --git a/README.md b/README.md index 952b3fb..960ebe0 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,236 @@ -# nas-as-code -A declarative, Ansible-driven approach to building and managing a home NAS — provisioning, configuration, updates, and backups all codified as Infrastructure as Code and automated with GitHub Actions. +
+ + + +### NAS-as-Code + +_... managed with Ansible and GitHub Actions_ 🤖 + +[![Validation](https://github.com/osotechie/nas-as-code/actions/workflows/validation.yml/badge.svg)](https://github.com/osotechie/nas-as-code/actions/workflows/validation.yml) [![Provision](https://github.com/osotechie/nas-as-code/actions/workflows/provision.yml/badge.svg)](https://github.com/osotechie/nas-as-code/actions/workflows/provision.yml) [![Updates](https://github.com/osotechie/nas-as-code/actions/workflows/updates.yml/badge.svg)](https://github.com/osotechie/nas-as-code/actions/workflows/updates.yml) + +
+ +--- + +## 📖 Overview + +This repo codifies the build, configuration, and ongoing management of my home NAS using [Ansible](https://www.ansible.com/) and [GitHub Actions](https://github.com/features/actions). The goal is full Infrastructure as Code (IaC) — the NAS can be provisioned from scratch, updated, and restored entirely from this repository. + +### 🏁 Project Objectives + +The main objectives of the project are: + +1. ✅ Codify the build and configuration of the base NAS + - ✅ Install the latest packages and updates + - ✅ Ensure required user accounts are present + - ✅ Ensure required folder structure is present + - ✅ Configure roles + - ✅ Docker - to allow deployment of docker containers on the NAS + - ✅ Storage - to configure / mount storage correctly for the NAS + - ✅ Samba - to allow network based access to NAS storage + - ✅ Backup - to ensure backup jobs are configured to protect data + - ✅ UPS Monitoring - to ensure NAS shuts down when UPS is running low + - ✅ Coral TPU - ensure Coral TPU is driver is installed +2. [ ] Restore NAS state from latest backup (if required) + - [ ] Restore persistent docker data from network backup + - [ ] Initiate re-deployment of Docker Stacks from [NAS-Docker](https://github.com/osotechie/nas-docker) +3. ✅ Codify on-going management tasks for the NAS + - ✅ Automatic updates and reboots (if required) + +
+ +--- + +
+ +## 🏗️ Repository Structure +
+I have created the following structure to manage this project. I have developed the structure to make to solution as modular as possible. + +``` +nas-as-code/ +├── .github/ +│ ├── dependabot.yml # Automated dependency updates for pinned Actions +│ └── workflows/ +│ ├── validation.yml # CI — lint & syntax checks on push (non-main) +│ ├── deploy.yml # CD — provision via Ansible on PR merge to main +│ └── updates.yml # Scheduled — weekly OS updates (Tue 2AM UTC) +├── ansible/ +│ ├── ansible.cfg # Ansible configuration (inventory path, roles path) +│ ├── inventory/ +│ │ ├── environments.yml # Host definitions for test & production +│ │ └── group_vars/ +│ │ ├── test.yml # Variables for the test environment +│ │ └── production.yml # Variables for the production environment +│ └── playbooks/ +│ ├── provision.yml # Full NAS provisioning playbook +│ ├── updates.yml # OS package updates & reboot if required +│ └── roles/ +│ ├── owendemooy.docker/ # Installs Docker CE & Compose +│ ├── owendemooy.storage/ # Configures MergerFS storage pool +│ ├── owendemooy.samba/ # Sets up Samba file shares +│ ├── owendemooy.nut-client/ # NUT UPS monitoring client +│ ├── owendemooy.nas-backup/ # Nightly rsync backup with WOL +│ └── owendemooy.coraltpu/ # Google Coral TPU driver & runtime +└── .gitignore +``` +
+
+ +## 🌍 Environments +
+The inventory files are used to define two environments, each with their own matching GitHub Environment for secrets / variables: +
+ +| Environment | Host | Purpose | +|-------------|------|---------| +| `test` | `nas-build-test` | Test VM to validate all changes | +| `production` | `nas` | The production NAS | + +Environment-specific configuration lives in `inventory/group_vars/{environment}.yml` and includes: +- `functions` — which roles to apply +- `apt_packages` — additional packages to install +- `users` — user accounts and SSH keys +- `host_directories` — required directories with ACLs +- `samba_users` / `samba_shares` — Samba configuration + +
+
+ +## 🔧 Playbooks +
+The heavy lifting for the actual provisioning and updating is done using Ansible Playbooks. I have create two main Playbooks for this project to handle the provison and updates of the NAS. + + +### 🏗️ Provision + +The `provision.yml` playbook performs a full host setup: + +1. **Packages** — Upgrades all packages and installs additional required packages +2. **Host entries** — Ensures `/etc/hosts` contains all managed hosts +3. **Users** — Creates required user accounts and configures SSH authorized keys +4. **Directories** — Creates required directory structure with correct ACLs +5. **Roles** — Conditionally applies roles based on the `functions` variable: + + | Role | Function | Purpose | + |------|----------|---------| + | `owendemooy.docker` | `docker` | Docker CE & Compose installation | + | `owendemooy.storage` | `storage` | MergerFS disk pool configuration | + | `owendemooy.samba` | `samba` | SMB file sharing | + | `owendemooy.nut-client` | `nut-client` | UPS monitoring via NUT | + | `owendemooy.nas-backup` | `nas-backup` | Nightly rsync backup with wake-on-LAN | + | `owendemooy.coraltpu` | `coraltpu` | Google Coral Edge TPU drivers | + +
+ +### ♻️ Updates + +The `updates.yml` playbook handles routine OS maintenance: + +1. **Package upgrade** — Installs the latest available versions of all installed packages via `apt upgrade` +2. **Reboot check** — Inspects `/var/run/reboot-required` to determine if a restart is needed (e.g. kernel update) +3. **Safe reboot** — If required, reboots the host and waits for it to come back online (up to 5 minutes) + +
+
+ +## 🚀 GitHub Actions +
+I am using GitHub Actions to control the validation, provisioning and updates to my NAS. The GitHub actions have been designed to validate and test all changes again the test environment, before applying any changes to Production. +
+ +### Validation (CI) + +**File:** `.github/workflows/validation.yml` +**Trigger:** Any push to a non-main branch, or manual dispatch. + +| Step | Description | +|------|-------------| +| Install dependencies | Installs `ansible-dev-tools` and the `ansible.posix` collection | +| Syntax check | Runs `ansible-playbook --syntax-check` on all playbooks | +| Ansible Lint | Lints playbooks and roles using Code Climate format for reporting | +| Test Report | Generates a markdown summary with findings table viewable on the Actions run page | +| SARIF Upload | Publishes lint findings to GitHub Code Scanning (inline PR annotations) | +| Fail gate | Fails the workflow if any lint violations are found | +| Microsoft Security DevOps | Scans with Checkov and Trivy for security misconfigurations and vulnerabilities | +| Upload Security SARIF | Publishes security findings to GitHub Code Scanning | + +
+ +### Provision (CD) + +**File:** `.github/workflows/provision.yml` +**Trigger:** Pull request merged to `main` (ignoring markdown-only changes). + +Deploys sequentially to **test** then **production** using a matrix strategy (`fail-fast: true`, `max-parallel: 1`). If test fails, production is skipped. + +| Step | Description | +|------|-------------| +| Azure Login | Authenticates to Azure for KeyVault access | +| Get Secrets | Pulls secrets from Azure KeyVault into the runner environment | +| Replace Tokens | Substitutes `#{TOKEN}#` placeholders in config files with real values | +| WireGuard VPN | Connects to home network via WireGuard tunnel | +| Add Routes | Adds a route to the target host over WireGuard | +| SSH Setup | Configures SSH keys, pinned host fingerprints, and keepalive settings | +| Ansible Playbook | Runs `provision.yml --limit {env}` against the target host | +| Deployment Report | Parses PLAY RECAP into a summary table with collapsible full log | +| Disconnect | Tears down the WireGuard tunnel (always runs) | + +
+ +### Updates (Scheduled) + +**File:** `.github/workflows/updates.yml` +**Trigger:** Scheduled every Tuesday at 02:00 UTC (`cron: '0 2 * * 2'`), or manual dispatch. + +Runs the `updates.yml` playbook against the **test** environment to apply OS package updates safely. + +| Step | Description | +|------|-------------| +| Azure Login | Authenticates to Azure for KeyVault access | +| Get Secrets | Pulls secrets from Azure KeyVault into the runner environment | +| Replace Tokens | Substitutes `#{TOKEN}#` placeholders in config files | +| WireGuard VPN | Connects to home network via WireGuard tunnel | +| Add Routes | Adds a route to the target host over WireGuard | +| SSH Setup | Configures SSH keys and pinned host fingerprints | +| Ansible Playbook | Runs `updates.yml --limit {env}` to apply updates | + + +
+
+ +## 🤐 Secrets & Variables +
+ +To avoid storing secrets in any files I use a combination of environment variables GitHub Secrets, and Azure KeyVault. + +Secrets are never stored in the repository. The workflow uses a two-layer approach: + +1. **GitHub Environment Secrets** — SSH keys, WireGuard config, host fingerprints +2. **Azure KeyVault** — All application secrets (Samba passwords, SSH public keys, etc.) + +Secrets are injected at deploy time using token replacement: +- Config files contain `#{TOKEN}#` placeholders +- The [Replace Tokens](https://github.com/marketplace/actions/replace-tokens) action substitutes them with matching environment variables sourced from KeyVault + +
+
+ +## 🔒 Security +
+ +- All GitHub Actions are **pinned to commit SHAs** to prevent supply-chain attacks +- [Dependabot](https://docs.github.com/en/code-security/dependabot) monitors for new versions weekly +- SSH connections use **pinned host fingerprints** (no trust-on-first-use) +- The runner connects via **WireGuard VPN** — no ports exposed to the internet +- Azure SPN credentials use scoped access to a single KeyVault + +
+
+ + +## 📎 Related Repos + +| Repo | Purpose | +|------|---------| +| [nas-docker](https://github.com/osotechie/nas-docker) | Docker Compose stacks deployed on the NAS | diff --git a/ansible/ansible.cfg b/ansible/ansible.cfg new file mode 100644 index 0000000..5d0d6e3 --- /dev/null +++ b/ansible/ansible.cfg @@ -0,0 +1,4 @@ +[defaults] +inventory = inventory/environments.yml +roles_path = playbooks/roles/ +host_key_checking = False diff --git a/ansible/inventory/environments.yml b/ansible/inventory/environments.yml new file mode 100644 index 0000000..2382a87 --- /dev/null +++ b/ansible/inventory/environments.yml @@ -0,0 +1,12 @@ +--- +test: + hosts: + nas-build-test: + ansible_host: 10.1.1.118 + ansible_user: owen + +production: + hosts: + nas: + ansible_host: 10.1.1.118 + ansible_user: owen diff --git a/ansible/inventory/group_vars/production.yml b/ansible/inventory/group_vars/production.yml new file mode 100644 index 0000000..ba9e173 --- /dev/null +++ b/ansible/inventory/group_vars/production.yml @@ -0,0 +1,116 @@ +--- +functions: + - docker + - samba + - storage + - nut-client + - backup + - coraltpu + +# apt_packages - variable used to define what additional packages are required +apt_packages: + - acl + - wakeonlan + +# users - variable used to create the required users on host +users: + smb: + displayname: Samba User + groups: + key: + argus: + displayname: Argus (Agent) + groups: "sudo,docker" + key: #{SSH_PUBKEY_ARGUS}# + + +# host_directories - variable used to create the required folders on host +host_directories: + config: + path: /config + acls: + - user::rwx + - group::rwx + - other::rwx + - default:user::rwx + - default:group::rwx + - default:other::rwx + docker: + path: /docker + acls: + - user::rwx + - group::rwx + - other::rwx + - default:user::rwx + - default:group::rwx + - default:other::rwx + storage: + path: /mnt/storage + acls: + - user::rwx + - group::rwx + - other::rwx + - default:user::rwx + - default:group::rwx + - default:other::rwx + + +# samba - variable used to configure the required samba users and shares +samba_users: + smb: + password: #{SAMBA_SMB_PASSWORD}# +samba_shares: + config: + path: "/config" + users: + - "smb" + available: "yes" + browsable: "yes" + readonly: "no" + writable: "yes" + docker: + path: "/docker" + users: + - "smb" + available: "yes" + browsable: "yes" + readonly: "no" + writable: "yes" + storage: + path: "/mnt/storage" + users: + - "smb" + available: "yes" + browsable: "yes" + readonly: "no" + writable: "yes" + +# storage - variable used to configure directories and mounts for storage +storage_disks: + disk1: + path: /mnt/array/disk1 + src: LABEL=ARRAY-DISK1 + fstype: ext4 + disk2: + path: /mnt/array/disk2 + src: LABEL=ARRAY-DISK2 + fstype: ext4 + disk3: + path: /mnt/array/disk3 + src: LABEL=ARRAY-DISK3 + fstype: ext4 + disk4: + path: /mnt/array/disk4 + src: LABEL=ARRAY-DISK4 + fstype: ext4 + disk5: + path: /mnt/array/parity1 + src: LABEL=ARRAY-PARITY1 + fstype: ext4 +storage_mergerfs: + storage: + path: /mnt/storage + src: /mnt/array/disk* + fstype: fuse.mergerfs + opts: defaults,nonempty,allow_other,use_ino,cache.files=off,moveonenospc=true,category.create=mfs,dropcacheonclose=true,minfreespace=1G,posix_acl=true,fsname=MergerFS + diff --git a/ansible/inventory/group_vars/test.yml b/ansible/inventory/group_vars/test.yml new file mode 100644 index 0000000..ba9e173 --- /dev/null +++ b/ansible/inventory/group_vars/test.yml @@ -0,0 +1,116 @@ +--- +functions: + - docker + - samba + - storage + - nut-client + - backup + - coraltpu + +# apt_packages - variable used to define what additional packages are required +apt_packages: + - acl + - wakeonlan + +# users - variable used to create the required users on host +users: + smb: + displayname: Samba User + groups: + key: + argus: + displayname: Argus (Agent) + groups: "sudo,docker" + key: #{SSH_PUBKEY_ARGUS}# + + +# host_directories - variable used to create the required folders on host +host_directories: + config: + path: /config + acls: + - user::rwx + - group::rwx + - other::rwx + - default:user::rwx + - default:group::rwx + - default:other::rwx + docker: + path: /docker + acls: + - user::rwx + - group::rwx + - other::rwx + - default:user::rwx + - default:group::rwx + - default:other::rwx + storage: + path: /mnt/storage + acls: + - user::rwx + - group::rwx + - other::rwx + - default:user::rwx + - default:group::rwx + - default:other::rwx + + +# samba - variable used to configure the required samba users and shares +samba_users: + smb: + password: #{SAMBA_SMB_PASSWORD}# +samba_shares: + config: + path: "/config" + users: + - "smb" + available: "yes" + browsable: "yes" + readonly: "no" + writable: "yes" + docker: + path: "/docker" + users: + - "smb" + available: "yes" + browsable: "yes" + readonly: "no" + writable: "yes" + storage: + path: "/mnt/storage" + users: + - "smb" + available: "yes" + browsable: "yes" + readonly: "no" + writable: "yes" + +# storage - variable used to configure directories and mounts for storage +storage_disks: + disk1: + path: /mnt/array/disk1 + src: LABEL=ARRAY-DISK1 + fstype: ext4 + disk2: + path: /mnt/array/disk2 + src: LABEL=ARRAY-DISK2 + fstype: ext4 + disk3: + path: /mnt/array/disk3 + src: LABEL=ARRAY-DISK3 + fstype: ext4 + disk4: + path: /mnt/array/disk4 + src: LABEL=ARRAY-DISK4 + fstype: ext4 + disk5: + path: /mnt/array/parity1 + src: LABEL=ARRAY-PARITY1 + fstype: ext4 +storage_mergerfs: + storage: + path: /mnt/storage + src: /mnt/array/disk* + fstype: fuse.mergerfs + opts: defaults,nonempty,allow_other,use_ino,cache.files=off,moveonenospc=true,category.create=mfs,dropcacheonclose=true,minfreespace=1G,posix_acl=true,fsname=MergerFS + diff --git a/ansible/playbooks/provision.yml b/ansible/playbooks/provision.yml new file mode 100644 index 0000000..2c2c506 --- /dev/null +++ b/ansible/playbooks/provision.yml @@ -0,0 +1,104 @@ +--- +- name: Provision Hosts + hosts: all + become: true + + tasks: + + # PACKAGES - Ensure latest packages are installed + - name: Packages - Ensure latest packages are installed. + ansible.builtin.apt: + upgrade: true + + - name: Packages - Ensure additional packages are installed. + ansible.builtin.apt: + pkg: "{{ apt_packages }}" + + # HOST ENTRIES - Ensure Host Entries are present + - name: Hosts Entries - Ensuring the /etc/hosts file contains the required entries. + ansible.builtin.lineinfile: + dest: /etc/hosts + regexp: '.*{{ item }}$' + line: "{{ hostvars[item].ansible_host }} {{ item }}" + state: present + when: hostvars[item].ansible_host is defined + with_items: "{{ ansible_play_hosts }}" + + # USERS - Ensure the required users are present + - name: Ensure the users exists. + ansible.builtin.user: + name: "{{ item.key }}" + comment: "{{ item.value.displayname }}" + create_home: true + with_items: "{{ users | dict2items }}" + no_log: true + + # USERS - Add SSH Authorized Keys for the required users + - name: Ensure the users SSH Authorized Keys are present. + ansible.posix.authorized_key: + user: "{{ item.key }}" + key: "{{ item.value.key }}" + with_items: "{{ users | dict2items }}" + no_log: true + + # DIRECTORIES - Ensure required directories are present + - name: Directories - Ensure required directories exist. + ansible.builtin.file: + path: "{{ item.value.path }}" + state: directory + mode: "0755" + with_items: "{{ host_directories | dict2items }}" + + - name: Directories - Ensure require directories ACLs are correct. + ansible.posix.acl: + path: "{{ item.0.path }}" + entry: "{{ item.1 }}" + state: present + loop: "{{ host_directories | subelements('acls') }}" + + # ROLES - Ensure Docker Role has been provisioned. + - name: Roles - Ensure Docker Role has been provisioned. + ansible.builtin.import_role: + name: owendemooy.docker + when: '"docker" in functions' + + # ROLES - Ensure Storage Role has been provisioned. + - name: Roles - Ensure Storage Server Role has been provisioned. + ansible.builtin.import_role: + name: owendemooy.storage + when: '"storage" in functions' + + # ROLES - Ensure Samba Role has been provisioned. + - name: Roles - Ensure Samba Role has been provisioned. + ansible.builtin.import_role: + name: owendemooy.samba + when: '"samba" in functions' + + # ROLES - Ensure NUT Client Role has been provisioned. + - name: Roles - Ensure NUT Client Role has been provisioned. + ansible.builtin.import_role: + name: owendemooy.nut-client + when: '"nut-client" in functions' + + # ROLES - Ensure Backup Role has been provisioned. + - name: Roles - Ensure NAS Backup Role has been provisioned. + ansible.builtin.import_role: + name: owendemooy.nas-backup + when: '"nas-backup" in functions' + + # ROLES - Ensure Coral TPU Role has been provisioned. + - name: Roles - Ensure Coral TPU Role has been provisioned. + ansible.builtin.import_role: + name: owendemooy.coraltpu + when: '"coraltpu" in functions' + + # USERS - Ensure the required users are in the correct groups + - name: Ensure the users exists. + ansible.builtin.user: + name: "{{ item.key }}" + comment: "{{ item.value.displayname }}" + groups: "{{ item.value.groups }}" + create_home: true + append: true + with_items: "{{ users | dict2items }}" + no_log: true diff --git a/ansible/playbooks/roles/owendemooy.coraltpu/files/gasket-dkms_1.0-18_all.deb b/ansible/playbooks/roles/owendemooy.coraltpu/files/gasket-dkms_1.0-18_all.deb new file mode 100644 index 0000000000000000000000000000000000000000..8a76e6b3eab756670bbd3a6c9ffe4b512d323904 GIT binary patch literal 48994 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¾twOW(IhXLJMT_ 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 z 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 + MONITOR {{ nut_client_ups }}@{{ nut_client_server }} 1 + {{ nut_client_username }} {{ nut_client_password }} + {{ nut_client_type | default('secondary') }} + state: present + mode: "0640" + notify: Restart nut-client + +- name: Change NUT configuration. + ansible.builtin.lineinfile: + path: "{{ nut_config_path }}/nut.conf" + regexp: '^MODE.+' + line: "MODE=netclient" + state: present + mode: "0640" + +- name: Ensure NUT Client is running (if configured). + ansible.builtin.service: + name: "{{ nut_client_service }}" + state: "{{ nut_client_state }}" + enabled: "{{ nut_client_enabled }}" + when: not ansible_check_mode diff --git a/ansible/playbooks/roles/owendemooy.samba/defaults/main.yml b/ansible/playbooks/roles/owendemooy.samba/defaults/main.yml new file mode 100644 index 0000000..e48d578 --- /dev/null +++ b/ansible/playbooks/roles/owendemooy.samba/defaults/main.yml @@ -0,0 +1,4 @@ +--- + +smb_conf: /etc/samba/smb.conf +smb_daemon: smbd.service diff --git a/ansible/playbooks/roles/owendemooy.samba/handlers/main.yml b/ansible/playbooks/roles/owendemooy.samba/handlers/main.yml new file mode 100644 index 0000000..adc8b57 --- /dev/null +++ b/ansible/playbooks/roles/owendemooy.samba/handlers/main.yml @@ -0,0 +1,17 @@ +--- + +- name: Restart Samba + ansible.builtin.systemd: + name: "{{ smb_daemon }}" + state: restarted + +# Whenever you modify smb.conf you should run the command "testparm" +# to check that you have not made any basic syntactic errors. +- name: Testparm + ansible.builtin.command: > + testparm --suppress-prompt + register: samba_testparm + changed_when: false + # output should contain the line: + # Loaded services file OK. + failed_when: samba_testparm.stderr_lines is not search("Loaded services file OK.") diff --git a/ansible/playbooks/roles/owendemooy.samba/meta/main.yml b/ansible/playbooks/roles/owendemooy.samba/meta/main.yml new file mode 100644 index 0000000..3cec659 --- /dev/null +++ b/ansible/playbooks/roles/owendemooy.samba/meta/main.yml @@ -0,0 +1,53 @@ +--- +galaxy_info: + author: owendemooy + description: Provision Samba + # company: your company (optional) + + # If the issue tracker for your role is not on github, uncomment the + # next line and provide a value + # issue_tracker_url: http://example.com/issue/tracker + + # Choose a valid license ID from https://spdx.org - some suggested licenses: + # - BSD-3-Clause (default) + # - MIT + # - GPL-2.0-or-later + # - GPL-3.0-only + # - Apache-2.0 + # - CC-BY-4.0 + license: MIT + + min_ansible_version: "2.1" + + # If this a Container Enabled role, provide the minimum Ansible Container version. + # min_ansible_container_version: + + # + # Provide a list of supported platforms, and for each platform a list of versions. + # If you don't wish to enumerate all versions for a particular platform, use 'all'. + # To view available platforms and versions (or releases), visit: + # https://galaxy.ansible.com/api/v1/platforms/ + # + # platforms: + # - name: Fedora + # versions: + # - all + # - 25 + # - name: SomePlatform + # versions: + # - all + # - 1.0 + # - 7 + # - 99.99 + + galaxy_tags: [] + # List tags for your role here, one per line. A tag is a keyword that describes + # and categorizes the role. Users find roles by searching for tags. Be sure to + # remove the '[]' above, if you add tags to this list. + # + # NOTE: A tag is limited to a single word comprised of alphanumeric characters. + # Maximum 20 tags per role. + +dependencies: [] + # List your role dependencies here, one per line. Be sure to remove the '[]' above, + # if you add dependencies to this list. diff --git a/ansible/playbooks/roles/owendemooy.samba/tasks/main.yml b/ansible/playbooks/roles/owendemooy.samba/tasks/main.yml new file mode 100644 index 0000000..eba90b5 --- /dev/null +++ b/ansible/playbooks/roles/owendemooy.samba/tasks/main.yml @@ -0,0 +1,44 @@ +--- +# tasks file for owendemooy.samba +- name: Ensure Samba related packages are installed. + ansible.builtin.apt: + name: + - samba + - samba-common + - cifs-utils + - wsdd-server + state: present + +- name: Ensure Samba is running and set to start on boot. + ansible.builtin.service: + name: "smbd.service" + state: started + enabled: true + +- name: Configure Samba share(s) + ansible.builtin.blockinfile: + path: "{{ smb_conf }}" + backup: true + marker: "# {mark} ANSIBLE MANAGED BLOCK for {{ item.key }}" + insertafter: "#===== Share Definitions =====" + block: | + [{{ item.key }}] + path = "{{ item.value.path }}" + available = {{ item.value.available }} + valid users = {{ item.value.users | join(" ") }} + browsable = {{ item.value.browsable }} + read only = {{ item.value.readonly }} + writable = {{ item.value.writable }} + with_items: "{{ samba_shares | dict2items }}" + notify: Restart Samba + +- name: Set Samba password for samba users + ansible.builtin.expect: + command: smbpasswd -a {{ item.key }} + timeout: 3 + responses: + 'New SMB password': "{{ item.value.password }}" + 'Retype new SMB password': "{{ item.value.password }}" + with_items: "{{ samba_users | dict2items }}" + no_log: true + notify: Restart Samba diff --git a/ansible/playbooks/roles/owendemooy.storage/meta/main.yml b/ansible/playbooks/roles/owendemooy.storage/meta/main.yml new file mode 100644 index 0000000..282ef8c --- /dev/null +++ b/ansible/playbooks/roles/owendemooy.storage/meta/main.yml @@ -0,0 +1,53 @@ +--- +galaxy_info: + author: owendemooy + description: Provision Storage + # company: your company (optional) + + # If the issue tracker for your role is not on github, uncomment the + # next line and provide a value + # issue_tracker_url: http://example.com/issue/tracker + + # Choose a valid license ID from https://spdx.org - some suggested licenses: + # - BSD-3-Clause (default) + # - MIT + # - GPL-2.0-or-later + # - GPL-3.0-only + # - Apache-2.0 + # - CC-BY-4.0 + license: MIT + + min_ansible_version: "2.1" + + # If this a Container Enabled role, provide the minimum Ansible Container version. + # min_ansible_container_version: + + # + # Provide a list of supported platforms, and for each platform a list of versions. + # If you don't wish to enumerate all versions for a particular platform, use 'all'. + # To view available platforms and versions (or releases), visit: + # https://galaxy.ansible.com/api/v1/platforms/ + # + # platforms: + # - name: Fedora + # versions: + # - all + # - 25 + # - name: SomePlatform + # versions: + # - all + # - 1.0 + # - 7 + # - 99.99 + + galaxy_tags: [] + # List tags for your role here, one per line. A tag is a keyword that describes + # and categorizes the role. Users find roles by searching for tags. Be sure to + # remove the '[]' above, if you add tags to this list. + # + # NOTE: A tag is limited to a single word comprised of alphanumeric characters. + # Maximum 20 tags per role. + +dependencies: [] + # List your role dependencies here, one per line. Be sure to remove the '[]' above, + # if you add dependencies to this list. diff --git a/ansible/playbooks/roles/owendemooy.storage/tasks/main.yml b/ansible/playbooks/roles/owendemooy.storage/tasks/main.yml new file mode 100644 index 0000000..76eaff7 --- /dev/null +++ b/ansible/playbooks/roles/owendemooy.storage/tasks/main.yml @@ -0,0 +1,28 @@ +--- +# tasks file for owendemooy.storage +- name: Ensure Storage related packages are installed. + ansible.builtin.apt: + name: + - fuse + - mergerfs + - mtools + state: present + +- name: Ensure Storage disks are mounted. + ansible.posix.mount: + path: "{{ item.value.path }}" + src: "{{ item.value.src }}" + fstype: "{{ item.value.fstype }}" + state: mounted + with_items: "{{ storage_disks | dict2items }}" + when: storage_disks is defined + +- name: Ensure MergerFS fuses are mounted. + ansible.posix.mount: + path: "{{ item.value.path }}" + src: "{{ item.value.src }}" + fstype: "{{ item.value.fstype }}" + opts: "{{ item.value.opts }}" + state: mounted + with_items: "{{ storage_mergerfs | dict2items }}" + when: storage_mergerfs is defined diff --git a/ansible/playbooks/updates.yml b/ansible/playbooks/updates.yml new file mode 100644 index 0000000..5a8829e --- /dev/null +++ b/ansible/playbooks/updates.yml @@ -0,0 +1,30 @@ +--- +- name: Update Hosts + hosts: all + become: true + + tasks: + + # PACKAGES - Ensure latest packages are installed + - name: Packages - Ensure latest packages are installed. + ansible.builtin.apt: + upgrade: full + update_cache: true + + # Check if a system reboot is required + - name: Check if a system reboot is required + ansible.builtin.stat: + path: /var/run/reboot-required + get_checksum: false + register: reboot_required_file + + # REBOOT - Check if reboot is required, and if so, reboot the server safely + - name: Reboot the server safely + ansible.builtin.reboot: + msg: "Reboot initiated by Ansible after package updates" + connect_timeout: 5 + reboot_timeout: 300 + pre_reboot_delay: 0 + post_reboot_delay: 30 + test_command: uptime + when: reboot_required_file.stat.exists From fb1319d28620ea4969b6cf5c76a1ae6aeb7adf25 Mon Sep 17 00:00:00 2001 From: Osotechie Date: Mon, 22 Jun 2026 19:49:19 +1200 Subject: [PATCH 2/3] update variables to use tokens after changing nut username and password to avoid leaking secrets --- ansible/inventory/group_vars/production.yml | 5 +++++ ansible/inventory/group_vars/test.yml | 5 +++++ .../roles/owendemooy.nut-client/defaults/main.yml | 8 ++++---- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/ansible/inventory/group_vars/production.yml b/ansible/inventory/group_vars/production.yml index ba9e173..393d083 100644 --- a/ansible/inventory/group_vars/production.yml +++ b/ansible/inventory/group_vars/production.yml @@ -114,3 +114,8 @@ storage_mergerfs: fstype: fuse.mergerfs opts: defaults,nonempty,allow_other,use_ino,cache.files=off,moveonenospc=true,category.create=mfs,dropcacheonclose=true,minfreespace=1G,posix_acl=true,fsname=MergerFS +# nut-client - variable used to configure the nut-client role +nut_client_ups: dynamix +nut_client_server: 10.1.1.3 +nut_client_username: #{NUT_CLIENT_USERNAME}# +nut_client_password: #{NUT_CLIENT_PASSWORD}# \ No newline at end of file diff --git a/ansible/inventory/group_vars/test.yml b/ansible/inventory/group_vars/test.yml index ba9e173..393d083 100644 --- a/ansible/inventory/group_vars/test.yml +++ b/ansible/inventory/group_vars/test.yml @@ -114,3 +114,8 @@ storage_mergerfs: fstype: fuse.mergerfs opts: defaults,nonempty,allow_other,use_ino,cache.files=off,moveonenospc=true,category.create=mfs,dropcacheonclose=true,minfreespace=1G,posix_acl=true,fsname=MergerFS +# nut-client - variable used to configure the nut-client role +nut_client_ups: dynamix +nut_client_server: 10.1.1.3 +nut_client_username: #{NUT_CLIENT_USERNAME}# +nut_client_password: #{NUT_CLIENT_PASSWORD}# \ No newline at end of file diff --git a/ansible/playbooks/roles/owendemooy.nut-client/defaults/main.yml b/ansible/playbooks/roles/owendemooy.nut-client/defaults/main.yml index a5d384d..0c5f0b2 100644 --- a/ansible/playbooks/roles/owendemooy.nut-client/defaults/main.yml +++ b/ansible/playbooks/roles/owendemooy.nut-client/defaults/main.yml @@ -2,10 +2,10 @@ nut_config_path: /etc/nut nut_client_service: nut-client -nut_client_ups: dynamix -nut_client_server: 10.1.1.3 -nut_client_username: user -nut_client_password: ups +nut_client_ups: ups +nut_client_server: 0.0.0.0 +nut_client_username: +nut_client_password: nut_client_type: secondary nut_client_state: started From f64be7df9233af097c70301f35c5922eae51738e Mon Sep 17 00:00:00 2001 From: Osotechie Date: Mon, 22 Jun 2026 19:52:52 +1200 Subject: [PATCH 3/3] fix validation warnings on nut-client\defaults\mail.yml --- .../playbooks/roles/owendemooy.nut-client/defaults/main.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ansible/playbooks/roles/owendemooy.nut-client/defaults/main.yml b/ansible/playbooks/roles/owendemooy.nut-client/defaults/main.yml index 0c5f0b2..ce1461c 100644 --- a/ansible/playbooks/roles/owendemooy.nut-client/defaults/main.yml +++ b/ansible/playbooks/roles/owendemooy.nut-client/defaults/main.yml @@ -4,8 +4,8 @@ nut_client_service: nut-client nut_client_ups: ups nut_client_server: 0.0.0.0 -nut_client_username: -nut_client_password: +nut_client_username: +nut_client_password: nut_client_type: secondary nut_client_state: started