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..393d083 --- /dev/null +++ b/ansible/inventory/group_vars/production.yml @@ -0,0 +1,121 @@ +--- +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 + +# 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 new file mode 100644 index 0000000..393d083 --- /dev/null +++ b/ansible/inventory/group_vars/test.yml @@ -0,0 +1,121 @@ +--- +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 + +# 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/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 0000000..8a76e6b Binary files /dev/null and b/ansible/playbooks/roles/owendemooy.coraltpu/files/gasket-dkms_1.0-18_all.deb differ diff --git a/ansible/playbooks/roles/owendemooy.coraltpu/meta/main.yml b/ansible/playbooks/roles/owendemooy.coraltpu/meta/main.yml new file mode 100644 index 0000000..17c7bee --- /dev/null +++ b/ansible/playbooks/roles/owendemooy.coraltpu/meta/main.yml @@ -0,0 +1,53 @@ +--- +galaxy_info: + author: owendemooy + description: Coral TPU + # 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.coraltpu/tasks/main.yml b/ansible/playbooks/roles/owendemooy.coraltpu/tasks/main.yml new file mode 100644 index 0000000..7495d9e --- /dev/null +++ b/ansible/playbooks/roles/owendemooy.coraltpu/tasks/main.yml @@ -0,0 +1,32 @@ +--- +# tasks file for owendemooy.coraltpu + +- name: Gather the package facts + ansible.builtin.package_facts: + manager: auto + +- name: Copy Gasket Driver to the host + ansible.builtin.copy: + src: "{{ role_path }}/files/gasket-dkms_1.0-18_all.deb" + dest: /tmp/gasket-dkms_1.0-18_all.deb + mode: "0644" + when: "'gasket-dkms' not in ansible_facts.packages" + +- name: Install Gasket Driver + ansible.builtin.apt: + deb: /tmp/gasket-dkms_1.0-18_all.deb + state: present + when: "'gasket-dkms' not in ansible_facts.packages" + +- name: Ensure Google Cloud APT Key and Repository have been added. + ansible.builtin.deb822_repository: + name: CorelEdgeTPU + uris: https://packages.cloud.google.com/apt + suites: 'coral-edgetpu-stable' + components: main + signed_by: https://packages.cloud.google.com/apt/doc/apt-key.gpg + +- name: Ensure Coral Edge TPU is installed. + ansible.builtin.apt: + name: [libedgetpu1-std] + update_cache: true diff --git a/ansible/playbooks/roles/owendemooy.docker/meta/main.yml b/ansible/playbooks/roles/owendemooy.docker/meta/main.yml new file mode 100644 index 0000000..55b71df --- /dev/null +++ b/ansible/playbooks/roles/owendemooy.docker/meta/main.yml @@ -0,0 +1,53 @@ +--- +galaxy_info: + author: owendemooy + description: Provision Docker + # 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.docker/tasks/main.yml b/ansible/playbooks/roles/owendemooy.docker/tasks/main.yml new file mode 100644 index 0000000..2cf0d0c --- /dev/null +++ b/ansible/playbooks/roles/owendemooy.docker/tasks/main.yml @@ -0,0 +1,18 @@ +--- +# tasks file for owendemooy.docker +- name: Ensure required packages are installed. + ansible.builtin.apt: + name: [ca-certificates, curl, gnupg] + +- name: Ensure Docker GOG Apt Key and Repositrory has been addded. + ansible.builtin.deb822_repository: + name: Docker + uris: https://download.docker.com/linux/ubuntu + suites: "{{ ansible_facts['distribution_release'] }}" + components: stable + signed_by: https://download.docker.com/linux/ubuntu/gpg + +- name: Ensure Docker is installed. + ansible.builtin.apt: + name: [docker-ce, docker-ce-cli, containerd.io, docker-compose, docker-compose-plugin] + update_cache: true diff --git a/ansible/playbooks/roles/owendemooy.nas-backup/defaults/main.yml b/ansible/playbooks/roles/owendemooy.nas-backup/defaults/main.yml new file mode 100644 index 0000000..d9f8b24 --- /dev/null +++ b/ansible/playbooks/roles/owendemooy.nas-backup/defaults/main.yml @@ -0,0 +1,8 @@ +--- +backup_directories: + backup: + path: /mnt/backup + snapshot: + path: /mnt/backup/snapshot + remote: + path: /mnt/backup/remote diff --git a/ansible/playbooks/roles/owendemooy.nas-backup/files/backup.sh b/ansible/playbooks/roles/owendemooy.nas-backup/files/backup.sh new file mode 100644 index 0000000..c2b64eb --- /dev/null +++ b/ansible/playbooks/roles/owendemooy.nas-backup/files/backup.sh @@ -0,0 +1,59 @@ +#!/bin/bash +#Purpose = Backup Docker Container Data +#Version 1.0 +#START + +LVM_VG=ubuntu-vg # Define name of the LVM Group to create snapshot from +LVM_LV=$LVM_VG/ubuntu-lv # Define name of the LVM Volume to create snapshot from +SNAPSHOT=BACKUP # Name for the Snapshot (note: cannot use SNAPSHOT as resevered word) +SNAPSHOT_SIZE=100G # Size to allocate for Snapshot +SNAPSHOT_DEV=/dev/$LVM_VG/$SNAPSHOT # Device path to the snapshot +SNAPSHOT_MNT=/mnt/backup/snapshot # Mount point for the snapshot + +FILENAME=NAS-Backup.tar.gz # Define Backup file name +SRC_DIR=docker # Location of Data to be backed up +DES_DIR=/mnt/storage/backups/nas # Destination of backup file +STACKS=/config/stacks # Define path to dir containing subdirs for each docker stack + +EXCLUDE_DIR="$SRC_DIR/media/plex/Library/Application Support/Plex Media Server/Cache" + +echo $(date)' Starting Backup' +echo $(date)' -----------------------------------------------------------------------------------' +echo $(date)" Excluded Directories: $EXCLUDE_DIR" + +#Stop Docker Containers to allow clean backup +echo $(date)' Stopping Container' +for dir in $STACKS/*/; do + cd "$dir" + docker compose stop + cd .. +done + +#Create Snapshot to backup data from +echo $(date)' Creating Snapshot' +lvcreate -s -n $SNAPSHOT -L $SNAPSHOT_SIZE $LVM_LV +mount $SNAPSHOT_DEV $SNAPSHOT_MNT + +#Re-start Docker Containers now we have a snapshot to work from +for dir in $STACKS/*/; do + cd "$dir" + docker compose up -d + cd .. +done + +#Backup data +echo $(date)' Backing up persistant data for Containers' +echo tar --exclude="$EXCLUDE_DIR" -cpzf $DES_DIR/$FILENAME -C $SNAPSHOT_MNT $SRC_DIR +tar --exclude="$EXCLUDE_DIR" -cpzf $DES_DIR/$FILENAME -C $SNAPSHOT_MNT $SRC_DIR + +#Remove snapshot +echo $(date)' Removing Snapshot' +umount $SNAPSHOT_MNT +lvremove $LVM_VG/$SNAPSHOT -f + +#Unmount external cifs share +#echo $(date)' Unmounting Backup Share' +#umount $DES_DIR + +#END + diff --git a/ansible/playbooks/roles/owendemooy.nas-backup/files/restore.sh b/ansible/playbooks/roles/owendemooy.nas-backup/files/restore.sh new file mode 100644 index 0000000..1a6fc5c --- /dev/null +++ b/ansible/playbooks/roles/owendemooy.nas-backup/files/restore.sh @@ -0,0 +1,14 @@ +#!/bin/bash +#Purpose = Restore Docker Container Data +#Version 1.0 +#START + +FILENAME=NAS-Backup.tar.gz # Define Backup file name +DES_DIR=/mnt/storage/backups/nas # Destination of backup file + + +#Backup Container data +echo 'Restoring persistant data for Containers' +tar xvzf $DES_DIR/$FILENAME -C / + +#END diff --git a/ansible/playbooks/roles/owendemooy.nas-backup/meta/main.yml b/ansible/playbooks/roles/owendemooy.nas-backup/meta/main.yml new file mode 100644 index 0000000..d0a4e75 --- /dev/null +++ b/ansible/playbooks/roles/owendemooy.nas-backup/meta/main.yml @@ -0,0 +1,53 @@ +--- +galaxy_info: + author: owendemooy + description: Provision Backup + # 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.nas-backup/tasks/main.yml b/ansible/playbooks/roles/owendemooy.nas-backup/tasks/main.yml new file mode 100644 index 0000000..91ac8a4 --- /dev/null +++ b/ansible/playbooks/roles/owendemooy.nas-backup/tasks/main.yml @@ -0,0 +1,29 @@ +--- +# tasks file for owendemooy.backup +- name: Ensure Backup related packages are installed. + ansible.builtin.apt: + name: + - wakeonlan + state: present + +- name: Directories - Ensure required directories exist. + ansible.builtin.file: + path: "{{ item.value.path }}" + state: directory + mode: "0755" + with_items: "{{ backup_directories | dict2items }}" + +- name: Copy Backup and Restore Scripts + ansible.builtin.copy: + src: "{{ role_path }}/files/" + dest: /config + mode: "0777" + +- name: Ensure Schedule Task exists for Backup Job + ansible.builtin.cron: + name: "Host Backup" + user: root + weekday: 0,1,2,3,4,5,6 + minute: "0" + hour: "4" + job: "/config/backup.sh |& tee /tmp/backup.log" diff --git a/ansible/playbooks/roles/owendemooy.nut-client/defaults/main.yml b/ansible/playbooks/roles/owendemooy.nut-client/defaults/main.yml new file mode 100644 index 0000000..ce1461c --- /dev/null +++ b/ansible/playbooks/roles/owendemooy.nut-client/defaults/main.yml @@ -0,0 +1,12 @@ +--- +nut_config_path: /etc/nut +nut_client_service: nut-client + +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 +nut_client_enabled: true diff --git a/ansible/playbooks/roles/owendemooy.nut-client/handlers/main.yml b/ansible/playbooks/roles/owendemooy.nut-client/handlers/main.yml new file mode 100644 index 0000000..82bcecc --- /dev/null +++ b/ansible/playbooks/roles/owendemooy.nut-client/handlers/main.yml @@ -0,0 +1,6 @@ +--- +- name: Restart nut-client + ansible.builtin.service: + name: "{{ nut_client_service }}" + state: restarted + when: not ansible_check_mode diff --git a/ansible/playbooks/roles/owendemooy.nut-client/meta/main.yml b/ansible/playbooks/roles/owendemooy.nut-client/meta/main.yml new file mode 100644 index 0000000..c74e7da --- /dev/null +++ b/ansible/playbooks/roles/owendemooy.nut-client/meta/main.yml @@ -0,0 +1,53 @@ +--- +galaxy_info: + author: owendemooy + description: NUT Client + # 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.nut-client/tasks/main.yml b/ansible/playbooks/roles/owendemooy.nut-client/tasks/main.yml new file mode 100644 index 0000000..186bcea --- /dev/null +++ b/ansible/playbooks/roles/owendemooy.nut-client/tasks/main.yml @@ -0,0 +1,35 @@ +--- +# tasks file for owendemooy.nut-client + +- name: Ensure NUT Client related packages are installed. + ansible.builtin.apt: + name: + - nut-client + state: present + +- name: Change UPSMON configuration. + ansible.builtin.lineinfile: + path: "{{ nut_config_path }}/upsmon.conf" + regexp: "^MONITOR.*" + line: >- + 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