From b2b7e3a7dbfad5eb1e132c479087d5debb308913 Mon Sep 17 00:00:00 2001 From: ishaan-arora-1 Date: Fri, 11 Jul 2025 15:31:57 +0530 Subject: [PATCH] ansible mods --- ansible/ansible.cfg | 8 + ansible/deploy.sh | 36 +++++ ansible/education-website.service.j2 | 17 +++ ansible/env.j2 | 82 +++++++++++ ansible/nginx-http.conf.j2 | 57 ++++++++ ansible/nginx.conf.j2 | 138 ++++++++++++++++++ ansible/playbook.yml | 211 +++++++++++++++++++++++++++ ansible/update.sh.j2 | 8 + 8 files changed, 557 insertions(+) create mode 100644 ansible/ansible.cfg create mode 100644 ansible/deploy.sh create mode 100644 ansible/education-website.service.j2 create mode 100644 ansible/env.j2 create mode 100644 ansible/nginx-http.conf.j2 create mode 100644 ansible/nginx.conf.j2 create mode 100644 ansible/playbook.yml create mode 100644 ansible/update.sh.j2 diff --git a/ansible/ansible.cfg b/ansible/ansible.cfg new file mode 100644 index 000000000..76e9618d6 --- /dev/null +++ b/ansible/ansible.cfg @@ -0,0 +1,8 @@ +[defaults] +inventory = hosts +pipelining = True +# host_key_checking = False # use default secure behavior (commented to fall back to default) +interpreter_python = /usr/bin/python3 + +[ssh_connection] +retries = 3 diff --git a/ansible/deploy.sh b/ansible/deploy.sh new file mode 100644 index 000000000..5afe07fdb --- /dev/null +++ b/ansible/deploy.sh @@ -0,0 +1,36 @@ +#!/bin/bash +set -e +# Change to script directory +cd "$(dirname "$0")" + +# ─── Load env file from project root if present ──────────────────────────── +PROJECT_ROOT="$(cd .. && pwd)" +if [[ -f "$PROJECT_ROOT/.env" ]]; then + # shellcheck disable=SC1091 + set -a + source "$PROJECT_ROOT/.env" + set +a +fi + +# ─── VPS connection details ─────────────────────────────────────────────── +VPS_IP="${VPS_IP:-}" +VPS_USER="${VPS_USER:-root}" +VPS_PASSWORD="${VPS_PASSWORD:-}" + +if [[ -z "$VPS_IP" ]]; then + echo "❌ VPS_IP not set (export or add to .env)." >&2 + exit 1 +fi + +# Forward any extra args to ansible-playbook +EXTRA_ARGS=("$@") + +ANSIBLE_CMD=(ansible-playbook -i "${VPS_IP}," -u "$VPS_USER" playbook.yml) + +if [[ -n "$VPS_PASSWORD" ]]; then + ANSIBLE_CMD+=(--extra-vars "ansible_password=${VPS_PASSWORD}") +else + ANSIBLE_CMD+=(--ask-pass) +fi + +exec "${ANSIBLE_CMD[@]}" "${EXTRA_ARGS[@]}" diff --git a/ansible/education-website.service.j2 b/ansible/education-website.service.j2 new file mode 100644 index 000000000..72db72386 --- /dev/null +++ b/ansible/education-website.service.j2 @@ -0,0 +1,17 @@ +[Unit] +Description={{ project_name }} website +After=network.target + +[Service] +User={{ vps_user }} +Group={{ vps_user }} +WorkingDirectory=/home/{{ vps_user }}/{{ project_name }} +Environment="PATH=/home/{{ vps_user }}/{{ project_name }}/venv/bin" +ExecStart=/home/{{ vps_user }}/{{ project_name }}/venv/bin/gunicorn \ + web.asgi:application -w 4 -k uvicorn.workers.UvicornWorker \ + --timeout 120 \ + -b 127.0.0.1:{{ app_port }} +Restart=always + +[Install] +WantedBy=multi-user.target diff --git a/ansible/env.j2 b/ansible/env.j2 new file mode 100644 index 000000000..2c6b743f9 --- /dev/null +++ b/ansible/env.j2 @@ -0,0 +1,82 @@ +# Django Core Settings +SECRET_KEY="{{ secret_key }}" +DEBUG="{{ debug }}" +ENVIRONMENT="production" + +ALLOWED_HOSTS="{{ allowed_hosts }}" +CSRF_TRUSTED_ORIGINS="{{ csrf_trusted_origins }}" + +# Database configuration (Production PostgreSQL) +DB_NAME="{{ db_name }}" +DB_USER="{{ db_user }}" +DB_PASSWORD="{{ db_password }}" +DB_HOST="localhost" +DB_PORT="5432" + +# Static files +STATIC_ROOT="/home/{{ vps_user }}/{{ project_name }}/staticfiles" + +# Email Configuration +{% if sendgrid_api_key %} +SENDGRID_API_KEY="{{ sendgrid_api_key }}" +{% endif %} +{% if sendgrid_password %} +SENDGRID_PASSWORD="{{ sendgrid_password }}" +{% endif %} +{% if email_from %} +EMAIL_FROM="{{ email_from }}" +{% endif %} + +# Slack webhook +{% if slack_webhook_url %} +SLACK_WEBHOOK_URL="{{ slack_webhook_url }}" +{% endif %} + +# Stripe configuration +{% if stripe_secret_key %} +STRIPE_SECRET_KEY="{{ stripe_secret_key }}" +{% endif %} +{% if stripe_publishable_key %} +STRIPE_PUBLISHABLE_KEY="{{ stripe_publishable_key }}" +{% endif %} +{% if stripe_webhook_secret %} +STRIPE_WEBHOOK_SECRET="{{ stripe_webhook_secret }}" +{% endif %} +{% if stripe_connect_webhook_secret %} +STRIPE_CONNECT_WEBHOOK_SECRET="{{ stripe_connect_webhook_secret }}" +{% endif %} + +# Security & Encryption +{% if message_encryption_key %} +MESSAGE_ENCRYPTION_KEY="{{ message_encryption_key }}" +{% endif %} + +# Admin Configuration +{% if admin_url %} +ADMIN_URL="{{ admin_url }}" +{% endif %} + +# Google Cloud Storage Configuration +{% if gs_bucket_name %} +GS_BUCKET_NAME="{{ gs_bucket_name }}" +{% endif %} +{% if gs_project_id %} +GS_PROJECT_ID="{{ gs_project_id }}" +{% endif %} +{% if service_account_file %} +SERVICE_ACCOUNT_FILE="{{ service_account_file }}" +{% endif %} + +# Django Debug (separate from DEBUG) +{% if django_debug %} +DJANGO_DEBUG="{{ django_debug }}" +{% endif %} + +# Redis Configuration +{% if redis_url %} +REDIS_URL="{{ redis_url }}" +{% endif %} + +# Cookie security overrides (for staging without HTTPS) +CSRF_COOKIE_SECURE="{{ csrf_cookie_secure }}" +SESSION_COOKIE_SECURE="{{ session_cookie_secure }}" diff --git a/ansible/nginx-http.conf.j2 b/ansible/nginx-http.conf.j2 new file mode 100644 index 000000000..9c4cac382 --- /dev/null +++ b/ansible/nginx-http.conf.j2 @@ -0,0 +1,57 @@ +user www-data; +worker_processes auto; +pid /run/nginx.pid; +include /etc/nginx/modules-enabled/*.conf; + +events { + worker_connections 768; +} + +http { + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 2048; + + include /etc/nginx/mime.types; + default_type application/octet-stream; + + access_log /var/log/nginx/access.log; + error_log /var/log/nginx/error.log; + + gzip on; + + server { + listen 80 default_server; + {% if domain_name %} + server_name {{ domain_name }}{% if domain_name and not domain_name.startswith('www.') %} www.{{ domain_name }}{% endif %}; + {% else %} + server_name _; + {% endif %} + + location /.well-known/acme-challenge/ { + root /var/www/html; + } + + location /static/ { + alias /home/{{ vps_user }}/{{ project_name }}/staticfiles/; + } + + location /media/ { + alias /home/{{ vps_user }}/{{ project_name }}/media/; + } + + location / { + proxy_pass http://127.0.0.1:{{ app_port }}; + # WebSocket support + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + } +} diff --git a/ansible/nginx.conf.j2 b/ansible/nginx.conf.j2 new file mode 100644 index 000000000..091c1ef02 --- /dev/null +++ b/ansible/nginx.conf.j2 @@ -0,0 +1,138 @@ +user www-data; +worker_processes auto; +pid /run/nginx.pid; +include /etc/nginx/modules-enabled/*.conf; + +events { + worker_connections 768; + # multi_accept on; +} + +http { + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 2048; + + include /etc/nginx/mime.types; + default_type application/octet-stream; + + ssl_protocols TLSv1.2 TLSv1.3; + ssl_prefer_server_ciphers on; + + access_log /var/log/nginx/access.log; + error_log /var/log/nginx/error.log; + + gzip on; + + # Disable gzip for SSE endpoints + gzip_disable "MSIE [1-6]\."; + + {% if enable_https_redirect == "True" and domain_name %} + server { + listen 80 default_server; + server_name {{ domain_name }}{% if domain_name and not domain_name.startswith('www.') %} www.{{ domain_name }}{% endif %}; + + # This block is for certbot http-01 challenge + location /.well-known/acme-challenge/ { + root /var/www/html; + } + + location / { + return 301 https://$host$request_uri; + } + } + + server { + listen 443 ssl http2; + server_name {{ domain_name }}{% if domain_name and not domain_name.startswith('www.') %} www.{{ domain_name }}{% endif %}; + + # SSL configuration managed by Certbot + ssl_certificate /etc/letsencrypt/live/{{ domain_name }}/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/{{ domain_name }}/privkey.pem; + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; + + location /static/ { + alias /home/{{ vps_user }}/{{ project_name }}/staticfiles/; + } + + location /media/ { + alias /home/{{ vps_user }}/{{ project_name }}/media/; + } + + # Special handling for SSE endpoints (admin logs) + location ~ ^/admin-[^/]+/logs/$ { + proxy_pass http://127.0.0.1:{{ app_port }}; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # SSE-specific settings + proxy_set_header Connection ''; + proxy_http_version 1.1; + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 24h; + proxy_send_timeout 24h; + + # Disable gzip for SSE + gzip off; + + # Add SSE headers + add_header Cache-Control 'no-cache, no-store, must-revalidate'; + add_header Pragma 'no-cache'; + add_header Expires '0'; + add_header X-Accel-Buffering 'no'; + } + + location / { + proxy_pass http://127.0.0.1:{{ app_port }}; + # WebSocket support + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host {{ domain_name | default('alphaonelabs.com') }}; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Standard proxy settings + proxy_buffering on; + proxy_buffer_size 4k; + proxy_buffers 8 4k; + } + } + {% else %} + server { + listen 80 default_server; + {% if domain_name %} + server_name {{ domain_name }}{% if domain_name and not domain_name.startswith('www.') %} www.{{ domain_name }}{% endif %}; + {% else %} + server_name _; + {% endif %} + + location /static/ { + alias /home/{{ vps_user }}/{{ project_name }}/staticfiles/; + } + + location /media/ { + alias /home/{{ vps_user }}/{{ project_name }}/media/; + } + + location / { + proxy_pass http://127.0.0.1:{{ app_port }}; + # WebSocket support + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host {{ domain_name | default('alphaonelabs.com') }}; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + } + {% endif %} +} diff --git a/ansible/playbook.yml b/ansible/playbook.yml new file mode 100644 index 000000000..b4a121709 --- /dev/null +++ b/ansible/playbook.yml @@ -0,0 +1,211 @@ +--- +- name: 0️⃣ Bootstrap OS + hosts: all + gather_facts: false + become: true + vars: + pkgs: + - python3.11 + - python3.11-venv + - python3-pip + - git + - nginx + - postgresql + - postgresql-contrib + - libpq-dev + - redis-server + + tasks: + - name: Install base packages (non-interactive, synchronous) + apt: + name: "{{ pkgs }}" + state: present + update_cache: yes + cache_valid_time: 3600 + environment: {DEBIAN_FRONTEND: noninteractive} + + - name: Reboot and wait + reboot: + reboot_timeout: 900 + register: rebooted + + - name: Wait for SSH + wait_for_connection: + delay: 10 + timeout: 300 + when: rebooted is changed + + - name: Create 2 GB swapfile if not present + shell: | + set -e + if ! swapon --show | grep -q '^/swapfile'; then + fallocate -l 2G /swapfile || dd if=/dev/zero of=/swapfile bs=1M count=2048 + chmod 600 /swapfile + mkswap /swapfile + swapon /swapfile + fi + args: + creates: /swapfile + + - name: Persist swapfile in /etc/fstab + lineinfile: + path: /etc/fstab + line: "/swapfile none swap sw 0 0" + state: present + +- name: 1️⃣ Deploy application + hosts: all + gather_facts: false + vars: + project_root: /home/{{ vps_user }}/{{ project_name }} + git_repo: "https://github.com/AlphaOneLabs/education-website.git" + git_branch: "main" + + tasks: + - name: Read .env file into list + set_fact: + env_lines: "{{ lookup('ansible.builtin.file', '../.env').splitlines() }}" + + - name: Init empty env_vars dict + set_fact: + env_vars: {} + + - name: Build env_vars dict line-by-line + set_fact: + env_vars: "{{ env_vars | combine({ key: val }) }}" + vars: + clean: "{{ item.split('#',1)[0] | trim }}" + key: "{{ clean.split('=',1)[0] | trim }}" + val: "{{ (clean.split('=',1)[1] | trim) if ('=' in clean) else '' }}" + loop: "{{ env_lines }}" + when: clean != '' and not clean.startswith('#') + loop_control: + label: "{{ key }}" + + - name: Promote env_vars to play vars + set_fact: "{{ env_vars }}" + + - name: Map essential env vars to lowercase variables expected by templates + set_fact: + secret_key: "{{ env_vars.SECRET_KEY }}" + debug: "{{ env_vars.DEBUG }}" + stripe_secret_key: "{{ env_vars.STRIPE_SECRET_KEY | default('') }}" + stripe_publishable_key: "{{ env_vars.STRIPE_PUBLISHABLE_KEY | default('') }}" + stripe_webhook_secret: "{{ env_vars.STRIPE_WEBHOOK_SECRET | default('') }}" + stripe_connect_webhook_secret: "{{ env_vars.STRIPE_CONNECT_WEBHOOK_SECRET | default('') }}" + sendgrid_api_key: "{{ env_vars.SENDGRID_API_KEY | default('') }}" + sendgrid_password: "{{ env_vars.SENDGRID_PASSWORD | default('') }}" + email_from: "{{ env_vars.EMAIL_FROM | default('') }}" + slack_webhook_url: "{{ env_vars.SLACK_WEBHOOK_URL | default('') }}" + message_encryption_key: "{{ env_vars.MESSAGE_ENCRYPTION_KEY | default('') }}" + admin_url: "{{ env_vars.ADMIN_URL | default('') }}" + gs_bucket_name: "{{ env_vars.GS_BUCKET_NAME | default('') }}" + gs_project_id: "{{ env_vars.GS_PROJECT_ID | default('') }}" + service_account_file: "{{ env_vars.SERVICE_ACCOUNT_FILE | default('') }}" + django_debug: "{{ env_vars.DJANGO_DEBUG | default('') }}" + enable_https_redirect: "{{ env_vars.ENABLE_HTTPS_REDIRECT | default('True') }}" + domain_name: "{{ env_vars.DOMAIN_NAME | default('') }}" + app_port: "{{ env_vars.APP_PORT | default('8000') }}" + vps_user: "{{ env_vars.VPS_USER | default('django') }}" + db_name: "{{ env_vars.DB_NAME | default('education_website') }}" + db_user: "{{ env_vars.DB_USER | default('root') }}" + db_password: "{{ env_vars.DB_PASSWORD | default('change_me') }}" + project_name: "{{ env_vars.PROJECT_NAME | default('education-website') }}" + git_repo: "{{ env_vars.GIT_REPO | default(git_repo) }}" + git_branch: "{{ env_vars.GIT_BRANCH | default(git_branch) }}" + allowed_hosts: "{{ env_vars.ALLOWED_HOSTS | default('localhost,127.0.0.1') }}" + csrf_trusted_origins: "{{ env_vars.CSRF_TRUSTED_ORIGINS | default('localhost,127.0.0.1') }}" + redis_url: "{{ env_vars.REDIS_URL | default('redis://localhost:6379') }}" + csrf_cookie_secure: "{{ env_vars.CSRF_COOKIE_SECURE | default('False') }}" + session_cookie_secure: "{{ env_vars.SESSION_COOKIE_SECURE | default('False') }}" + + - name: Ensure application user exists + user: { name: "{{ vps_user }}", system: true, create_home: true, shell: /bin/bash } + + - name: Clone code + git: + repo: "{{ git_repo }}" + dest: "{{ project_root }}" + version: "{{ git_branch }}" + force: yes + register: git_clone + + - name: Ensure correct ownership of project directory + file: + path: "{{ project_root }}" + state: directory + recurse: yes + owner: "{{ vps_user }}" + group: "{{ vps_user }}" + when: git_clone is changed + + - name: Create venv + install deps + become: yes + become_user: "{{ vps_user }}" + shell: | + python3.11 -m venv venv + source venv/bin/activate + pip install --upgrade pip wheel + pip install -r requirements.txt uvicorn gunicorn psycopg2-binary + args: + chdir: "{{ project_root }}" + executable: /bin/bash + + - name: Generate .env for server + template: + src: env.j2 # unchanged template + dest: "{{ project_root }}/.env" + mode: '0600' + owner: "{{ vps_user }}" + group: "{{ vps_user }}" + + - name: Django migrate + collectstatic + become: yes + become_user: "{{ vps_user }}" + shell: | + set -e + source venv/bin/activate + export DJANGO_SETTINGS_MODULE=web.settings + python manage.py migrate --noinput + python manage.py collectstatic --noinput + args: + chdir: "{{ project_root }}" + executable: /bin/bash + +- name: 2️⃣ Harden & run + hosts: all + gather_facts: false + tasks: + - name: Configure nginx + template: + src: nginx-http.conf.j2 + dest: /etc/nginx/nginx.conf + notify: restart nginx + + - name: Copy systemd service + template: + src: education-website.service.j2 + dest: /etc/systemd/system/education-website.service + notify: restart app + + - name: Allow ports + community.general.ufw: + rule: allow + port: "{{ item }}" + loop: [22, 80, 443] + + - name: Enable firewall (now that everything is ready) + community.general.ufw: + state: enabled + + - name: Ensure Redis service is enabled and running + service: + name: redis-server + state: started + enabled: yes + + handlers: + - name: restart nginx + service: {name: nginx, state: restarted, enabled: yes} + - name: restart app + service: {name: education-website, state: restarted, enabled: yes} diff --git a/ansible/update.sh.j2 b/ansible/update.sh.j2 new file mode 100644 index 000000000..08e409ab6 --- /dev/null +++ b/ansible/update.sh.j2 @@ -0,0 +1,8 @@ +#!/bin/bash +cd /home/{{ vps_user }}/{{ project_name }} +git pull +source venv/bin/activate +pip install -r requirements.txt +python manage.py migrate +python manage.py collectstatic --noinput +sudo systemctl restart {{ project_name }}