From 5e8be7240eb6abfc51166b1d5013237f53a59f38 Mon Sep 17 00:00:00 2001 From: Jan-Willem Oostendorp Date: Sun, 22 Mar 2026 13:21:07 +0100 Subject: [PATCH 1/2] Add basic ansible create site functionality. --- ansible-playbooks/create-wp.yml | 476 ++++++++++++++++++++++++++++++++ app/Jobs/RunAnsible.php | 2 +- app/Services/Ansible.php | 90 +++++- 3 files changed, 557 insertions(+), 11 deletions(-) create mode 100644 ansible-playbooks/create-wp.yml diff --git a/ansible-playbooks/create-wp.yml b/ansible-playbooks/create-wp.yml new file mode 100644 index 0000000..bbafa38 --- /dev/null +++ b/ansible-playbooks/create-wp.yml @@ -0,0 +1,476 @@ +--- +- name: SWORD WordPress Site Installation + hosts: all + become: true + gather_facts: false + vars: + # These must be passed via --extra-vars: + # callback_url, domain, site_id, php_version, + # db_name, db_user, db_password, mysql_root_password, + # wp_admin_user, wp_admin_password, admin_email, + # admin_display_name + + site_dir: "/srv/sword/sites/{{ domain }}" + stack_dir: "/srv/sword/stacks/{{ domain }}" + wp_dir: "/srv/sword/sites/{{ domain }}/wordpress" + cache_dir: "/srv/sword/stacks/{{ domain }}/nginx-cache" + + php_container: "sword_{{ site_id }}_php" + redis_container: "sword_{{ site_id }}_redis" + nginx_container: "sword_{{ site_id }}_nginx" + + nginx_helper_options: + enable_purge: "1" + cache_method: enable_fastcgi + purge_method: get_request + enable_map: null + enable_log: null + log_level: INFO + log_filesize: "5" + enable_stamp: null + purge_homepage_on_edit: "1" + purge_homepage_on_del: "1" + purge_archive_on_edit: "1" + purge_archive_on_del: "1" + purge_archive_on_new_comment: "1" + purge_archive_on_deleted_comment: "1" + purge_page_on_mod: "1" + purge_page_on_new_comment: "1" + purge_page_on_deleted_comment: "1" + redis_hostname: "sword_{{ site_id }}_redis" + redis_port: "6379" + redis_prefix: "nginx_cache:" + enable_purge_all: null + nginx_helper_cache_path: /var/cache/nginx/fastcgi + + wp_extra_php: | + /** Disable file editing in dashboard */ + define( 'DISALLOW_FILE_EDIT', true ); + + /** Redis object cache */ + define( 'WP_REDIS_HOST', 'sword_{{ site_id }}_redis' ); + define( 'WP_REDIS_PORT', 6379 ); + define( 'WP_REDIS_TIMEOUT', 1 ); + define( 'WP_REDIS_READ_TIMEOUT', 1 ); + define( 'WP_REDIS_DATABASE', 0 ); + define( 'WP_REDIS_PREFIX', '{{ site_id }}:' ); + define( 'WP_REDIS_MAXTTL', 86400 ); + + /** Nginx FastCGI cache helper */ + define( 'RT_WP_NGINX_HELPER_CACHE_PATH', '/var/cache/nginx/fastcgi' ); + + dockerfile_content: | + FROM php:{{ php_version }}-fpm-alpine + + # Install PHP extensions required by WordPress + Redis + RUN apk add --no-cache \ + freetype libpng libjpeg-turbo freetype-dev libpng-dev libjpeg-turbo-dev \ + libzip-dev icu-dev icu-libs libintl oniguruma-dev curl \ + && docker-php-ext-configure gd --with-freetype --with-jpeg \ + && docker-php-ext-install -j$(nproc) \ + gd mysqli pdo pdo_mysql zip intl mbstring exif opcache \ + && apk del freetype-dev libpng-dev libjpeg-turbo-dev icu-dev + + # Install phpredis extension + RUN apk add --no-cache --virtual .build-deps $PHPIZE_DEPS \ + && pecl install redis \ + && docker-php-ext-enable redis \ + && apk del .build-deps + + # Install WP-CLI + RUN curl -sO https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar \ + && chmod +x wp-cli.phar \ + && mv wp-cli.phar /usr/local/bin/wp + + # PHP tuning: memory, opcache, and upload settings + RUN echo 'memory_limit = 256M' > /usr/local/etc/php/conf.d/sword.ini \ + && echo 'upload_max_filesize = 64M' >> /usr/local/etc/php/conf.d/sword.ini \ + && echo 'post_max_size = 64M' >> /usr/local/etc/php/conf.d/sword.ini \ + && echo 'opcache.enable = 1' >> /usr/local/etc/php/conf.d/sword.ini \ + && echo 'opcache.memory_consumption = 128' >> /usr/local/etc/php/conf.d/sword.ini \ + && echo 'opcache.interned_strings_buffer = 16' >> /usr/local/etc/php/conf.d/sword.ini \ + && echo 'opcache.max_accelerated_files = 10000' >> /usr/local/etc/php/conf.d/sword.ini \ + && echo 'opcache.revalidate_freq = 60' >> /usr/local/etc/php/conf.d/sword.ini \ + && echo 'opcache.fast_shutdown = 1' >> /usr/local/etc/php/conf.d/sword.ini + + docker_compose_content: | + services: + php: + build: + context: . + dockerfile: Dockerfile + image: sword_php_{{ php_version }} + container_name: {{ php_container }} + restart: unless-stopped + volumes: + - {{ wp_dir }}:/var/www/html + environment: + - PHP_FPM_POOL_NAME={{ site_id }} + networks: + - sword_network + labels: + ofelia.enabled: 'true' + ofelia.job-exec.wpcron-{{ site_id }}.schedule: '@every 5m' + ofelia.job-exec.wpcron-{{ site_id }}.user: www-data + ofelia.job-exec.wpcron-{{ site_id }}.command: 'wp cron event run --due-now' + + redis: + image: redis:7-alpine + container_name: {{ redis_container }} + restart: unless-stopped + command: > + redis-server + --maxmemory 128mb + --maxmemory-policy allkeys-lru + --save "" + --appendonly no + networks: + - sword_network + + nginx: + image: nginx:alpine + container_name: {{ nginx_container }} + restart: unless-stopped + volumes: + - {{ wp_dir }}:/var/www/html:ro + - {{ stack_dir }}/nginx.conf:/etc/nginx/conf.d/default.conf:ro + - {{ cache_dir }}:/var/cache/nginx/fastcgi:rw + labels: + - traefik.enable=true + - traefik.http.routers.{{ site_id }}.rule=Host(`{{ domain }}`) + - traefik.http.routers.{{ site_id }}.entrypoints=websecure + - traefik.http.routers.{{ site_id }}.tls.certresolver=letsencrypt + networks: + - sword_network + + networks: + sword_network: + name: sword_network + external: true + + nginx_conf_content: | + # ── FastCGI cache zone ─────────────────────────────────── + # 100m key zone (~800k keys), 1g max cache size, 60m inactive TTL + fastcgi_cache_path /var/cache/nginx/fastcgi + levels=1:2 + keys_zone=wp_fcgi:100m + max_size=1g + inactive=60m + use_temp_path=off; + + fastcgi_cache_key "$scheme$request_method$host$request_uri"; + + server { + listen 80; + server_name {{ domain }}; + root /var/www/html; + index index.php; + + # ── Cache bypass conditions ────────────────────────── + # Bypass for logged-in users, WooCommerce sessions, and non-GET requests. + set $skip_cache 0; + + if ($request_method = POST) { set $skip_cache 1; } + if ($query_string != "") { set $skip_cache 1; } + if ($request_uri ~* "/wp-admin/|/xmlrpc.php|wp-.*.php|/feed/|index.php|sitemap(_index)?.xml") { set $skip_cache 1; } + if ($http_cookie ~* "comment_author|wordpress_[a-f0-9]+|wp-postpass|wordpress_no_cache|wordpress_logged_in") { set $skip_cache 1; } + + # ── Static assets ──────────────────────────────────── + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|otf|eot|webp|avif)$ { + expires max; + log_not_found off; + add_header Cache-Control "public, immutable"; + } + + location = /favicon.ico { log_not_found off; access_log off; } + location = /robots.txt { log_not_found off; access_log off; } + + # ── Security ───────────────────────────────────────── + location ~* ^/wp-config\.php { deny all; } + location ~ /\. { deny all; } + + # ── WordPress routing ──────────────────────────────── + location / { + try_files $uri $uri/ /index.php?$args; + } + + # ── PHP-FPM + FastCGI cache ────────────────────────── + location ~ \.php$ { + fastcgi_pass php:9000; + fastcgi_index index.php; + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + fastcgi_param HTTPS on; + + fastcgi_cache wp_fcgi; + fastcgi_cache_valid 200 301 302 1h; + fastcgi_cache_use_stale error timeout updating invalid_header http_500; + fastcgi_cache_lock on; + fastcgi_cache_bypass $skip_cache; + fastcgi_no_cache $skip_cache; + + # Expose cache status for debugging (X-Cache-Status: HIT / MISS / BYPASS) + add_header X-Cache-Status $upstream_cache_status always; + + fastcgi_buffers 16 16k; + fastcgi_buffer_size 32k; + fastcgi_read_timeout 120s; + fastcgi_connect_timeout 60s; + fastcgi_send_timeout 60s; + } + } + + tasks: + # ── Progress: started ────────────────────────────────── + - name: Notify progress - started + ansible.builtin.uri: + url: "{{ callback_url }}" + method: POST + body_format: form-urlencoded + body: + status: installing + step: started + validate_certs: false + ignore_errors: true + + # ── Create directories ───────────────────────────────── + - name: Create site directories + ansible.builtin.file: + path: "{{ item }}" + state: directory + owner: sword + group: sword + loop: + - "{{ site_dir }}" + - "{{ stack_dir }}" + - "{{ wp_dir }}" + - "{{ cache_dir }}" + + # ── Create MySQL database and user ───────────────────── + - name: Create MySQL database + ansible.builtin.command: + cmd: > + docker exec sword_mysql mysql -uroot -p"{{ mysql_root_password }}" + -e "CREATE DATABASE IF NOT EXISTS `{{ db_name }}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" + + - name: Create MySQL user + ansible.builtin.command: + cmd: > + docker exec sword_mysql mysql -uroot -p"{{ mysql_root_password }}" + -e "CREATE USER IF NOT EXISTS '{{ db_user }}'@'%' IDENTIFIED BY '{{ db_password }}';" + + - name: Grant MySQL privileges + ansible.builtin.command: + cmd: > + docker exec sword_mysql mysql -uroot -p"{{ mysql_root_password }}" + -e "GRANT ALL PRIVILEGES ON `{{ db_name }}`.* TO '{{ db_user }}'@'%'; FLUSH PRIVILEGES;" + + - name: Notify progress - create_database + ansible.builtin.uri: + url: "{{ callback_url }}" + method: POST + body_format: form-urlencoded + body: + status: installing + step: create_database + validate_certs: false + ignore_errors: true + + # ── Write Dockerfile ─────────────────────────────────── + - name: Write PHP Dockerfile + ansible.builtin.copy: + content: "{{ dockerfile_content }}" + dest: "{{ stack_dir }}/Dockerfile" + mode: "0644" + + # ── Write Docker Compose file ────────────────────────── + - name: Write docker-compose.yml + ansible.builtin.copy: + content: "{{ docker_compose_content }}" + dest: "{{ stack_dir }}/docker-compose.yml" + mode: "0644" + + # ── Write Nginx config ───────────────────────────────── + - name: Write Nginx config + ansible.builtin.copy: + content: "{{ nginx_conf_content }}" + dest: "{{ stack_dir }}/nginx.conf" + mode: "0644" + + # ── Start containers ─────────────────────────────────── + - name: Deploy site containers + ansible.builtin.command: + cmd: docker compose -f {{ stack_dir }}/docker-compose.yml up -d --build + changed_when: true + + - name: Notify progress - docker_setup + ansible.builtin.uri: + url: "{{ callback_url }}" + method: POST + body_format: form-urlencoded + body: + status: installing + step: docker_setup + validate_certs: false + ignore_errors: true + + # ── Download WordPress ───────────────────────────────── + - name: Wait for PHP-FPM to become ready + ansible.builtin.pause: + seconds: 3 + + - name: Download WordPress core + ansible.builtin.command: + cmd: > + docker exec {{ php_container }} wp core download + --path=/var/www/html + --allow-root + --locale=en_US + --force + + # ── Create wp-config.php ─────────────────────────────── + - name: Write wp-config extra PHP to temp file + ansible.builtin.copy: + content: "{{ wp_extra_php }}" + dest: "{{ stack_dir }}/wp-extra.php" + mode: "0644" + + - name: Copy wp-extra.php into PHP container + ansible.builtin.command: + cmd: docker cp {{ stack_dir }}/wp-extra.php {{ php_container }}:/tmp/wp-extra.php + + - name: Create wp-config.php + ansible.builtin.shell: + cmd: > + docker exec {{ php_container }} wp config create + --path=/var/www/html + --dbname="{{ db_name }}" + --dbuser="{{ db_user }}" + --dbpass="{{ db_password }}" + --dbhost="sword_mysql" + --allow-root + --force + --extra-php < <(docker exec {{ php_container }} cat /tmp/wp-extra.php) + executable: /bin/bash + + - name: Remove temp wp-extra.php files + ansible.builtin.file: + path: "{{ stack_dir }}/wp-extra.php" + state: absent + + - name: Remove temp wp-extra.php from container + ansible.builtin.command: + cmd: docker exec {{ php_container }} rm -f /tmp/wp-extra.php + ignore_errors: true + + # ── Run WordPress install ────────────────────────────── + - name: Install WordPress + ansible.builtin.command: + cmd: > + docker exec {{ php_container }} wp core install + --path=/var/www/html + --url="https://{{ domain }}" + --title="{{ domain }}" + --admin_user="{{ wp_admin_user }}" + --admin_password="{{ wp_admin_password }}" + --admin_email="{{ admin_email }}" + --skip-email + --allow-root + + - name: Update WordPress admin display name + ansible.builtin.command: + cmd: > + docker exec {{ php_container }} wp user update "{{ wp_admin_user }}" + --path=/var/www/html + --display_name="{{ admin_display_name }}" + --allow-root + + # ── Fix permissions ──────────────────────────────────── + - name: Fix site directory ownership + ansible.builtin.file: + path: "{{ site_dir }}" + owner: sword + group: sword + recurse: true + + - name: Fix WordPress file ownership in container + ansible.builtin.command: + cmd: docker exec {{ php_container }} chown -R www-data:www-data /var/www/html + + # ── Remove default plugins ───────────────────────────── + - name: Remove default Hello plugin + ansible.builtin.command: + cmd: > + docker exec {{ php_container }} wp plugin delete hello + --path=/var/www/html + --allow-root + ignore_errors: true + + # ── Install & configure Redis object cache ───────────── + - name: Install Redis Cache plugin + ansible.builtin.command: + cmd: > + docker exec {{ php_container }} wp plugin install redis-cache + --path=/var/www/html + --activate + --allow-root + + - name: Enable Redis object cache + ansible.builtin.command: + cmd: > + docker exec {{ php_container }} wp redis enable + --path=/var/www/html + --skip-flush + --allow-root + ignore_errors: true + + # ── Install & configure Nginx Helper ─────────────────── + - name: Install Nginx Helper plugin + ansible.builtin.command: + cmd: > + docker exec {{ php_container }} wp plugin install nginx-helper + --path=/var/www/html + --activate + --allow-root + + - name: Configure Nginx Helper options + ansible.builtin.command: + cmd: > + docker exec {{ php_container }} wp option update rt_wp_nginx_helper_options + '{{ nginx_helper_options | to_json }}' + --format=json + --path=/var/www/html + --allow-root + + - name: Notify progress - install_wordpress + ansible.builtin.uri: + url: "{{ callback_url }}" + method: POST + body_format: form-urlencoded + body: + status: installing + step: install_wordpress + validate_certs: false + ignore_errors: true + + # ── Restart Ofelia ───────────────────────────────────── + - name: Restart Ofelia to pick up new cron jobs + ansible.builtin.command: + cmd: docker restart sword_ofelia + ignore_errors: true + + # ── Done ─────────────────────────────────────────────── + - name: Notify progress - installed + ansible.builtin.uri: + url: "{{ callback_url }}" + method: POST + body_format: form-urlencoded + body: + status: installed + step: installed + validate_certs: false + ignore_errors: true + + - name: Print completion message + ansible.builtin.debug: + msg: "SWORD site installation complete! Domain: {{ domain }}" diff --git a/app/Jobs/RunAnsible.php b/app/Jobs/RunAnsible.php index 7cf7a3c..107965e 100644 --- a/app/Jobs/RunAnsible.php +++ b/app/Jobs/RunAnsible.php @@ -30,7 +30,7 @@ public function handle(): void } $ansible = new Ansible; - $ansible->runPlaybook($this->serverID, 'provision.yml'); + $ansible->runServerPlaybook($this->serverID, 'provision.yml'); // $this->testRawSSHConnection($this->serverID); } diff --git a/app/Services/Ansible.php b/app/Services/Ansible.php index a2a9c38..80b5cb8 100644 --- a/app/Services/Ansible.php +++ b/app/Services/Ansible.php @@ -3,7 +3,9 @@ namespace App\Services; use App\Models\Server; +use App\Models\Site; use Illuminate\Support\Facades\Process; +use Illuminate\Support\Str; use Symfony\Component\Yaml\Yaml; class Ansible @@ -11,24 +13,46 @@ class Ansible public function generateInventory() { // Get all servers. - $servers = Server::all(); $formattedData = [ - 'all' => [ + 'servers' => [ + 'hosts' => [], + ], + 'sites' => [ 'hosts' => [], ], ]; // Structure the data. + $servers = Server::all(); foreach ($servers as $server) { - $formattedData['all']['hosts']['server-'.$server->id.'-'.$server->name] = [ + $formattedData['servers']['hosts']['server-'.$server->id.'-'.$server->name] = [ 'ansible_host' => $server->ip_address, 'ansible_user' => 'root', // @todo make this a variable. 'ansible_port' => $server->ssh_port, // @todo make this a variable. 'ansible_ssh_private_key_file' => $this->createTempPrivateKeyFile($server), ]; } + + // get all sites. + $sites = Site::all(); + + foreach ($sites as $site) { + $formattedData['sites']['hosts']['site-'.$site->id.'-'.$site->domain] = [ + 'server_id' => $site->server_id, + 'domain' => $site->domain, + 'php_version' => $site->php_version, + 'db_name' => $site->db_name, + 'db_user' => $site->db_user, + 'db_password' => $site->db_password, + ]; + } // Create Yaml Inventory file. - $yaml = Yaml::dump($formattedData, 2); + $yaml = Yaml::dump( + $formattedData, + 9001, //It's over 9000. + 4, + Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK + ); file_put_contents('/tmp/ansible-sword-inventory.yml', $yaml); } @@ -52,27 +76,24 @@ public function createTempPrivateKeyFile($server): string /** * Run a specific playbook. * - * @param mixed $server The Server model, server ID, or null to run for all servers. + * @param int|Server $server The Server model, server ID, or null to run for all servers. * @param string|null $playbookpath Path to a playbook, relative to ./ansible-playbooks. * Or full paths. Default to main.yml * @param array $extraVars Extra variables to pass to the playbook via --extra-vars. */ - public function runPlaybook($server = null, ?string $playbookpath = null, array $extraVars = []) + public function runServerPlaybook( int|Server $server, ?string $playbookpath = null, array $extraVars = []) { - // Always make sure we have a fresh inventory. - $this->generateInventory(); if (is_int($server)) { $server = Server::findOrFail($server); } - $LimitServer = 'all'; if ($server) { $LimitServer = 'server-'.$server->id.'-'.$server->name; } // Ugly path handling. if (empty($playbookpath)) { - $playbookpath = __DIR__.'/../../ansible-playbooks/main.yml'; + $playbookpath = __DIR__.'/../../ansible-playbooks/provision.yml'; } elseif (! str_starts_with($playbookpath, '/')) { $playbookpath = __DIR__.'/../../ansible-playbooks/'.$playbookpath; } @@ -94,6 +115,55 @@ public function runPlaybook($server = null, ?string $playbookpath = null, array $extraVars ); + $this->runPlaybook($playbookpath, $extraVars, $LimitServer); + } + + public function runSitePlaybook( int|Site $site, ?string $playbookpath = null, array $extraVars = []) + { + if (is_int($site)) { + $site = Site::findOrFail($site); + } + + // Ugly path handling. + if (empty($playbookpath)) { + $playbookpath = __DIR__.'/../../ansible-playbooks/create-wp.yml'; + } elseif (! str_starts_with($playbookpath, '/')) { + $playbookpath = __DIR__.'/../../ansible-playbooks/'.$playbookpath; + } + + $server = $site->server; + + $extraVars = array_merge( + [ + 'callback_url' => route('sites.callbacks.install', [ + 'site' => $site->id, + 'signature' => $site->callback_signature, + ]), + 'domain' => $site->domain, + 'site_id' => $site->id, + 'php_version' => $site->php_version, + 'db_name' => $site->db_name, + 'db_user' => $site->db_user, + 'db_password' => $site->db_password, + 'mysql_root_password' => $server->mysql_root_password, + // @todo these should be stored in the Job, and not generated here. + 'wp_admin_user' => $site->user->name ?? 'admin', + 'wp_admin_password' => Str::random(16), // @todo Get from the job. + 'admin_email' => $site->user->email ?? 'admin@'.$site->domain, + 'admin_display_name' => $site->user->name ?? null, + ], + $extraVars + ); + + return $this->runPlaybook( $playbookpath, $extraVars); + + } + + public function runPlaybook( string $playbookpath, array $extraVars = [], string $LimitServer = 'all') + { + // Always make sure we have a fresh inventory. + $this->generateInventory(); + $command = 'ANSIBLE_HOST_KEY_CHECKING=false '; $command .= 'ansible-playbook'; $command .= ' -i /tmp/ansible-sword-inventory.yml'; From 8ef1417b2906fb1dd1a68f178456ce15f372bdf5 Mon Sep 17 00:00:00 2001 From: Jan-Willem Oostendorp Date: Sun, 22 Mar 2026 13:24:53 +0100 Subject: [PATCH 2/2] Trigger site creation script. --- app/Jobs/InstallSiteJob.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/Jobs/InstallSiteJob.php b/app/Jobs/InstallSiteJob.php index 0a33eb5..5020f1d 100644 --- a/app/Jobs/InstallSiteJob.php +++ b/app/Jobs/InstallSiteJob.php @@ -3,6 +3,7 @@ namespace App\Jobs; use App\Models\Site; +use App\Services\Ansible; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Queue\Queueable; use phpseclib3\Crypt\EC; @@ -40,5 +41,12 @@ public function handle(): void $ssh->login('root', $privateKey); $ssh->exec(sprintf('wget -qO create-wp-site.sh "%s" && nohup bash create-wp-site.sh > create-wp-site.log 2>&1 < /dev/null & disown', $installUrl)); + + if (! config('services.ansible.enabled')) { + logger()->warning('Ansible execution is disabled. Skipping RunAnsible job for server ID: '.$this->serverID); + } else { + $ansible = new Ansible; + $ansible->runSitePlaybook($site ); + } } }