# 03 - Test Ansible Bootstrap

The purpose of this phase is to prepare the test machines created by Terraform for Linux, hardening, Docker, and Swarm. DB software installation is outside this phase.

## Ansible Installation

Ansible must be installed on the control machine, meaning your own computer. No agent is installed on target servers; SSH access is enough.

### Installation by Operating System

- **Ubuntu / Debian:**
  ```bash
  sudo apt update  
  sudo apt install -y pipx python3-venv  
  
  pipx ensurepath  
  export PATH="$HOME/.local/bin:$PATH"  
  
  pipx install --include-deps ansible
  ```
> Note: The `sudo apt install ansible` command may install old Ansible packages on some Ubuntu/Debian versions. Therefore, the `pipx` method should be preferred for using an up-to-date Ansible version.

- **Fedora / Rocky Linux / RHEL:**
  ```bash
  sudo dnf install -y pipx python3-virtualenv
  
  pipx ensurepath
  export PATH="$HOME/.local/bin:$PATH"
  
  pipx install --include-deps ansible
  ```

- **macOS (Homebrew):**
  ```bash
  brew install ansible
  ```

- **With Python Pip, on any platform:**
  ```bash
  pipx install --include-deps ansible
  ```

### Additional Python Dependencies

`passlib` is required on the control machine for the `password_hash` filter:

```bash
pipx inject ansible passlib
```

> If you installed with `pip`: `pip install passlib`

### Verify the Installation

Whichever method you used to install it, use the following commands to verify that the installation succeeded:

```bash
# Check the Ansible version and configuration paths
ansible --version

# Check which location the Ansible binary is running from
which -a ansible
```

## Running Ansible Commands

All commands must be run from the `ansible/test/` directory. `ansible.cfg` automatically defines the inventory and `roles_path`.

### 0. Install Required Collections Once During Initial Setup

```bash
ansible-galaxy collection install -r ../requirements.yml
```

### 1. Connection Test (Ping)

```bash
ansible all -m ping
```

### 2. Run the Bootstrap Playbook

```bash
ansible-playbook test-bootstrap.yml --ask-vault-pass
```

*Note: The `--ask-vault-pass` parameter asks for the Ansible Vault password; the StorageBox password is decrypted this way.*

### 3. Run Only a Specific Role (Tags)

```bash
ansible-playbook test-bootstrap.yml --tags "hardening" --ask-vault-pass
```

## Target Machines

| Host | Role |
| --- | --- |
| `iklim-app-01` | Swarm manager + app worker |
| `iklim-db-01` | OS-hardened DB node / Swarm worker for DB services |

## Recommended File Structure

```text
ansible/
  test/
    ansible.cfg
    inventory/
      generated/
        test.yml
    group_vars/
      all/
        vars.yml
        vault.yml
    host_vars/
      iklim-app-01/
        vars.yml        # Host-specific variables such as floating IP
        vault.yml
      iklim-db-01/
        vault.yml
    test-bootstrap.yml
    test-app-post-stack.yml   # act_runner installation
    test-db-post-stack.yml    # db_stack + wireguard installation
  roles/
    base/
    hardening/
    docker/
    swarm/
    node_dirs/
    storagebox/
    storagebox_ssh_key/
    db_stack/           # DB directory and configuration preparation
    wireguard/          # WireGuard VPN service (DB node)
    act_runner/         # Gitea act_runner installation (app node)
```

## Base Role

Applied to all test nodes:

- `dnf update`
- `epel-release` — installed first as a separate task; `fail2ban`, `davfs2`, `htop`, and `btop` depend on this repo
- base packages, after `epel-release` is active:
  - `curl`
  - `wget`
  - `git`
  - `jq`
  - `tar`
  - `unzip`
  - `bash-completion`
  - `gettext` — required for envsubst in CI/CD deploy pipelines
  - `tree`
  - `ca-certificates`
  - `fail2ban`
  - `chrony`
  - `python3`
  - `python3-pip`
  - `python3-passlib` — for the `password_hash` filter (EPEL)
  - `htop` — interactive process monitoring (EPEL)
  - `btop` — resource monitor with graphical interface (EPEL)
- timezone: `Europe/Istanbul`
- hostname setup
- keyboard layout: `trq` (Turkish Q)
- controlled reboot if the system requires a reboot
- **Hetzner Floating IP systemd service** (`hetzner-floating-ip`): if `hetzner_floating_ip` is defined in `host_vars`, the IP address is added to `eth0` and automatically restored on reboot (`ip addr replace`)

## Security Hardening Role

Applied to all test nodes:

- SSH password login is disabled.
- Root SSH login via password is disabled (`PermitRootLogin prohibit-password`); key-based root login remains active so Ansible can connect throughout the bootstrap.
- Only SSH key login remains.
- `PermitEmptyPasswords no`
- `MaxAuthTries 3`
- The `fail2ban` SSH jail is enabled.
- Automatic security updates are enabled with `dnf-automatic`.
- The `iklim` system user is created and added to the `wheel` group; the password is read from vault.
- `firewalld` default:
  - incoming: deny (drop zone)
  - outgoing: allow
- The SSH rule is first written as a rich rule to the `drop` zone, then the default zone is set to `drop`; this removes the lockout risk.
- Public SSH is opened only from the admin CIDR.

### SELinux Decision

Rocky Linux 10 comes in SELinux enforcing mode. Decision: **disabled**.

Rationale:
- Hetzner Cloud firewall (external perimeter) + firewalld (host) provide two layers of network security.
- The Docker + davfs2 + firewalld combination requires additional policy and volume label management in SELinux enforcing mode.
- It was also disabled on the Utils VPS, so consistency is preserved.

```bash
# Inside /etc/selinux/config:
SELINUX=disabled
# The change becomes active after reboot
reboot
```

In Ansible:

```yaml
- name: Disable SELinux
  ansible.posix.selinux:
    state: disabled
  register: selinux_change

- name: Reboot if SELinux state changed
  ansible.builtin.reboot:
  when: selinux_change.changed
```

### fail2ban Configuration

Content of `/etc/fail2ban/jail.local`:

```ini
[DEFAULT]
ignoreip = 127.0.0.1/8 {{ admin_allowed_cidrs }}
bantime  = 21600
findtime = 300
maxretry = 5
banaction = iptables-multiport
backend = systemd

[sshd]
enabled = true
```

- `bantime`: 6-hour ban
- `findtime`: within 5 minutes
- `maxretry`: 5 failed logins -> ban
- `ignoreip`: keeps admin CIDRs exempt from bans

In Ansible, the `admin_allowed_cidrs` list is converted to a space-separated string and written to the template.

Note: Docker iptables rules may interact with firewalld. The Hetzner Cloud firewall is considered the actual external perimeter; firewalld is used as a second layer inside the host.

## Docker Role

Required on both nodes (`iklim-app-01` and `iklim-db-01`). Because the DB node will join the network as a Swarm Worker, Docker Engine must be installed on both machines.

Docker is installed through the official Docker dnf repository:

- Docker GPG key + dnf repository (`https://download.docker.com/linux/rhel/docker-ce.repo`)
- packages:
  - `docker-ce`
  - `docker-ce-cli`
  - `containerd.io`
  - `docker-buildx-plugin`
  - `docker-compose-plugin`
- Docker service enabled + started

The Docker convenience script will not be used. The package repository path is preferred for a production-like test environment.

## Swarm Role

- Initialized as Swarm Manager on `iklim-app-01`.
- Joined as Swarm Worker on `iklim-db-01`, for overlay network access.
- advertise addr: `10.10.10.11`, for the manager
- overlay network:
  - `iklimco-net`
  - driver: `overlay`
  - attachable: `true`
- Node labels:
  - `iklim-app-01`: `type=service` — all infra and application services are deployed to this node
  - `iklim-db-01`: `role=db` — PostgreSQL and MongoDB services are deployed to this node
- On `iklim-app-01`, it remains both manager and worker (Active).

## Node Directory Role

Deploy prerequisites on `iklim-app-01`:

```text
/opt/iklimco
/opt/iklimco/ssl
/opt/iklimco/init
/opt/iklimco/init/postgresql
/opt/iklimco/init/mongodb
/opt/iklimco/stacks
```

Minimum DB-node host directories:

```text
/opt/iklimco
/opt/iklimco/db
/opt/iklimco/backup
```

## StorageBox DAVFS Mount Role

Applied to both nodes (`iklim-app-01` and `iklim-db-01`).

### Purpose

Mounts Hetzner StorageBox as `/mnt/storagebox` through the WebDAV (DAVFS) protocol. Docker volumes are connected to this directory to provide data persistence and backups.

### Test Environment Sub-Account

| Parameter | Variable | Value |
| --- | --- | --- |
| Main account | `storagebox_account` | `u469968` |
| Sub-account | `storagebox_user` | `u469968-sub4` |
| WebDAV URL | `storagebox_url` | `https://u469968-sub4.your-storagebox.de/` |
| Mount point | `storagebox_mount_point` | `/mnt/storagebox` |

### Role Variables

All variables are defined in `group_vars/all/vars.yml`:

```yaml
storagebox_account: "u469968"
storagebox_user: "{{ storagebox_account }}-sub4"
storagebox_url: "https://{{ storagebox_user }}.your-storagebox.de/"
storagebox_password: "{{ vault_storagebox_password }}"
storagebox_mount_point: "/mnt/storagebox"
storagebox_managed_directories:
  - path: "{{ storagebox_mount_point }}/precipitation/images"
    mode: "0755"
```

In prod, the suffix changes from `sub4` to `sub5`.

Passwords are stored encrypted with Ansible Vault inside `group_vars/all/vault.yml`:

```bash
ansible-vault edit group_vars/all/vault.yml
```

Content of `vault.yml`:

```yaml
vault_storagebox_password: "SUB_ACCOUNT_PASSWORD"
vault_iklim_password: "IKLIM_USER_PASSWORD"
```

### Steps

1. **Install davfs2**

   ```yaml
   - name: Install davfs2
     ansible.builtin.dnf:
       name: davfs2
       state: present
   ```

2. **Credentials file** (`/etc/davfs2/secrets`)

   ```yaml
   - name: Configure davfs2 secrets
     ansible.builtin.lineinfile:
       path: /etc/davfs2/secrets
       line: "{{ storagebox_url }} {{ storagebox_user }} {{ storagebox_password }}"
       create: yes
       mode: "0600"
       owner: root
       group: root
   ```

3. **Create mount point**

   ```yaml
   - name: Create mount point
     ansible.builtin.file:
       path: "{{ storagebox_mount_point }}"
       state: directory
       mode: "0755"
   ```

4. **fstab entry**

   ```yaml
   - name: Add fstab entry
     ansible.builtin.lineinfile:
       path: /etc/fstab
       line: >-
         {{ storagebox_url }} {{ storagebox_mount_point }} davfs
         _netdev,auto,user,rw,uid=root,gid=root 0 0
       state: present
   ```

5. **Mount**

   ```yaml
   - name: Mount StorageBox
     ansible.builtin.command: mount {{ storagebox_mount_point }}
     args:
       creates: "{{ storagebox_mount_point }}/.mounted_marker"
   ```

A marker file can be written to the directory to confirm mount success:

   ```yaml
   - name: Write mount marker
     ansible.builtin.copy:
       content: "mounted by ansible"
       dest: "{{ storagebox_mount_point }}/.mounted_marker"
   ```

6. **Create service bind mount directories**

In the test environment, the precipitation service's `image-data` volume is bind mounted on the host to `/mnt/storagebox/precipitation/images`. The directory is created by Ansible after StorageBox is mounted and left with `0755` permissions.

   ```yaml
   - name: Create managed StorageBox directories
     ansible.builtin.file:
       path: "{{ item.path }}"
       state: directory
       owner: "{{ item.owner | default(omit) }}"
       group: "{{ item.group | default(omit) }}"
       mode: "{{ item.mode | default('0755') }}"
     loop: "{{ storagebox_managed_directories | default([]) }}"
   ```

### Notes

- The `davfs2` package is in the EPEL repository; the base role already installs `epel-release`.
- StorageBox passwords are never added to the repository as plaintext; Ansible Vault is mandatory.
- The mount point is automatically mounted after the network is ready on reboot, thanks to the `_netdev` flag.
- Docker Swarm services use service directories under StorageBox as bind mounts.
- The precipitation service's test environment image directory must be `/mnt/storagebox/precipitation/images`; this path must exactly match the `device` value in `BE-Precipitation/docker-stack-service.yml`.

## StorageBox SSH Key Role

Applied to both nodes (`iklim-app-01` and `iklim-db-01`).

### Purpose

An ed25519 SSH key pair is generated on the server and uploaded to the StorageBox main account. This allows CI/CD pipelines to use the `STORAGEBOX_SSH_PRIV` Gitea secret for passwordless access.

### Steps

1. **SSH key generation**

   ```yaml
   - name: Generate SSH key for StorageBox
     ansible.builtin.user:
       name: root
       generate_ssh_key: yes
       ssh_key_type: ed25519
       ssh_key_file: .ssh/id_ed25519_storagebox
       ssh_key_comment: "{{ inventory_hostname }}-storagebox"
   ```

2. **Upload the public key to StorageBox**

This step is done manually and requires the password the first time:

   ```bash
   cat /root/.ssh/id_ed25519_storagebox.pub | ssh -p23 u469968-sub4@u469968-sub4.your-storagebox.de install-ssh-key
   ```

Later access works passwordlessly:

   ```bash
   sftp -P23 u469968-sub4@u469968-sub4.your-storagebox.de
   ```

3. **Add private and public keys to Gitea**

Gitea -> Organization Settings -> Actions -> Secrets:

   | Secret Name | Value |
   | --- | --- |
   | `STORAGEBOX_SSH_PRIV` | Contents of `/root/.ssh/id_ed25519_storagebox` |
   | `STORAGEBOX_SSH_PUB` | Contents of `/root/.ssh/id_ed25519_storagebox.pub` |

To get the key contents:

   ```bash
   cat /root/.ssh/id_ed25519_storagebox
   cat /root/.ssh/id_ed25519_storagebox.pub
   ```

### Notes

- A separate key is generated for each server; all public keys are uploaded to the StorageBox main account.
- The private key is never committed to the repo; it is stored only as a Gitea secret.

## Acceptance Criteria
