diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 2217a36..c354353 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,4 +1,5 @@ stages: + - iac - build - test - security @@ -7,8 +8,6 @@ stages: variables: BASE_IMAGE_NAME: $CI_REGISTRY_IMAGE - DOCKER_DRIVER: overlay2 - DOCKER_TLS_CERTDIR: "" TRIVY_CACHE_DIR: .cache/trivy TRIVY_NO_PROGRESS: "true" TRIVY_TIMEOUT: "10m0s" @@ -17,6 +16,8 @@ variables: TRIVY_REGISTRY: $CI_REGISTRY UV_PROJECT_ENVIRONMENT: .venv UV_CACHE_DIR: .cache/uv + BUILDAH_ISOLATION: oci + STORAGE_DRIVER: vfs cache: key: "${CI_COMMIT_REF_SLUG}" @@ -26,12 +27,12 @@ cache: - $UV_PROJECT_ENVIRONMENT policy: pull-push -.docker-job: &docker-job - image: docker:28.5 - services: - - docker:28.5-dind +.buildah-job: &buildah-job + image: quay.io/containers/buildah:latest + variables: + STORAGE_DRIVER: vfs before_script: - - docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" $CI_REGISTRY + - buildah login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" $CI_REGISTRY .trivy-fs-template: &trivy-fs-scan stage: security @@ -116,20 +117,19 @@ cache: when: on_success .build-template: &build-config - <<: *docker-job + <<: *buildah-job stage: build - variables: - DOCKER_BUILDKIT: 1 - BUILDKIT_INLINE_CACHE: 1 script: - | - docker buildx create --use - docker buildx build . \ - -t $IMAGE_NAME:$CI_COMMIT_SHA \ - -f $CONTAINERFILE --target $BUILDTARGET --push \ - --cache-from type=registry,ref=$IMAGE_NAME-cache \ - --cache-to type=registry,ref=$IMAGE_NAME-cache,mode=max,oci-mediatypes=true,image-manifest=true,compression=zstd \ - --build-arg BUILDKIT_INLINE_CACHE=1 + buildah bud \ + --tag $IMAGE_NAME:$CI_COMMIT_SHA \ + --file $CONTAINERFILE \ + --target $BUILDTARGET \ + --layers \ + --cache-from $IMAGE_NAME-cache \ + --cache-to $IMAGE_NAME-cache \ + . + - buildah push $IMAGE_NAME:$CI_COMMIT_SHA rules: - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH when: always @@ -140,26 +140,26 @@ cache: allow_failure: true .tag-template: &tag-config - <<: *docker-job + <<: *buildah-job stage: tag script: - | set -euo pipefail - IMAGE="$IMAGE_NAME:$CI_COMMIT_SHA" - docker pull "$IMAGE" + + buildah pull $IMAGE_NAME:$CI_COMMIT_SHA if [ -n "${CI_COMMIT_TAG:-}" ]; then - docker tag "$IMAGE" "$IMAGE_NAME:$CI_COMMIT_TAG" - docker push "$IMAGE_NAME:$CI_COMMIT_TAG" + buildah tag $IMAGE_NAME:$CI_COMMIT_SHA $IMAGE_NAME:$CI_COMMIT_TAG + buildah push $IMAGE_NAME:$CI_COMMIT_TAG fi if [ -n "${CI_COMMIT_BRANCH:-}" ]; then - docker tag "$IMAGE" "$IMAGE_NAME:$CI_COMMIT_REF_SLUG" - docker push "$IMAGE_NAME:$CI_COMMIT_REF_SLUG" + buildah tag $IMAGE_NAME:$CI_COMMIT_SHA $IMAGE_NAME:$CI_COMMIT_REF_SLUG + buildah push $IMAGE_NAME:$CI_COMMIT_REF_SLUG if [ "$CI_COMMIT_BRANCH" = "$CI_DEFAULT_BRANCH" ]; then - docker tag "$IMAGE" "$IMAGE_NAME:latest" - docker push "$IMAGE_NAME:latest" + buildah tag $IMAGE_NAME:$CI_COMMIT_SHA $IMAGE_NAME:latest + buildah push $IMAGE_NAME:latest fi fi rules: @@ -184,6 +184,23 @@ cache: - curl -LsSf https://astral.sh/uv/install.sh | sh - export PATH="$HOME/.local/bin:$PATH" +ansible-initvm: + stage: iac + image: alpine/ansible:2.18.6 + variables: + ANSIBLE_HOST_KEY_CHECKING: false + before_script: + - echo $ENV_PRIVATE_KEY_BASE64 | base64 -d > /id.pem + - chmod 0600 /id.pem + - mv "$INVENTORY_ALPHA_VM" ./infrastructure/iac/ansible/inventory/host_vars/alpha.yaml + - printf "[servers]\nalpha\n" > infrastructure/iac/ansible/inventory/hosts + script: + - cd ./infrastructure/iac/ansible + - ansible-galaxy collection install -r requirements.yaml + - ansible-galaxy install -r requirements.yaml + - ansible-playbook -i inventory/hosts site.yaml + when: manual + build-runtime: <<: *build-config variables: @@ -220,41 +237,50 @@ lint: - if: $CI_COMMIT_TAG test: - <<: *docker-job + <<: *buildah-job stage: test variables: COMPOSE_PROFILES: | --profile migrations --profile tests script: - - apk add --no-cache docker-compose + - apk add --no-cache podman podman-compose - export PROFILES="$(printf '%s ' $COMPOSE_PROFILES)" - cp "$TEST_STAGE_FIREBASE_CONF" ./infrastructure/configs/backend/firebase.json - | ( while true; do - docker compose -f compose.yaml $PROFILES logs -f 2>&1 + podman-compose -f compose.yaml $PROFILES logs -f 2>&1 sleep 1 done ) | tee -a compose.log & - LOGS_PID=$! - | REGISTRY_PREFIX=$CI_REGISTRY_IMAGE IMAGE_TAG=$CI_COMMIT_SHA \ - docker compose -f compose.yaml -f compose.prod.yaml \ - $PROFILES up -d --quiet-pull --quiet-build 2>&1 | tee compose.log - - | - TEST_CONTAINER_ID=$(docker compose -f compose.yaml $PROFILES ps -q tests -a) - timeout 600 docker wait $TEST_CONTAINER_ID - TEST_EXIT_CODE=$(docker inspect --format "{{.State.ExitCode}}" $TEST_CONTAINER_ID) + podman-compose -f compose.yaml -f compose.prod.yaml \ + $PROFILES up -d 2>&1 | tee compose.log + - | + TEST_CONTAINER_ID=$( + podman-compose ps --all --format json \ + | jq -r '.[] | select(.Labels["io.podman.compose.service"] == "tests") | .Id' + ) - if [ $TEST_EXIT_CODE -eq 0 ]; then + if [ -z "$TEST_CONTAINER_ID" ]; then + echo "Tests container not found." + exit 1 + fi + + timeout 600 podman wait "$TEST_CONTAINER_ID" + TEST_EXIT_CODE=$(podman inspect --format "{{.State.ExitCode}}" "$TEST_CONTAINER_ID") + + if [ "$TEST_EXIT_CODE" -eq 0 ]; then echo "Tests passed." else echo "Tests failed with exit code $TEST_EXIT_CODE." exit 1 - fi + fi - | - docker compose -f compose.yaml $PROFILES down + podman-compose -f compose.yaml $PROFILES down - cat .cov/coverage.txt artifacts: paths: diff --git a/Containerfile b/Containerfile index ecac0c2..6088d41 100644 --- a/Containerfile +++ b/Containerfile @@ -63,15 +63,15 @@ ENV PYTHONDONTWRITEBYTECODE=1 \ WORKDIR /app +RUN uv sync --no-install-project --group tests --frozen --no-cache + COPY ./src ./src COPY ./tests ./tests -RUN uv sync --group tests --frozen --no-cache +RUN uv pip install -e . -RUN mkdir -p /app/cov - -RUN mkdir /app/cov/html +RUN mkdir -p /app/cov && mkdir /app/cov/html CMD [ "sh", "-c", "coverage run --source=src -m pytest -v && coverage report > /app/cov/coverage.txt && coverage xml -o /app/cov/coverage.xml && coverage html -d /app/cov/html" ] @@ -89,10 +89,14 @@ WORKDIR /app RUN mkdir -p ./src/template_project -COPY ./src/template_project ./src/template_project +RUN uv sync --no-install-project --group migrations --frozen --no-cache + +COPY ./src ./src + +COPY ./tests ./tests COPY ./alembic.ini ./ -RUN uv sync --group migrations --frozen --no-cache +RUN uv pip install -e . CMD [ "alembic", "upgrade", "head" ] diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/compose.yaml b/compose.yaml index 839adaf..93ac88b 100644 --- a/compose.yaml +++ b/compose.yaml @@ -10,10 +10,10 @@ services: - template-project-backend:latest pull: true depends_on: - migrations: - restart: false - condition: service_completed_successfully - required: false + # migrations: + # restart: false + # condition: service_completed_successfully + # required: false postgres: restart: false condition: service_healthy @@ -22,17 +22,18 @@ services: restart: false condition: service_healthy required: true - configs: - - source: backend_config - target: /app/config.toml - secrets: - - source: firebase - target: /app/firebase.json env_file: - path: ./infrastructure/configs/backend/.env.template required: true - path: ./infrastructure/configs/backend/.env required: false + healthcheck: + test: [ "CMD", "curl", "-fsS", "http://localhost:8080/healthcheck" ] + interval: 5s + timeout: 4s + start_period: 5s + start_interval: 2s + retries: 5 networks: - default ports: @@ -44,6 +45,19 @@ services: app_protocol: http restart: unless-stopped shm_size: 4mb + volumes: + - type: bind + source: ./infrastructure/configs/backend/config.toml + target: /app/config.toml + read_only: true + bind: + selinux: z + - type: bind + source: ./infrastructure/configs/backend/firebase.json + target: /app/firebase.json + read_only: true + bind: + selinux: z tests: build: @@ -70,26 +84,36 @@ services: restart: false condition: service_healthy required: true - configs: - - source: backend_config - target: /app/config.toml - - source: alembic_config - target: /app/alembic.ini env_file: - path: ./infrastructure/configs/backend/.env.template required: true - path: ./infrastructure/configs/backend/.env required: false + healthcheck: + test: [ "CMD", "pg_isready", "-U", "postgres", "--dbname=postgres" ] + interval: 5s + timeout: 4s + start_period: 5s + start_interval: 2s + retries: 5 networks: - default profiles: - tests - restart: no shm_size: 4mb volumes: + - type: bind + source: ./infrastructure/configs/backend/config.toml + target: /app/config.toml + read_only: true + bind: + selinux: z - type: bind source: ./.cov target: /app/cov + read_only: false + bind: + selinux: z migrations: build: @@ -104,11 +128,6 @@ services: restart: false condition: service_healthy required: true - configs: - - source: backend_config - target: /app/config.toml - - source: alembic_config - target: /app/alembic.ini env_file: - path: ./infrastructure/configs/backend/.env.template required: true @@ -118,23 +137,32 @@ services: - default profiles: - migrations - restart: no shm_size: 4mb + volumes: + - type: bind + source: ./infrastructure/configs/backend/config.toml + target: /app/config.toml + read_only: false + bind: + selinux: z + - type: bind + source: ./alembic.ini + target: /app/alembic.ini + read_only: true + bind: + selinux: z postgres: image: docker.io/postgres:17-alpine - configs: - - source: postgres_config - target: /etc/postgresql/postgresql.conf env_file: - path: ./infrastructure/configs/postgres/.env.template required: true - path: ./infrastructure/configs/postgres/.env required: false healthcheck: - test: [ "CMD", "pg_isready", "--dbname=postgres" ] - interval: 1m30s - timeout: 5s + test: [ "CMD", "pg_isready", "-U", "postgres", "--dbname=postgres" ] + interval: 5s + timeout: 4s start_period: 5s start_interval: 2s retries: 5 @@ -144,15 +172,18 @@ services: restart: unless-stopped shm_size: 128mb volumes: + - type: bind + source: ./infrastructure/configs/postgres/postgresql.conf + target: /etc/postgresql/postgresql.conf + read_only: true + bind: + selinux: z - type: volume source: postgres_data target: /var/lib/postgresql/data pgadmin: image: docker.io/dpage/pgadmin4:9 - configs: - - source: pgadmin_servers_config - target: /pgadmin4/servers.json depends_on: postgres: restart: false @@ -165,8 +196,8 @@ services: required: false healthcheck: test: [ "CMD", "wget", "-O", "-", "http://localhost:80/misc/ping" ] - interval: 1m30s - timeout: 5s + interval: 5s + timeout: 4s start_period: 5s start_interval: 2s retries: 5 @@ -184,17 +215,19 @@ services: restart: unless-stopped shm_size: 4mb volumes: + - type: bind + source: ./infrastructure/configs/pgadmin/servers.json + target: /pgadmin4/servers.json + read_only: true + bind: + selinux: z - type: volume source: pgadmin_data target: /var/lib/pgadmin - read_only: false redis: image: docker.io/redis:8-alpine command: redis-server /usr/local/etc/redis/redis.conf - configs: - - source: redis_config - target: /usr/local/etc/redis/redis.conf env_file: - path: ./infrastructure/configs/redis/.env.template required: true @@ -202,8 +235,8 @@ services: required: false healthcheck: test: [ "CMD", "redis-cli", "ping" ] - interval: 1m30s - timeout: 5s + interval: 5s + timeout: 4s start_period: 5s start_interval: 2s retries: 5 @@ -212,10 +245,15 @@ services: restart: unless-stopped shm_size: 4mb volumes: + - type: bind + source: ./infrastructure/configs/redis/redis.conf + target: /usr/local/etc/redis/redis.conf + read_only: true + bind: + selinux: z - type: volume source: redis_data target: /data - read_only: false networks: default: @@ -227,25 +265,7 @@ networks: internal: false external: false - volumes: postgres_data: pgadmin_data: redis_data: - - -configs: - backend_config: - file: ./infrastructure/configs/backend/config.toml - alembic_config: - file: alembic.ini - postgres_config: - file: ./infrastructure/configs/postgres/postgresql.conf - pgadmin_servers_config: - file: ./infrastructure/configs/pgadmin/servers.json - redis_config: - file: ./infrastructure/configs/redis/redis.conf - -secrets: - firebase: - file: ./infrastructure/configs/backend/firebase.json diff --git a/infrastructure/iac/ansible/.env.template b/infrastructure/iac/ansible/.env.template new file mode 100644 index 0000000..6de5b57 --- /dev/null +++ b/infrastructure/iac/ansible/.env.template @@ -0,0 +1,4 @@ +# Change all vars before going to production and remove all comments (!) +# Below all environment variables and default values + +TAILSCALE_KEY= diff --git a/infrastructure/iac/ansible/.gitignore b/infrastructure/iac/ansible/.gitignore new file mode 100644 index 0000000..679e57a --- /dev/null +++ b/infrastructure/iac/ansible/.gitignore @@ -0,0 +1,15 @@ +# Ansible Galaxy roles +external_roles/* + +# Python files +__pycache__/ + +# dotenv file +.env + +# Inventory +inventory/host_vars/* +inventory/hosts + +# Unignore .gitkeep files +!.gitkeep diff --git a/infrastructure/iac/ansible/README.md b/infrastructure/iac/ansible/README.md new file mode 100644 index 0000000..d90d148 --- /dev/null +++ b/infrastructure/iac/ansible/README.md @@ -0,0 +1,6 @@ +# VM Setup + +```bash +ansible-galaxy collection install -r requirements.yaml +ansible-galaxy install -r requirements.yaml -p external_roles +``` diff --git a/infrastructure/iac/ansible/ansible.cfg b/infrastructure/iac/ansible/ansible.cfg new file mode 100644 index 0000000..0e5a962 --- /dev/null +++ b/infrastructure/iac/ansible/ansible.cfg @@ -0,0 +1,59 @@ +[defaults] +inventory = inventory/hosts +roles_path = roles:external_roles +library = plugins/modules +filter_plugins = plugins/filter +callback_plugins = plugins/callback + +host_key_checking = False +allow_world_readable_tmpfiles = False +remote_tmp = ~/.ansible/tmp +local_tmp = ~/.ansible/tmp + +any_errors_fatal = False +forks = 20 +poll_interval = 15 +timeout = 30 +gathering = smart +fact_caching = memory +fact_caching_timeout = 300 + +stdout_callback = yaml +bin_ansible_callbacks = True +display_skipped_hosts = False +deprecation_warnings = True +command_warnings = False + +interpreter_python = auto_silent + +[ssh_connection] +ssh_args = + -o ControlMaster=auto + -o ControlPersist=60s + -o ServerAliveInterval=30 + -o ServerAliveCountMax=3 + -o ConnectTimeout=10 + -o TCPKeepAlive=yes + -o UserKnownHostsFile=/dev/null + -o StrictHostKeyChecking=no + -o IdentitiesOnly=yes + +pipelining = True +control_path_dir = ~/.ansible/cp +scp_if_ssh = True +retries = 3 + +[inventory] +cache = True +cache_plugin = memory +cache_timeout = 300 +enable_plugins = host_list, script, auto, yaml, ini, toml + +[galaxy] +role_file = requirements.yaml +ignore_certs = False +no_deps = False + +[persistent_connection] +connect_timeout = 600 +command_timeout = 600 diff --git a/infrastructure/iac/ansible/apps.yaml b/infrastructure/iac/ansible/apps.yaml new file mode 100644 index 0000000..a70b108 --- /dev/null +++ b/infrastructure/iac/ansible/apps.yaml @@ -0,0 +1,27 @@ +--- +- name: Application Deployment + hosts: servers + gather_facts: true + become: true + serial: 1 + + roles: + - role: dokploy + when: applications.dokploy.enabled | bool + vars: + dokploy_state: "{{ applications.dokploy.state | default('present') }}" + tags: dokploy, apps + + - role: coolify + when: applications.coolify.enabled | bool + vars: + coolify_state: "{{ applications.coolify.state | default('present') }}" + tags: coolify, apps + + - role: artis3n.tailscale.machine + when: applications.tailscale.enabled | bool + tags: tailscale, apps + + - role: borgbase.ansible_role_borgbackup + when: applications.borgbackup.enabled | bool + tags: borgbackup, apps diff --git a/infrastructure/iac/ansible/base_setup.yaml b/infrastructure/iac/ansible/base_setup.yaml new file mode 100644 index 0000000..1ab33fc --- /dev/null +++ b/infrastructure/iac/ansible/base_setup.yaml @@ -0,0 +1,91 @@ +--- +- name: Base system setup + hosts: servers + gather_facts: true + become: true + serial: "100%" + + pre_tasks: + - name: Update apt cache and upgrade system + ansible.builtin.apt: + update_cache: true + cache_valid_time: 3600 + upgrade: dist + register: apt_upgrade + tags: system, updates + + - name: Autoremove and clean + ansible.builtin.apt: + autoremove: true + autoclean: true + tags: system, updates + + - name: Check system requirements + block: + - name: Verify Python 3 is available + ansible.builtin.command: which python3 + register: python_check + changed_when: false + failed_when: python_check.rc != 0 + + - name: Check available memory + ansible.builtin.setup: + filter: ansible_memtotal_mb + register: memory_info + failed_when: memory_info.ansible_facts.ansible_memtotal_mb < 512 + tags: validation + + roles: + - role: common + tags: common, system, bootstrap + + - role: security + tags: security, harden + + - role: monitoring + when: monitoring_enabled | bool + tags: monitoring + + post_tasks: + - name: Display system summary + ansible.builtin.debug: + msg: | + System setup completed on {{ inventory_hostname }} + OS: {{ ansible_distribution }} {{ ansible_distribution_version }} + Kernel: {{ ansible_kernel }} + Architecture: {{ ansible_architecture }} + Memory: {{ ansible_memtotal_mb }}MB + CPUs: {{ ansible_processor_vcpus }} + Storage: {{ ansible_devices.vda.size if ansible_devices.vda is defined + else (ansible_devices.sda.size if ansible_devices.sda is defined + else 'N/A') }} + tags: always, info + + - name: Check if a reboot is required after updates + ansible.builtin.stat: + path: /var/run/reboot-required + register: reboot_required_file + tags: system + + - name: Reboot if required + ansible.builtin.reboot: + msg: "Reboot triggered by Ansible for system updates" + connect_timeout: 10 + reboot_timeout: 600 + pre_reboot_delay: 5 + post_reboot_delay: 45 + test_command: uptime + when: reboot_required_file.stat.exists + register: reboot_result + async: 600 + poll: 0 + tags: system + + - name: Wait for reboot to complete + ansible.builtin.wait_for_connection: + connect_timeout: 20 + sleep: 5 + delay: 5 + timeout: 600 + when: reboot_required_file.stat.exists + tags: system diff --git a/infrastructure/iac/ansible/docker.yaml b/infrastructure/iac/ansible/docker.yaml new file mode 100644 index 0000000..815ca27 --- /dev/null +++ b/infrastructure/iac/ansible/docker.yaml @@ -0,0 +1,10 @@ +--- +- name: Docker Setup + hosts: servers + gather_facts: true + become: true + serial: "100%" + + roles: + - role: geerlingguy.docker + tags: docker diff --git a/infrastructure/iac/ansible/external_roles/.gitkeep b/infrastructure/iac/ansible/external_roles/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/infrastructure/iac/ansible/group_vars/all/all.yaml b/infrastructure/iac/ansible/group_vars/all/all.yaml new file mode 100644 index 0000000..ae2b089 --- /dev/null +++ b/infrastructure/iac/ansible/group_vars/all/all.yaml @@ -0,0 +1,33 @@ +--- +ansible_become: true +ansible_become_method: sudo +ansible_python_interpreter: /usr/bin/python3 + +# System configuration +timezone: UTC +system_locale: en_US.UTF-8 + +# Monitoring +monitoring_enabled: true +monitoring_prometheus_node_exporter: true + +# User management +admin_users: + - name: "{{ ansible_user }}" + groups: "sudo" + shell: /bin/bash + ssh_keys: "{{ admin_ssh_keys | default([]) }}" + state: present + +# Applications management +applications: + coolify: + enabled: false + state: latest + dokploy: + enabled: false + state: latest + tailscale: + enabled: false + borgbackup: + enabled: false diff --git a/infrastructure/iac/ansible/group_vars/all/borgbackup.yaml b/infrastructure/iac/ansible/group_vars/all/borgbackup.yaml new file mode 100644 index 0000000..9f82d7b --- /dev/null +++ b/infrastructure/iac/ansible/group_vars/all/borgbackup.yaml @@ -0,0 +1,46 @@ +--- +borg_repository: +borg_install_method: pip +borg_lock_wait_time: 10 +borg_source_directories: + - /home + - /root + - /etc + - /srv + - /var/www + - /var/lib/docker/volumes +borg_ssh_key_type: "ed25519" +borg_retention_policy: + keep_hourly: 1 + keep_daily: 1 + keep_weekly: 1 + keep_monthly: 3 + +borgmatic_timer_cron_name: "borgmatic" +borgmatic_timer: cron +borgmatic_timer_hour: "{{ range(0, 5) | random(seed=inventory_hostname) }}" +borgmatic_timer_minute: "{{ range(0, 59) | random(seed=inventory_hostname) }}" +borgmatic_timer_flags: "" + +borgmatic_config_name: config.yaml +borgmatic_hooks: + on_error: + - echo "`date` - Error while creating a backup." + before_backup: + - echo "`date` - Starting backup." + after_backup: + - echo "`date` - Finished backup." +borgmatic_checks: + - name: repository + frequency: "4 weeks" + - name: archives + frequency: "6 weeks" +borgmatic_check_last: 3 +borgmatic_store_atime: false +borgmatic_store_ctime: false +borgmatic_relocated_repo_access_is_ok: false +borgmatic_version: ">=1.7.11" + +borg_venv_path: /opt/borgmatic +borg_user: root +borg_group: root diff --git a/infrastructure/iac/ansible/group_vars/all/docker.yaml b/infrastructure/iac/ansible/group_vars/all/docker.yaml new file mode 100644 index 0000000..99c385a --- /dev/null +++ b/infrastructure/iac/ansible/group_vars/all/docker.yaml @@ -0,0 +1,17 @@ +--- +docker_edition: "ce" +docker_install_compose_plugin: true +docker_users: + - "{{ ansible_user }}" +docker_daemon_options: + storage-driver: "overlay2" + + log-driver: "json-file" + log-opts: + max-size: "50m" + max-file: "5" + + live-restore: false + icc: false + userland-proxy: false + default-address-pools: [{"base": "10.200.0.0/16", "size": 24}] diff --git a/infrastructure/iac/ansible/group_vars/all/ntp.yaml b/infrastructure/iac/ansible/group_vars/all/ntp.yaml new file mode 100644 index 0000000..027a912 --- /dev/null +++ b/infrastructure/iac/ansible/group_vars/all/ntp.yaml @@ -0,0 +1,15 @@ +--- +ntp_enabled: true +ntp_timezone: Etc/UTC +ntp_manage_config: true + +ntp_servers: + - 0.pool.ntp.org + - 1.pool.ntp.org + - 2.pool.ntp.org + - 3.pool.ntp.org +ntp_restrict: + - "127.0.0.1" + - "::1" + +ntp_cron_handler_enabled: true diff --git a/infrastructure/iac/ansible/group_vars/all/packages.yaml b/infrastructure/iac/ansible/group_vars/all/packages.yaml new file mode 100644 index 0000000..7704156 --- /dev/null +++ b/infrastructure/iac/ansible/group_vars/all/packages.yaml @@ -0,0 +1,33 @@ +--- +system_packages: + essential: + - curl + - wget + - git + - htop + - vim + - gnupg + - ca-certificates + - apt-transport-https + - software-properties-common + - iproute2 + - net-tools + - unzip + - jq + - tree + - bash-completion + - tmux + - rsync + - python3-docker + - borgbackup + monitoring: + - atop + - iotop + - nethogs + - nload + - sysstat + - dstat + - smartmontools + security: + - fail2ban + - nftables diff --git a/infrastructure/iac/ansible/group_vars/all/security.yaml b/infrastructure/iac/ansible/group_vars/all/security.yaml new file mode 100644 index 0000000..24624e0 --- /dev/null +++ b/infrastructure/iac/ansible/group_vars/all/security.yaml @@ -0,0 +1,25 @@ +--- +security_firewall_default_policy: drop +security_firewall_allowed_ports: + - "{{ security_ssh_port }}/tcp" + - "80/tcp" + - "443/tcp" + - "443/udp" + - "53/udp" + +security_ssh_port: 2424 +security_fail2ban_enabled: true +security_fail2ban_custom_configuration_template: "jail.local.j2" +security_autoupdate_enabled: true + +ssh_config: + permit_root_login: "no" + password_authentication: "no" + challenge_response_authentication: "no" + use_pam: "yes" + x11_forwarding: "no" + client_alive_interval: 300 + client_alive_count_max: 2 + max_auth_tries: 3 + max_sessions: 10 + allow_users: "root {{ admin_users | map(attribute='name') | join(' ') }}" diff --git a/infrastructure/iac/ansible/group_vars/all/sysctl.yaml b/infrastructure/iac/ansible/group_vars/all/sysctl.yaml new file mode 100644 index 0000000..db0d3ae --- /dev/null +++ b/infrastructure/iac/ansible/group_vars/all/sysctl.yaml @@ -0,0 +1,25 @@ +--- +sysctl_tuning: + # Network tuning + net.core.somaxconn: 65535 + net.ipv4.tcp_max_syn_backlog: 65535 + net.ipv4.tcp_fin_timeout: 30 + net.ipv4.tcp_keepalive_time: 600 + net.ipv4.tcp_keepalive_probes: 5 + net.ipv4.tcp_keepalive_intvl: 15 + net.ipv4.ip_local_port_range: "1024 65535" + + # Memory tuning + vm.swappiness: 10 + vm.vfs_cache_pressure: 50 + vm.dirty_ratio: 15 + vm.dirty_background_ratio: 5 + vm.overcommit_memory: 1 + vm.overcommit_ratio: 90 + + # Security tuning + net.ipv4.conf.all.rp_filter: 1 + net.ipv4.conf.default.rp_filter: 1 + net.ipv4.icmp_echo_ignore_broadcasts: 1 + net.ipv4.icmp_ignore_bogus_error_responses: 1 + net.ipv4.tcp_syncookies: 1 diff --git a/infrastructure/iac/ansible/group_vars/all/tailscale.yaml b/infrastructure/iac/ansible/group_vars/all/tailscale.yaml new file mode 100644 index 0000000..74550e2 --- /dev/null +++ b/infrastructure/iac/ansible/group_vars/all/tailscale.yaml @@ -0,0 +1,16 @@ +--- +state: "{{ applications.tailscale.state | default('latest') }}" + +tailscale_authkey: "{{ lookup('env', 'TAILSCALE_KEY') }}" +tailscale_tags: >- + {{ + ['vm'] + + ([hostname.split('-')[-1]] if '-' in hostname else []) + }} +tailscale_args: "--accept-dns=true --accept-routes=false --netfilter-mode on --shields-up=false --ssh=true --stateful-filtering=false" + +tailscale_oauth_ephemeral: false +tailscale_oauth_preauthorized: true +insecurely_log_authkey: false +release_stability: stable +tailscale_up_timeout: 120 diff --git a/infrastructure/iac/ansible/inventory/host_vars/.gitkeep b/infrastructure/iac/ansible/inventory/host_vars/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/infrastructure/iac/ansible/requirements.yaml b/infrastructure/iac/ansible/requirements.yaml new file mode 100644 index 0000000..b56bad8 --- /dev/null +++ b/infrastructure/iac/ansible/requirements.yaml @@ -0,0 +1,20 @@ +--- +collections: + - name: ansible.posix + version: 2.1.0 + - name: community.general + version: 11.4.0 + - name: community.docker + version: 4.8.1 + - name: artis3n.tailscale + version: 1.1.0 + +roles: + - src: geerlingguy.docker + version: 7.8.0 + - src: geerlingguy.security + version: 3.0.0 + - src: geerlingguy.ntp + version: 3.0.0 + - src: borgbase.ansible_role_borgbackup + version: v1.1.3 diff --git a/infrastructure/iac/ansible/roles/common/handlers/main.yaml b/infrastructure/iac/ansible/roles/common/handlers/main.yaml new file mode 100644 index 0000000..6665708 --- /dev/null +++ b/infrastructure/iac/ansible/roles/common/handlers/main.yaml @@ -0,0 +1,5 @@ +--- +- name: Restart ssh + ansible.builtin.service: + name: ssh + state: restarted diff --git a/infrastructure/iac/ansible/roles/common/tasks/main.yaml b/infrastructure/iac/ansible/roles/common/tasks/main.yaml new file mode 100644 index 0000000..fdf3962 --- /dev/null +++ b/infrastructure/iac/ansible/roles/common/tasks/main.yaml @@ -0,0 +1,77 @@ +--- +- name: Include optimization tasks + include_tasks: optimization.yaml + tags: optimization + +- name: Install essential packages + ansible.builtin.apt: + name: "{{ system_packages.essential }}" + state: present + update_cache: true + cache_valid_time: 3600 + tags: packages + +- name: Set hostname and FQDN + block: + - name: Set hostname + ansible.builtin.hostname: + name: "{{ hostname | default(inventory_hostname) }}" + + - name: Configure FQDN in hosts file + ansible.builtin.lineinfile: + path: /etc/hosts + regexp: '^127\.0\.1\.1.*' + line: "127.0.1.1 {{ fqdn | default(hostname) }} {{ hostname | default(inventory_hostname) }}" + state: present + tags: system + +- name: Deploy MOTD template + template: + src: motd.j2 + dest: /etc/motd + mode: '0644' + +- name: Configure timezone + community.general.timezone: + name: "{{ timezone }}" + tags: system, ntp + +- name: Install and configure NTP + include_role: + name: geerlingguy.ntp + tags: system, ntp + +- name: Deploy SSH configuration + ansible.builtin.template: + src: sshd_config.j2 + dest: /etc/ssh/sshd_config + owner: root + group: root + mode: '0600' + validate: '/usr/sbin/sshd -t -f %s' + notify: Restart ssh + +- name: Create admin users with proper SSH keys + block: + - name: Ensure user exists + ansible.builtin.user: + name: "{{ item.name }}" + shell: "{{ item.shell | default('/bin/bash') }}" + groups: "{{ item.groups }}" + append: true + state: "{{ item.state | default('present') }}" + create_home: true + home: "/home/{{ item.name }}" + loop: "{{ admin_users }}" + tags: users + + - name: Deploy SSH authorized keys + ansible.posix.authorized_key: + user: "{{ item.0.name }}" + state: present + key: "{{ item.1 }}" + manage_dir: true + with_subelements: + - "{{ admin_users }}" + - ssh_keys + tags: users, ssh diff --git a/infrastructure/iac/ansible/roles/common/tasks/optimization.yaml b/infrastructure/iac/ansible/roles/common/tasks/optimization.yaml new file mode 100644 index 0000000..632a563 --- /dev/null +++ b/infrastructure/iac/ansible/roles/common/tasks/optimization.yaml @@ -0,0 +1,22 @@ +--- +- name: Configure sysctl parameters + ansible.builtin.sysctl: + name: "{{ item.key }}" + value: "{{ item.value }}" + state: present + reload: true + loop: "{{ sysctl_tuning | dict2items }}" + tags: optimization + +- name: Configure file handle limits + ansible.builtin.lineinfile: + path: /etc/security/limits.conf + regexp: "^{{ item.user | regex_escape }}.*{{ item.type }}" + line: "{{ item.user }} - nofile {{ item.limit }}" + create: true + loop: + - {user: "root", type: "soft", limit: "65536"} + - {user: "root", type: "hard", limit: "65536"} + - {user: "*", type: "soft", limit: "65536"} + - {user: "*", type: "hard", limit: "65536"} + tags: limits diff --git a/infrastructure/iac/ansible/roles/common/templates/motd.j2 b/infrastructure/iac/ansible/roles/common/templates/motd.j2 new file mode 100644 index 0000000..39761ce --- /dev/null +++ b/infrastructure/iac/ansible/roles/common/templates/motd.j2 @@ -0,0 +1,4 @@ +{{ ansible_hostname }} +-------------------- +Welcome to {{ ansible_distribution }} {{ ansible_distribution_version }} +Kernel: {{ ansible_kernel }} diff --git a/infrastructure/iac/ansible/roles/common/templates/sshd_config.j2 b/infrastructure/iac/ansible/roles/common/templates/sshd_config.j2 new file mode 100644 index 0000000..3102d45 --- /dev/null +++ b/infrastructure/iac/ansible/roles/common/templates/sshd_config.j2 @@ -0,0 +1,100 @@ +# Managed by Ansible - do not modify manually +# Security hardened SSH configuration + +Include /etc/ssh/sshd_config.d/*.conf + +# Basic settings +Port {{ security_ssh_port }} +AddressFamily any +ListenAddress 0.0.0.0 +ListenAddress :: +Protocol 2 + +# Host keys (modern algorithms first) +HostKey /etc/ssh/ssh_host_ed25519_key +HostKey /etc/ssh/ssh_host_rsa_key +HostKey /etc/ssh/ssh_host_ecdsa_key + +# Cryptography settings (modern ciphers) +KexAlgorithms curve25519-sha256,curve25519-sha256@libssh.org,diffie-hellman-group16-sha512,diffie-hellman-group18-sha512 +Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes128-gcm@openssh.com,aes256-ctr,aes192-ctr,aes128-ctr +MACs hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com,umac-128-etm@openssh.com + +# Authentication security +PermitRootLogin {{ ssh_config.permit_root_login }} +MaxAuthTries {{ ssh_config.max_auth_tries }} +MaxSessions {{ ssh_config.max_sessions }} +ClientAliveInterval {{ ssh_config.client_alive_interval }} +ClientAliveCountMax {{ ssh_config.client_alive_count_max }} +LoginGraceTime 60 + +# General security settings +UsePAM {{ ssh_config.use_pam }} +X11Forwarding {{ ssh_config.x11_forwarding }} +PrintMotd no +Compression no +UseDNS no +IgnoreRhosts yes +StrictModes yes +PermitEmptyPasswords no +TCPKeepAlive yes +KbdInteractiveAuthentication no +PrintLastLog yes + +# Authorization settings +AuthorizedKeysFile .ssh/authorized_keys .ssh/authorized_keys2 + +# Logging +LogLevel INFO +SyslogFacility AUTH + +# User restrictions +{% if ssh_config.allow_users is defined and ssh_config.allow_users %} +AllowUsers {{ ssh_config.allow_users }} +{% endif %} +{% if ssh_config.allow_groups is defined and ssh_config.allow_groups %} +AllowGroups {{ ssh_config.allow_groups }} +{% endif %} + +# Key-based auth enforcement +PasswordAuthentication {{ ssh_config.password_authentication }} +PermitEmptyPasswords no +PubkeyAuthentication yes +AuthenticationMethods publickey +ChallengeResponseAuthentication {{ ssh_config.challenge_response_authentication }} + +# Rekey limits +RekeyLimit 512M 1h + +# Allow client to pass locale environment variables +AcceptEnv LANG LC_* + +Subsystem sftp /usr/lib/openssh/sftp-server + +# Authorized key and principal controls +AuthorizedPrincipalsFile none +AuthorizedKeysCommand none +AuthorizedKeysCommandUser nobody + +# Disable forwarding and tunnels unless explicitly needed +AllowAgentForwarding no +AllowTcpForwarding no +GatewayPorts no +PermitTunnel no + +# Disable user-controlled environments and TTY manipulations +PermitUserEnvironment no +PermitTTY yes +X11UseLocalhost yes +X11DisplayOffset 10 + +# Limit connection attempts +MaxStartups 2:30:100 + +# Misc hardening +IgnoreUserKnownHosts yes +VersionAddendum none +ChrootDirectory none + +Match Address 10.0.0.0/8,192.168.0.0/16,172.16.0.0/12 + PermitRootLogin yes diff --git a/infrastructure/iac/ansible/roles/coolify/defaults/main.yaml b/infrastructure/iac/ansible/roles/coolify/defaults/main.yaml new file mode 100644 index 0000000..87015f9 --- /dev/null +++ b/infrastructure/iac/ansible/roles/coolify/defaults/main.yaml @@ -0,0 +1,19 @@ +--- +coolify_state: present # latest | present | absent +coolify_remove_data_on_absent: false +coolify_base_dir: /data/coolify + +coolify_docker_network: coolify +coolify_http_port: 8000 + +coolify_owner: 9999 +coolify_group: root +coolify_compose_files: + - docker-compose.yml + - docker-compose.prod.yml + +coolify_container_names: + - coolify + - coolify-db + - coolify-redis + - coolify-realtime diff --git a/infrastructure/iac/ansible/roles/coolify/tasks/delete.yaml b/infrastructure/iac/ansible/roles/coolify/tasks/delete.yaml new file mode 100644 index 0000000..570f124 --- /dev/null +++ b/infrastructure/iac/ansible/roles/coolify/tasks/delete.yaml @@ -0,0 +1,120 @@ +--- +- name: Check if Coolify is running before uninstallation + community.docker.docker_container_info: + name: "{{ item }}" + loop: "{{ coolify_container_names }}" + register: coolify_containers_pre_uninstall + ignore_errors: true + tags: coolify, deletion, pre-check + +- name: Stop and remove Coolify services (compose down) + become: true + community.docker.docker_compose_v2: + project_src: "{{ coolify_base_dir }}/source" + files: "{{ coolify_compose_files }}" + state: absent + remove_orphans: true + remove_volumes: false + ignore_errors: true + tags: coolify, docker, deletion + +- name: Force remove any remaining Coolify containers + community.docker.docker_container: + name: "{{ item.item }}" + state: absent + force_kill: true + force_remove: true + loop: "{{ coolify_containers_pre_uninstall.results }}" + when: + - item.exists | default(false) + - coolify_remove_data_on_absent | bool + ignore_errors: true + tags: coolify, docker, deletion + +- name: Remove Coolify files and data (conditional) + ansible.builtin.file: + path: "{{ item }}" + state: absent + loop: + - "{{ coolify_base_dir }}/source" + - "{{ coolify_base_dir }}/applications" + - "{{ coolify_base_dir }}/databases" + - "{{ coolify_base_dir }}/backups" + - "{{ coolify_base_dir }}/services" + - "{{ coolify_base_dir }}/proxy" + - "{{ coolify_base_dir }}/webhooks-during-maintenance" + - "{{ coolify_base_dir }}/ssh" + - "{{ coolify_base_dir }}/sentinel" + when: coolify_remove_data_on_absent | bool + tags: coolify, files, deletion, data + +- name: Remove base directory if empty (only when requested) + ansible.builtin.file: + path: "{{ coolify_base_dir }}" + state: absent + when: coolify_remove_data_on_absent | bool + tags: coolify, files, deletion, data + +- name: Remove Coolify docker volumes (find) + community.docker.docker_volume_info: + name: "^coolify_.*" + register: coolify_volumes + ignore_errors: true + when: coolify_remove_data_on_absent | bool + tags: coolify, docker, deletion + +- name: Remove Coolify docker volumes + community.docker.docker_volume: + name: "{{ item.Name }}" + state: absent + force: true + loop: "{{ coolify_volumes.volumes | default([]) }}" + when: coolify_volumes is defined and coolify_volumes.volumes | length > 0 + tags: coolify, docker, deletion + +- name: Remove Coolify docker network + community.docker.docker_network: + name: "{{ coolify_docker_network }}" + state: absent + force: "{{ coolify_remove_data_on_absent | bool }}" + ignore_errors: true + when: coolify_remove_data_on_absent | bool + tags: coolify, docker, deletion + +- name: Prune Coolify images + become: true + community.docker.docker_prune: + images: true + images_filters: + reference: "ghcr.io/coollabsio/*" + build_cache: true + ignore_errors: true + when: coolify_remove_data_on_absent | bool + tags: coolify, docker, deletion + +- name: Prune unused Docker resources + become: true + community.docker.docker_prune: + containers: true + images: false + networks: true + volumes: false + builder_cache: true + ignore_errors: true + when: coolify_remove_data_on_absent | bool + tags: coolify, docker, deletion + +- name: Verify Coolify removal + community.docker.docker_container_info: + name: "{{ item }}" + loop: "{{ coolify_container_names }}" + register: coolify_containers_post_uninstall + ignore_errors: true + tags: coolify, deletion, verification + +- name: Display uninstallation message + ansible.builtin.debug: + msg: + - "Coolify has been uninstalled" + - "Application data preserved: {{ not coolify_remove_data_on_absent }}" + tags: coolify, deletion diff --git a/infrastructure/iac/ansible/roles/coolify/tasks/install.yaml b/infrastructure/iac/ansible/roles/coolify/tasks/install.yaml new file mode 100644 index 0000000..80ac528 --- /dev/null +++ b/infrastructure/iac/ansible/roles/coolify/tasks/install.yaml @@ -0,0 +1,111 @@ +--- +- name: Install prerequisites (apt) + ansible.builtin.apt: + name: + - curl + - openssl + state: present + update_cache: true + cache_valid_time: 3600 + tags: coolify, prerequisites, installation + +- name: Ensure Docker service is started and enabled + ansible.builtin.systemd: + name: docker + state: started + enabled: true + tags: coolify, docker, installation + +- name: Create Coolify directories + ansible.builtin.file: + path: "{{ item }}" + state: directory + owner: "{{ coolify_owner }}" + group: "{{ coolify_group }}" + mode: '0750' + loop: + - "{{ coolify_base_dir }}/source" + - "{{ coolify_base_dir }}/ssh/keys" + - "{{ coolify_base_dir }}/ssh/mux" + - "{{ coolify_base_dir }}/applications" + - "{{ coolify_base_dir }}/databases" + - "{{ coolify_base_dir }}/backups" + - "{{ coolify_base_dir }}/services" + - "{{ coolify_base_dir }}/proxy/dynamic" + - "{{ coolify_base_dir }}/webhooks-during-maintenance" + tags: coolify, files, installation + +- name: Download Coolify configuration files + ansible.builtin.get_url: + url: "https://cdn.coollabs.io/coolify/{{ item.file }}" + dest: "{{ coolify_base_dir }}/source/{{ item.dest }}" + mode: '0644' + loop: + - { file: "docker-compose.yml", dest: "docker-compose.yml" } + - { file: "docker-compose.prod.yml", dest: "docker-compose.prod.yml" } + - { file: "upgrade.sh", dest: "upgrade.sh" } + tags: coolify, files, installation + +- name: Ensure .env exists from template (only when missing) + ansible.builtin.stat: + path: "{{ coolify_base_dir }}/source/.env" + register: env_file_check + tags: coolify, files, installation + +- name: Create .env from template when missing + ansible.builtin.template: + src: "templates/.env.production.j2" + dest: "{{ coolify_base_dir }}/source/.env" + mode: '0640' + when: not env_file_check.stat.exists + tags: coolify, files, installation + +- name: Ensure correct ownership and permissions recursively + ansible.builtin.file: + path: "{{ coolify_base_dir }}" + owner: "{{ coolify_owner }}" + group: "{{ coolify_group }}" + mode: '0750' + recurse: true + tags: coolify, permissions, installation + +- name: Ensure Docker network exists + become: true + community.docker.docker_network: + name: "{{ coolify_docker_network }}" + driver: bridge + attachable: true + state: present + tags: coolify, docker, installation + +- name: Start Coolify services + become: true + community.docker.docker_compose_v2: + project_src: "{{ coolify_base_dir }}/source" + files: "{{ coolify_compose_files }}" + pull: always + state: present + wait: true + wait_timeout: 300 + tags: coolify, docker, installation + +- name: Wait for Coolify HTTP to respond + ansible.builtin.uri: + url: "http://localhost:{{ coolify_http_port }}" + method: GET + status_code: 200 + timeout: 30 + body_format: json + register: coolify_health + until: coolify_health.status == 200 + retries: 10 + delay: 10 + tags: coolify, health, installation + +- name: Show installed message + ansible.builtin.debug: + msg: + - "Coolify installed successfully" + - "All containers are healthy and responding" + - "Access at: http://{{ ansible_host }}:{{ coolify_http_port }}" + tags: coolify, installation diff --git a/infrastructure/iac/ansible/roles/coolify/tasks/main.yaml b/infrastructure/iac/ansible/roles/coolify/tasks/main.yaml new file mode 100644 index 0000000..c82902c --- /dev/null +++ b/infrastructure/iac/ansible/roles/coolify/tasks/main.yaml @@ -0,0 +1,51 @@ +--- +- name: Validate coolify_state parameter + ansible.builtin.assert: + that: + - coolify_state in ['present', 'absent', 'latest'] + msg: "coolify_state must be one of: present, absent, latest" + tags: always + +- name: Check if Coolify is installed (compose file exists) + ansible.builtin.stat: + path: "{{ coolify_base_dir }}/source/{{ coolify_compose_files[0] }}" + register: coolify_installed + tags: always + +- name: Check Coolify container status + community.docker.docker_container_info: + name: "{{ item }}" + loop: "{{ coolify_container_names }}" + register: coolify_containers + when: coolify_installed.stat.exists + tags: always + +- name: Set Coolify health fact + ansible.builtin.set_fact: + coolify_healthy: "{{ coolify_containers.results | selectattr('container', 'defined') | selectattr('container.State.Health.Status', 'defined') | selectattr('container.State.Health.Status', 'equalto', 'healthy') | list | length == coolify_container_names | length }}" + when: coolify_containers is defined and coolify_containers.results is defined + tags: always + +- name: Include deletion tasks if state is absent + ansible.builtin.include_tasks: delete.yaml + when: coolify_state == 'absent' + tags: coolify, deletion + +- name: Include installation tasks when desired and not installed + ansible.builtin.include_tasks: install.yaml + when: (coolify_state in ['present','latest']) and not coolify_installed.stat.exists + tags: coolify, installation + +- name: Include update tasks when latest requested and already installed + ansible.builtin.include_tasks: update.yaml + when: (coolify_state == 'latest') and coolify_installed.stat.exists + tags: coolify, update + +- name: Show status when present and already installed + ansible.builtin.debug: + msg: + - "Coolify is already installed and running" + - "Containers healthy: {{ coolify_healthy | default('unknown') }}" + - "Access at: http://{{ ansible_host }}:{{ coolify_http_port }}" + when: (coolify_state == 'present') and coolify_installed.stat.exists | default(false) + tags: coolify, status diff --git a/infrastructure/iac/ansible/roles/coolify/tasks/update.yaml b/infrastructure/iac/ansible/roles/coolify/tasks/update.yaml new file mode 100644 index 0000000..99d2add --- /dev/null +++ b/infrastructure/iac/ansible/roles/coolify/tasks/update.yaml @@ -0,0 +1,32 @@ +--- +- name: Update Coolify services with recreate + community.docker.docker_compose_v2: + project_src: "{{ coolify_base_dir }}/source" + files: "{{ coolify_compose_files }}" + pull: always + state: present + recreate: always + wait: true + wait_timeout: 300 + tags: coolify, update + +- name: Wait for Coolify HTTP to respond after update + ansible.builtin.uri: + url: "http://localhost:{{ coolify_http_port }}" + method: GET + status_code: 200 + timeout: 30 + body_format: json + register: coolify_health_after_update + until: coolify_health_after_update.status == 200 + retries: 10 + delay: 10 + tags: coolify, update, health + +- name: Show update success message + ansible.builtin.debug: + msg: + - "Coolify updated successfully to latest version" + - "All containers are healthy and responding" + - "Access at: http://{{ ansible_host }}:{{ coolify_http_port }}" + tags: coolify, update diff --git a/infrastructure/iac/ansible/roles/coolify/templates/.env.production.j2 b/infrastructure/iac/ansible/roles/coolify/templates/.env.production.j2 new file mode 100644 index 0000000..4e8e45c --- /dev/null +++ b/infrastructure/iac/ansible/roles/coolify/templates/.env.production.j2 @@ -0,0 +1,18 @@ +APP_ID={{ coolify_app_id | default(lookup('pipe','openssl rand -hex 16')) }} +APP_NAME={{ coolify_app_name | default('Coolify') }} +APP_KEY={{ coolify_app_key | default('base64:' ~ lookup('pipe','openssl rand -base64 32')) }} + +DB_USERNAME={{ coolify_db_username | default('coolify') }} +DB_PASSWORD={{ coolify_db_password | default(lookup('pipe','openssl rand -base64 32')) }} + +REDIS_PASSWORD={{ coolify_redis_password | default(lookup('pipe','openssl rand -base64 32')) }} + +PUSHER_APP_ID={{ coolify_pusher_app_id | default(lookup('pipe','openssl rand -hex 32')) }} +PUSHER_APP_KEY={{ coolify_pusher_app_key | default(lookup('pipe','openssl rand -hex 32')) }} +PUSHER_APP_SECRET={{ coolify_pusher_app_secret | default(lookup('pipe','openssl rand -hex 32')) }} + +ROOT_USERNAME={{ coolify_root_username | default('') }} +ROOT_USER_EMAIL={{ coolify_root_email | default('') }} +ROOT_USER_PASSWORD={{ coolify_root_password | default('') }} + +REGISTRY_URL={{ coolify_registry_url | default('ghcr.io') }} diff --git a/infrastructure/iac/ansible/roles/dokploy/defaults/main.yaml b/infrastructure/iac/ansible/roles/dokploy/defaults/main.yaml new file mode 100644 index 0000000..835c69b --- /dev/null +++ b/infrastructure/iac/ansible/roles/dokploy/defaults/main.yaml @@ -0,0 +1,20 @@ +--- +dokploy_state: present # present | absent | latest +dokploy_remove_data_on_absent: false +dokploy_update_all_services: true +dokploy_config_dir: /etc/dokploy +dokploy_http_port: 3000 +dokploy_advertise_addr: "" + +dokploy_postgres_image: postgres:16-alpine +dokploy_redis_image: redis:8-alpine +dokploy_dokploy_image: dokploy/dokploy:latest +dokploy_traefik_image: traefik:v3.6.1 + +dokploy_postgres_user: dokploy +dokploy_postgres_db: dokploy +dokploy_postgres_password: "{{ lookup('password', '/dev/null chars=ascii_letters,digits length=32') }}" + +dokploy_docker_network: dokploy-network + +dokploy_constraint_node_role: manager diff --git a/infrastructure/iac/ansible/roles/dokploy/tasks/delete.yaml b/infrastructure/iac/ansible/roles/dokploy/tasks/delete.yaml new file mode 100644 index 0000000..06dcfa4 --- /dev/null +++ b/infrastructure/iac/ansible/roles/dokploy/tasks/delete.yaml @@ -0,0 +1,55 @@ +--- +- name: Remove Dokploy services + become: true + community.docker.docker_swarm_service: + name: "{{ item }}" + state: absent + loop: + - dokploy-traefik + - dokploy + - dokploy-redis + - dokploy-postgres + ignore_errors: true + tags: dokploy, deletion + +- name: Leave Docker Swarm + become: true + community.docker.docker_swarm: + state: absent + ignore_errors: true + tags: dokploy, deletion + +- name: Remove Dokploy network + become: true + community.docker.docker_network: + name: "{{ dokploy_docker_network }}" + state: absent + ignore_errors: true + tags: dokploy, deletion + +- name: Remove Dokploy configuration directory + ansible.builtin.file: + path: "{{ dokploy_config_dir }}" + state: absent + when: dokploy_remove_data_on_absent | bool + tags: dokploy, deletion, files + +- name: Remove Dokploy volumes + become: true + community.docker.docker_volume: + name: "{{ item }}" + state: absent + loop: + - dokploy-postgres-database + - redis-data-volume + - dokploy-docker-config + when: dokploy_remove_data_on_absent | bool + ignore_errors: true + tags: dokploy, deletion, volumes + +- name: Display uninstallation message + ansible.builtin.debug: + msg: + - "Dokploy has been uninstalled" + - "Application data preserved: {{ not dokploy_remove_data_on_absent }}" + tags: dokploy, deletion diff --git a/infrastructure/iac/ansible/roles/dokploy/tasks/install.yaml b/infrastructure/iac/ansible/roles/dokploy/tasks/install.yaml new file mode 100644 index 0000000..985157b --- /dev/null +++ b/infrastructure/iac/ansible/roles/dokploy/tasks/install.yaml @@ -0,0 +1,273 @@ +--- +- name: Check if Docker is installed + ansible.builtin.command: + cmd: docker --version + ignore_errors: false + changed_when: false + tags: dokploy, docker, installation + +- name: Ensure Docker service is started and enabled + ansible.builtin.systemd: + name: docker + state: started + enabled: true + tags: dokploy, docker, installation + +- name: Leave existing Docker Swarm if any + become: true + community.docker.docker_swarm: + state: absent + ignore_errors: true + tags: dokploy, swarm, installation + +- name: Determine advertise address + block: + - name: Get private IP address + ansible.builtin.set_fact: + private_ip: "{{ ansible_default_ipv4.address }}" + when: dokploy_advertise_addr == "" + + - name: Set advertise address + ansible.builtin.set_fact: + effective_advertise_addr: "{{ dokploy_advertise_addr | default(private_ip) }}" + tags: dokploy, network, installation + +- name: Validate advertise address + ansible.builtin.assert: + that: + - effective_advertise_addr is defined + - effective_advertise_addr != "" + msg: "Could not determine advertise address. Please set dokploy_advertise_addr variable." + tags: dokploy, network, installation + +- name: Initialize Docker Swarm + become: true + community.docker.docker_swarm: + state: present + advertise_addr: "{{ effective_advertise_addr }}" + listen_addr: "{{ effective_advertise_addr }}" + tags: dokploy, swarm, installation + +- name: Create dokploy overlay network + become: true + community.docker.docker_network: + name: "{{ dokploy_docker_network }}" + driver: overlay + attachable: true + state: present + tags: dokploy, network, installation + +- name: Create Dokploy configuration directory + ansible.builtin.file: + path: "{{ dokploy_config_dir }}" + state: directory + mode: "0777" + tags: dokploy, files, installation + +- name: Pull all service images (when update all services requested) + become: true + community.docker.docker_image: + name: "{{ item }}" + source: pull + loop: + - "{{ dokploy_postgres_image }}" + - "{{ dokploy_redis_image }}" + - "{{ dokploy_dokploy_image }}" + - "{{ dokploy_traefik_image }}" + when: dokploy_update_all_services | bool + tags: dokploy, images, installation + +- name: Pull only Dokploy image (when latest requested and not updating all) + become: true + community.docker.docker_image: + name: "{{ dokploy_dokploy_image }}" + source: pull + when: + - not (dokploy_update_all_services | bool) + - dokploy_state == 'latest' + tags: dokploy, images, installation + +- name: Deploy PostgreSQL service + become: true + community.docker.docker_swarm_service: + name: dokploy-postgres + image: "{{ dokploy_postgres_image }}" + placement: + constraints: + - "node.role=={{ dokploy_constraint_node_role }}" + networks: + - name: "{{ dokploy_docker_network }}" + aliases: + - dokploy-postgres + force_update: "{{ (dokploy_state == 'latest') | bool }}" + update_config: + parallelism: 1 + order: stop-first + failure_action: rollback + restart_config: + condition: any + delay: 5s + max_attempts: 3 + window: 120s + env: + POSTGRES_USER: "{{ dokploy_postgres_user }}" + POSTGRES_DB: "{{ dokploy_postgres_db }}" + POSTGRES_PASSWORD: "{{ dokploy_postgres_password }}" + mounts: + - type: volume + source: dokploy-postgres-database + target: /var/lib/postgresql/data + state: present + tags: dokploy, database, installation + +- name: Deploy Redis service + become: true + community.docker.docker_swarm_service: + name: dokploy-redis + image: "{{ dokploy_redis_image }}" + placement: + constraints: + - "node.role=={{ dokploy_constraint_node_role }}" + networks: + - name: "{{ dokploy_docker_network }}" + aliases: + - dokploy-redis + force_update: "{{ (dokploy_state == 'latest') | bool }}" + update_config: + parallelism: 1 + order: stop-first + failure_action: rollback + restart_config: + condition: any + delay: 5s + max_attempts: 3 + window: 120s + mounts: + - type: volume + source: redis-data-volume + target: /data + state: present + tags: dokploy, redis, installation + +- name: Deploy Dokploy main service + become: true + community.docker.docker_swarm_service: + name: dokploy + image: "{{ dokploy_dokploy_image }}" + replicas: 1 + networks: + - name: "{{ dokploy_docker_network }}" + aliases: + - dokploy + force_update: "{{ (dokploy_state == 'latest') | bool }}" + update_config: + parallelism: 1 + order: stop-first + failure_action: rollback + restart_config: + condition: any + delay: 5s + max_attempts: 3 + window: 120s + mounts: + - type: bind + source: /var/run/docker.sock + target: /var/run/docker.sock + - type: bind + source: "{{ dokploy_config_dir }}" + target: /etc/dokploy + - type: volume + source: dokploy-docker-config + target: /root/.docker + publish: + - published_port: "{{ dokploy_http_port }}" + target_port: 3000 + protocol: tcp + mode: host + placement: + constraints: + - "node.role=={{ dokploy_constraint_node_role }}" + env: + ADVERTISE_ADDR: "{{ effective_advertise_addr }}" + DATABASE_URL: "postgres://{{ dokploy_postgres_user }}:{{ dokploy_postgres_password }}@dokploy-postgres:5432/{{ dokploy_postgres_db }}" + state: present + tags: dokploy, main, installation + +- name: Wait for Dokploy to start + ansible.builtin.pause: + seconds: 4 + tags: dokploy, wait, installation + +- name: Create Traefik configuration directories + ansible.builtin.file: + path: "{{ item }}" + state: directory + mode: "0755" + loop: + - "{{ dokploy_config_dir }}/traefik" + - "{{ dokploy_config_dir }}/traefik/dynamic" + tags: dokploy, traefik, installation + +- name: Deploy Traefik service + become: true + community.docker.docker_swarm_service: + name: dokploy-traefik + image: "{{ dokploy_traefik_image }}" + placement: + constraints: + - "node.role=={{ dokploy_constraint_node_role }}" + networks: + - name: "{{ dokploy_docker_network }}" + aliases: + - traefik + update_config: + parallelism: 1 + order: start-first + restart_config: + condition: any + mounts: + - type: bind + source: "{{ dokploy_config_dir }}/traefik/traefik.yml" + target: /etc/traefik/traefik.yml + - type: bind + source: "{{ dokploy_config_dir }}/traefik/dynamic" + target: /etc/dokploy/traefik/dynamic + - type: bind + source: /var/run/docker.sock + target: /var/run/docker.sock + publish: + - published_port: 443 + target_port: 443 + protocol: tcp + mode: host + - published_port: 80 + target_port: 80 + protocol: tcp + mode: host + - published_port: 443 + target_port: 443 + protocol: udp + mode: host + state: present + force_update: "{{ (dokploy_state == 'latest') | bool }}" + tags: dokploy, traefik, installation + +- name: Wait for Dokploy HTTP to respond + ansible.builtin.uri: + url: "http://localhost:{{ dokploy_http_port }}" + method: GET + status_code: 200 + timeout: 30 + register: dokploy_health + until: dokploy_health.status == 200 + retries: 10 + delay: 10 + tags: dokploy, health, installation + +- name: Show installation message + ansible.builtin.debug: + msg: + - "Dokploy installed successfully" + - "Using advertise address: {{ effective_advertise_addr }}" + - "Access at: http://{{ ansible_host }}:{{ dokploy_http_port }}" + tags: dokploy, installation diff --git a/infrastructure/iac/ansible/roles/dokploy/tasks/main.yaml b/infrastructure/iac/ansible/roles/dokploy/tasks/main.yaml new file mode 100644 index 0000000..d963268 --- /dev/null +++ b/infrastructure/iac/ansible/roles/dokploy/tasks/main.yaml @@ -0,0 +1,25 @@ +--- +- name: Validate dokploy_state parameter + ansible.builtin.assert: + that: + - dokploy_state in ['present', 'absent', 'latest'] + msg: "dokploy_state must be one of: present, absent, latest" + tags: always + +- name: Check if Dokploy services exist + become: true + community.docker.docker_swarm_service_info: + name: dokploy + register: dokploy_services + ignore_errors: true + tags: always + +- name: Include deletion tasks if state is absent + ansible.builtin.include_tasks: delete.yaml + when: dokploy_state == 'absent' + tags: dokploy, deletion + +- name: Include installation/update tasks when desired + ansible.builtin.include_tasks: install.yaml + when: dokploy_state in ['present', 'latest'] + tags: dokploy, installation diff --git a/infrastructure/iac/ansible/roles/monitoring/handlers/main.yaml b/infrastructure/iac/ansible/roles/monitoring/handlers/main.yaml new file mode 100644 index 0000000..59d8b2f --- /dev/null +++ b/infrastructure/iac/ansible/roles/monitoring/handlers/main.yaml @@ -0,0 +1,5 @@ +--- +- name: Restart prometheus-node-exporter + ansible.builtin.service: + name: prometheus-node-exporter + state: restarted diff --git a/infrastructure/iac/ansible/roles/monitoring/tasks/main.yaml b/infrastructure/iac/ansible/roles/monitoring/tasks/main.yaml new file mode 100644 index 0000000..e3831d0 --- /dev/null +++ b/infrastructure/iac/ansible/roles/monitoring/tasks/main.yaml @@ -0,0 +1,22 @@ +--- +- name: Install monitoring tools + ansible.builtin.apt: + name: "{{ system_packages.monitoring }}" + state: present + tags: monitoring + +- name: Manage Prometheus node exporter + block: + - name: Install Prometheus node exporter + ansible.builtin.apt: + name: prometheus-node-exporter + state: present + notify: Restart prometheus-node-exporter + + - name: Ensure Prometheus node exporter is running and enabled + ansible.builtin.systemd: + name: prometheus-node-exporter + state: started + enabled: true + when: monitoring_prometheus_node_exporter | bool + tags: monitoring, prometheus diff --git a/infrastructure/iac/ansible/roles/security/handlers/main.yaml b/infrastructure/iac/ansible/roles/security/handlers/main.yaml new file mode 100644 index 0000000..3785adb --- /dev/null +++ b/infrastructure/iac/ansible/roles/security/handlers/main.yaml @@ -0,0 +1,6 @@ +--- +- name: Reload nftables + ansible.builtin.systemd: + name: nftables + state: reloaded + tags: security, nftables diff --git a/infrastructure/iac/ansible/roles/security/tasks/main.yaml b/infrastructure/iac/ansible/roles/security/tasks/main.yaml new file mode 100644 index 0000000..6a872cf --- /dev/null +++ b/infrastructure/iac/ansible/roles/security/tasks/main.yaml @@ -0,0 +1,38 @@ +--- +- name: Install security packages + ansible.builtin.apt: + name: "{{ system_packages.security }}" + state: present + update_cache: true + tags: security + +- name: Install nftables + ansible.builtin.apt: + name: + - nftables + state: present + update_cache: true + tags: security, nftables + +- name: Render nftables configuration + ansible.builtin.template: + src: nftables.conf.j2 + dest: /etc/nftables.conf + owner: root + group: root + mode: '0644' + validate: 'nft -c -f %s' + notify: Reload nftables + tags: security, nftables + +- name: Enable and start nftables + ansible.builtin.systemd: + name: nftables + state: started + enabled: true + tags: security, nftables + +- name: Install and configure fail2ban + include_role: + name: geerlingguy.security + tags: security diff --git a/infrastructure/iac/ansible/roles/security/templates/nftables.conf.j2 b/infrastructure/iac/ansible/roles/security/templates/nftables.conf.j2 new file mode 100644 index 0000000..618a139 --- /dev/null +++ b/infrastructure/iac/ansible/roles/security/templates/nftables.conf.j2 @@ -0,0 +1,34 @@ +#!/usr/sbin/nft -f + +table inet filter { + chain input { + type filter hook input priority 0; + policy {{ security_firewall_default_policy | default('drop') }}; + + ct state established,related accept + iifname lo accept + + # allow ICMP + ip protocol icmp icmp type { echo-request, echo-reply, destination-unreachable, time-exceeded } accept + ip6 nexthdr icmpv6 icmpv6 type { echo-request, echo-reply, destination-unreachable, packet-too-big, time-exceeded, parameter-problem } accept + +{% for p in security_firewall_allowed_ports %} +{% set parts = p.split('/') %} +{% set port = parts[0] %} +{% set proto = parts[1] if parts|length > 1 else 'tcp' %} + {{ proto }} dport {{ port }} accept +{% endfor %} + + reject with icmpx type port-unreachable + } + + chain forward { + type filter hook forward priority 0; + policy accept; + } + + chain output { + type filter hook output priority 0; + policy accept; + } +} diff --git a/infrastructure/iac/ansible/site.yaml b/infrastructure/iac/ansible/site.yaml new file mode 100644 index 0000000..8d589e8 --- /dev/null +++ b/infrastructure/iac/ansible/site.yaml @@ -0,0 +1,12 @@ +--- +- name: Import base system setup + ansible.builtin.import_playbook: base_setup.yaml + +- name: Import Docker setup + ansible.builtin.import_playbook: docker.yaml + +- name: Import application deployment + ansible.builtin.import_playbook: apps.yaml + +- name: Import post-deployment validation + ansible.builtin.import_playbook: validation.yaml diff --git a/infrastructure/iac/ansible/validation.yaml b/infrastructure/iac/ansible/validation.yaml new file mode 100644 index 0000000..763a0fa --- /dev/null +++ b/infrastructure/iac/ansible/validation.yaml @@ -0,0 +1,60 @@ +--- +- name: Post-deployment validation and health checks + hosts: servers + gather_facts: true + become: false + serial: "100%" + + tasks: + - name: Verify SSH connectivity on custom port + ansible.builtin.wait_for: + port: "{{ security_ssh_port | default(22) }}" + host: "{{ ansible_host | default(inventory_hostname) }}" + timeout: 60 + delay: 5 + state: started + tags: validation, networking + + - name: Check critical system services + ansible.builtin.systemd: + name: "{{ item }}" + state: started + enabled: true + loop: + - ssh + - docker + - fail2ban + - nftables + tags: validation + ignore_errors: true + + - name: Run comprehensive system health checks + block: + - name: Set root mount fact + ansible.builtin.set_fact: + root_mount: "{{ ansible_mounts | selectattr('mount', 'equalto', '/') | list | first }}" + tags: validation, health + + - name: Check load average + ansible.builtin.shell: cat /proc/loadavg | awk '{print $1}' + register: load_avg + changed_when: false + + - name: Check Docker status + ansible.builtin.shell: + cmd: docker info >/dev/null 2>&1 && echo "healthy" || echo "unhealthy" + register: docker_status + changed_when: false + ignore_errors: true + + - name: Display comprehensive health status + ansible.builtin.debug: + msg: + - "Health check results for {{ inventory_hostname }}:" + - "Disk usage: {{ ((root_mount.size_total - root_mount.size_available) / root_mount.size_total * 100) | round(2) }}%" + - "Memory usage: {{ ((ansible_memtotal_mb - ansible_memfree_mb) / ansible_memtotal_mb * 100) | round(2) }}%" + - "Load average (1m): {{ load_avg.stdout }}" + - "Docker: {{ docker_status.stdout }}" + tags: always, health + + tags: validation, health