feat(deploy): aibook.fun deploy layer (terraform + ansible + docker on DO)
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
---
|
||||
# Usage: ansible-playbook -i 'root@<droplet-ip>,' deploy/ansible/deploy.yml
|
||||
# Pre: droplet cloud-init complete (.bootstrap-complete marker present)
|
||||
|
||||
- name: Deploy aibook-fun app layer
|
||||
hosts: all
|
||||
gather_facts: false
|
||||
become: true
|
||||
vars:
|
||||
app_dir: /opt/aibook_fun
|
||||
repo_dir: /opt/aibook_fun/repo
|
||||
repo_url: "https://git.weown.tools/WeOwnAI/aibook-fun.git"
|
||||
repo_branch: main
|
||||
image_tag: "aibook-fun-marketing:v1"
|
||||
|
||||
pre_tasks:
|
||||
- name: Verify droplet has completed cloud-init bootstrap
|
||||
ansible.builtin.stat:
|
||||
path: "{{ app_dir }}/.bootstrap-complete"
|
||||
register: bootstrap_marker
|
||||
|
||||
- name: Fail clearly if bootstrap is not complete
|
||||
ansible.builtin.fail:
|
||||
msg: >-
|
||||
{{ app_dir }}/.bootstrap-complete is missing — droplet has not
|
||||
finished cloud-init. Wait a few minutes after `tofu apply` and retry.
|
||||
Check /var/log/cloud-init-output.log on the droplet.
|
||||
when: not bootstrap_marker.stat.exists
|
||||
|
||||
- name: Ensure app dir exists
|
||||
ansible.builtin.file:
|
||||
path: "{{ app_dir }}"
|
||||
state: directory
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0755"
|
||||
|
||||
tasks:
|
||||
# ─── Clone / update repo on droplet ────────────────────────────────────
|
||||
- name: Clone (or update) aibook-fun repo on droplet
|
||||
ansible.builtin.git:
|
||||
repo: "{{ repo_url }}"
|
||||
dest: "{{ repo_dir }}"
|
||||
version: "{{ repo_branch }}"
|
||||
force: true
|
||||
register: git_clone
|
||||
notify: Rebuild image
|
||||
|
||||
# ─── Upload Dockerfile (deploy/ scaffold not yet pushed to git.weown.tools) ─
|
||||
- name: Upload Dockerfile to droplet
|
||||
ansible.builtin.copy:
|
||||
src: ../docker/Dockerfile
|
||||
dest: "{{ app_dir }}/Dockerfile"
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
|
||||
# ─── Build docker image locally on droplet (unconditional — docker layer cache handles no-ops) ─
|
||||
- name: Build Next.js docker image locally
|
||||
ansible.builtin.shell: |
|
||||
set -euo pipefail
|
||||
docker build -t {{ image_tag }} -f {{ app_dir }}/Dockerfile {{ repo_dir }}/marketing-site/
|
||||
args:
|
||||
executable: /bin/bash
|
||||
register: image_build
|
||||
changed_when: "'writing image' in image_build.stderr"
|
||||
notify: Reconcile docker compose stack
|
||||
|
||||
# ─── Upload compose + Caddyfile ────────────────────────────────────────
|
||||
- name: Upload compose.yaml
|
||||
ansible.builtin.copy:
|
||||
src: ../docker/compose.prod.yaml
|
||||
dest: "{{ app_dir }}/compose.yaml"
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
notify: Reconcile docker compose stack
|
||||
|
||||
- name: Upload Caddyfile
|
||||
ansible.builtin.copy:
|
||||
src: ../docker/Caddyfile
|
||||
dest: "{{ app_dir }}/Caddyfile"
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
notify: Reconcile docker compose stack
|
||||
|
||||
- name: Ensure caddy log dir is writable
|
||||
ansible.builtin.file:
|
||||
path: /var/log/caddy
|
||||
state: directory
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0777"
|
||||
|
||||
- name: Ensure Caddy Docker volumes are writable (caddy runs as UID 1000)
|
||||
ansible.builtin.shell: |
|
||||
set -euo pipefail
|
||||
for suffix in caddy_data caddy_config; do
|
||||
path="/var/lib/docker/volumes/aibook_fun_${suffix}/_data"
|
||||
if [ -d "$path" ]; then
|
||||
chown -R 1000:1000 "$path"
|
||||
fi
|
||||
done
|
||||
args:
|
||||
executable: /bin/bash
|
||||
changed_when: false
|
||||
|
||||
# ─── Ensure compose stack is running (idempotent) ──────────────────────
|
||||
- name: Ensure compose stack is running (idempotent — `up -d` no-op if unchanged)
|
||||
ansible.builtin.shell: |
|
||||
set -euo pipefail
|
||||
cd {{ app_dir }}
|
||||
docker compose up -d --remove-orphans
|
||||
args:
|
||||
executable: /bin/bash
|
||||
register: compose_up
|
||||
changed_when: "'Started' in compose_up.stderr or 'Created' in compose_up.stderr or 'Recreated' in compose_up.stderr"
|
||||
|
||||
- name: Wait for web app to respond
|
||||
ansible.builtin.uri:
|
||||
url: http://127.0.0.1:3000/
|
||||
status_code: [200]
|
||||
register: health_probe
|
||||
retries: 20
|
||||
delay: 5
|
||||
until: health_probe.status == 200
|
||||
|
||||
- name: Report success
|
||||
ansible.builtin.debug:
|
||||
msg: "aibook-fun app layer deployed. URL: https://aibook.fun"
|
||||
|
||||
handlers:
|
||||
- name: Rebuild image
|
||||
ansible.builtin.shell: |
|
||||
set -euo pipefail
|
||||
cd {{ repo_dir }}
|
||||
docker build -t {{ image_tag }} -f {{ repo_dir }}/deploy/docker/Dockerfile {{ repo_dir }}/marketing-site/
|
||||
args:
|
||||
executable: /bin/bash
|
||||
changed_when: false
|
||||
|
||||
- name: Reconcile docker compose stack
|
||||
ansible.builtin.shell: |
|
||||
set -euo pipefail
|
||||
cd {{ app_dir }}
|
||||
docker compose up -d --force-recreate --remove-orphans
|
||||
args:
|
||||
executable: /bin/bash
|
||||
changed_when: false
|
||||
@@ -0,0 +1,76 @@
|
||||
# aibook-fun — host hardening (CIS-aligned, Phase A)
|
||||
#
|
||||
# Applies the DevSec hardening baseline (≈ CIS Ubuntu L1) to the droplet:
|
||||
# kernel/sysctl, auth policy, filesystem perms, SSH crypto + policy — then
|
||||
# measures the result with Lynis. Idempotent; run any time after bootstrap:
|
||||
#
|
||||
# ansible-galaxy collection install -r ansible/requirements.yml # once
|
||||
# ansible-playbook ansible/harden.yml -i 'root@<droplet-ip>,'
|
||||
#
|
||||
# PHASE A vs PHASE B (deliberate scope — do not "fix" this into Phase B):
|
||||
# Phase A (this play): CIS L1 OS + SSH hardening with root login KEPT
|
||||
# (key-only). Every fleet tool (deploy.sh, backup.sh, manage-droplets.sh)
|
||||
# SSHes as root@; removing root here would break the fleet mid-flight.
|
||||
# Phase B (tracked, pre-first-customer): ops-user model (root SSH off,
|
||||
# password-sudo 2FA), mesh/CIDR-locked port 22 — per SOP-INFRA-012. Needs
|
||||
# the fleet tooling migrated off root@ first.
|
||||
#
|
||||
# Docker-safe exceptions (the CIS-L2-vs-container-runtime conflicts):
|
||||
# - net.ipv4.ip_forward stays 1 — Docker bridge networking requires it. A
|
||||
# defaults-level override silently LOSES to the inner role's defaults;
|
||||
# it must be a task-var on the include (hit live 2026-07-03: containers
|
||||
# got DNS but no TCP egress when this landed at 0).
|
||||
---
|
||||
- name: Harden aibook-fun host (CIS L1 via devsec — Phase A)
|
||||
hosts: all
|
||||
become: true
|
||||
vars:
|
||||
run_lynis: true
|
||||
|
||||
tasks:
|
||||
- name: "OS hardening (devsec.hardening.os_hardening ≈ CIS Ubuntu L1)"
|
||||
ansible.builtin.include_role:
|
||||
name: devsec.hardening.os_hardening
|
||||
vars:
|
||||
# MUST be task-vars here (see header) — Docker bridge needs ip_forward.
|
||||
sysctl_overwrite:
|
||||
net.ipv4.ip_forward: 1
|
||||
|
||||
- name: "SSH hardening (devsec.hardening.ssh_hardening — key-only, strong KEX/ciphers)"
|
||||
ansible.builtin.include_role:
|
||||
name: devsec.hardening.ssh_hardening
|
||||
vars:
|
||||
# Phase A: root stays (fleet tooling), but key-only and CIS crypto.
|
||||
ssh_permit_root_login: "without-password"
|
||||
ssh_password_authentication: false
|
||||
ssh_challenge_response_authentication: false
|
||||
|
||||
- name: "Measure: install + run Lynis (hardening index; non-blocking)"
|
||||
when: run_lynis | bool
|
||||
block:
|
||||
- name: "Install Lynis"
|
||||
ansible.builtin.apt:
|
||||
name: lynis
|
||||
state: present
|
||||
update_cache: true
|
||||
|
||||
- name: "Run Lynis audit (quick)"
|
||||
ansible.builtin.command: lynis audit system --quick --no-colors
|
||||
register: lynis_out
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: "Lynis summary (record this index in the site CHANGELOG)"
|
||||
ansible.builtin.debug:
|
||||
msg: "{{ lynis_out.stdout_lines | select('search', 'Hardening index|Warnings|Suggestions|Tests performed') | list }}"
|
||||
|
||||
- name: "Verify Docker survived hardening (containers must still run)"
|
||||
# brace-free format string: Go-template braces here would be re-templated
|
||||
# by ansible's own Jinja pass and error out.
|
||||
ansible.builtin.command: docker info --format json
|
||||
register: docker_ok
|
||||
changed_when: false
|
||||
|
||||
- name: "Docker status"
|
||||
ansible.builtin.debug:
|
||||
msg: "Docker OK (server {{ (docker_ok.stdout | from_json).ServerVersion | default('unknown') }}) — hardening did not break the runtime"
|
||||
@@ -0,0 +1,8 @@
|
||||
# Ansible Galaxy requirements for the optional hardening play (harden.yml).
|
||||
# Install once per operator workstation:
|
||||
# ansible-galaxy collection install -r ansible/requirements.yml
|
||||
collections:
|
||||
# DevSec Hardening Framework — CIS/STIG-aligned os_hardening + ssh_hardening.
|
||||
# https://github.com/dev-sec/ansible-collection-hardening
|
||||
- name: devsec.hardening
|
||||
version: ">=8.8.0"
|
||||
Reference in New Issue
Block a user