--- - name: Install a verified GuestOps source release hosts: guestops become: true gather_facts: true vars: guestops_root: /srv/guestops guestops_config_root: /etc/guestops guestops_public_hostname: sandbox-guestops.futuresens.co.uk guestops_public_origin: "https://{{ guestops_public_hostname }}" guestops_release_root: "{{ guestops_root }}/releases/{{ guestops_release_commit }}" guestops_staging_archive: "/var/tmp/{{ guestops_archive | basename }}" guestops_staging_record: "/var/tmp/{{ guestops_source_record | basename }}" guestops_api_image: "guestops-api:{{ guestops_release_commit }}" guestops_worker_image: "guestops-worker:{{ guestops_release_commit }}" pre_tasks: - name: Validate required release variables ansible.builtin.assert: that: - guestops_release_version is match('^[0-9]+\\.[0-9]+\\.[0-9]+$') - guestops_release_commit is match('^[0-9a-f]{40}$') - guestops_archive_sha256 is match('^[0-9a-f]{64}$') - guestops_archive | length > 0 - guestops_source_record | length > 0 - guestops_evidence_directory | length > 0 - guestops_public_hostname is match('^[A-Za-z0-9.-]+$') - guestops_public_hostname == 'sandbox-guestops.futuresens.co.uk' fail_msg: Release version, full commit, archive, SHA-256, source record and evidence directory are required. - name: Verify source package on the Ansible controller ansible.builtin.command: argv: - python3 - "{{ playbook_dir }}/../verify_source_package.py" - --archive - "{{ guestops_archive }}" - --record - "{{ guestops_source_record }}" - --expected-commit - "{{ guestops_release_commit }}" - --expected-version - "{{ guestops_release_version }}" delegate_to: localhost become: false changed_when: false - name: Check private GuestOps environment exists ansible.builtin.stat: path: "{{ guestops_config_root }}/guestops.env" register: guestops_environment - name: Require a private pre-provisioned environment file ansible.builtin.assert: that: - guestops_environment.stat.exists - guestops_environment.stat.isreg - guestops_environment.stat.mode == '0600' fail_msg: /etc/guestops/guestops.env must already exist with mode 0600. tasks: - name: Require supported Debian host ansible.builtin.assert: that: - ansible_facts.system == 'Linux' - ansible_facts.distribution == 'Debian' - ansible_facts.distribution_major_version | int >= 12 - ansible_facts.processor_vcpus | default(0) | int >= 4 - ansible_facts.memtotal_mb | default(0) | int >= 7300 fail_msg: GuestOps requires Debian 12 or newer with at least 4 CPUs and 7.3 GiB RAM. - name: Enable Docker at boot ansible.builtin.service: name: docker enabled: true state: started - name: Enable Nginx at boot ansible.builtin.service: name: nginx enabled: true state: started - name: Create release and evidence directories ansible.builtin.file: path: "{{ item.path }}" state: directory owner: root group: root mode: "{{ item.mode }}" loop: - { path: "{{ guestops_root }}/releases", mode: "0755" } - { path: "{{ guestops_release_root }}", mode: "0755" } - { path: "{{ guestops_config_root }}/release-records", mode: "0700" } - name: Copy source archive to the host ansible.builtin.copy: src: "{{ guestops_archive }}" dest: "{{ guestops_staging_archive }}" owner: root group: root mode: "0600" - name: Copy source record to the host ansible.builtin.copy: src: "{{ guestops_source_record }}" dest: "{{ guestops_staging_record }}" owner: root group: root mode: "0600" - name: Calculate transferred archive checksum ansible.builtin.stat: path: "{{ guestops_staging_archive }}" checksum_algorithm: sha256 register: guestops_transferred_archive - name: Reject a changed transferred archive ansible.builtin.assert: that: - guestops_transferred_archive.stat.checksum == guestops_archive_sha256 fail_msg: Transferred GuestOps archive does not match the approved SHA-256. - name: Extract committed application files ansible.builtin.unarchive: src: "{{ guestops_staging_archive }}" dest: "{{ guestops_release_root }}" remote_src: true extra_opts: - --strip-components=1 - name: Verify transferred package and record on the target ansible.builtin.command: argv: - python3 - deploy/verify_source_package.py - --archive - "{{ guestops_staging_archive }}" - --record - "{{ guestops_staging_record }}" - --expected-commit - "{{ guestops_release_commit }}" - --expected-version - "{{ guestops_release_version }}" - --output - "{{ guestops_config_root }}/release-records/{{ guestops_release_commit }}.source-verification.json" chdir: "{{ guestops_release_root }}" changed_when: false - name: Retain approved source record on the target ansible.builtin.copy: src: "{{ guestops_staging_record }}" dest: "{{ guestops_config_root }}/release-records/{{ guestops_release_commit }}.source.json" remote_src: true owner: root group: root mode: "0600" - name: Select release images and safe pilot defaults in the private environment ansible.builtin.lineinfile: path: "{{ guestops_config_root }}/guestops.env" regexp: "^{{ item.name }}=" line: "{{ item.name }}={{ item.value }}" create: false mode: "0600" loop: - { name: GUESTOPS_API_IMAGE, value: "{{ guestops_api_image }}" } - { name: GUESTOPS_WORKER_IMAGE, value: "{{ guestops_worker_image }}" } - { name: GOOGLE_ENABLE_SENDING, value: "false" } - { name: AUTO_REPLY_ENABLE_LIVE, value: "false" } no_log: true - name: Copy private deployment environment into the release ansible.builtin.copy: src: "{{ guestops_config_root }}/guestops.env" dest: "{{ guestops_release_root }}/.env" remote_src: true owner: root group: root mode: "0600" - name: Build API image from the verified source ansible.builtin.command: argv: - docker - build - --target - api - --tag - "{{ guestops_api_image }}" - . chdir: "{{ guestops_release_root }}" register: guestops_api_build changed_when: true - name: Build worker image from the verified source ansible.builtin.command: argv: - docker - build - --target - worker - --tag - "{{ guestops_worker_image }}" - . chdir: "{{ guestops_release_root }}" register: guestops_worker_build changed_when: true - name: Inspect API image identity ansible.builtin.command: argv: [docker, image, inspect, --format, "{{ '{{.Id}}' }}", "{{ guestops_api_image }}"] register: guestops_api_identity changed_when: false - name: Inspect worker image identity ansible.builtin.command: argv: [docker, image, inspect, --format, "{{ '{{.Id}}' }}", "{{ guestops_worker_image }}"] register: guestops_worker_identity changed_when: false - name: Create post-build release record ansible.builtin.command: argv: - python3 - deploy/release_record.py - --artifact - "{{ guestops_staging_archive }}" - --commit - "{{ guestops_release_commit }}" - --api-image - "{{ guestops_api_image }}" - --api-id - "{{ guestops_api_identity.stdout }}" - --worker-image - "{{ guestops_worker_image }}" - --worker-id - "{{ guestops_worker_identity.stdout }}" - --output - "{{ guestops_config_root }}/release-records/{{ guestops_release_commit }}.json" chdir: "{{ guestops_release_root }}" - name: Run offline deployment preflight ansible.builtin.command: argv: - python3 - deploy/ops.py - preflight - --offline chdir: "{{ guestops_release_root }}" changed_when: false - name: Validate Compose without exposing expanded configuration ansible.builtin.command: argv: - docker - compose - --env-file - "{{ guestops_config_root }}/guestops.env" - config - --quiet chdir: "{{ guestops_release_root }}" changed_when: false - name: Start the verified release without rebuilding ansible.builtin.command: argv: - docker - compose - --env-file - "{{ guestops_config_root }}/guestops.env" - up - --detach - --no-build chdir: "{{ guestops_release_root }}" environment: GUESTOPS_API_IMAGE: "{{ guestops_api_image }}" GUESTOPS_WORKER_IMAGE: "{{ guestops_worker_image }}" changed_when: true - name: Wait for loopback readiness ansible.builtin.uri: url: http://127.0.0.1:8080/health/ready method: GET status_code: 200 return_content: false register: guestops_readiness retries: 30 delay: 2 until: guestops_readiness.status == 200 - name: Confirm TLS certificate files exist ansible.builtin.stat: path: "{{ item }}" follow: true loop: - "/etc/letsencrypt/live/{{ guestops_public_hostname }}/fullchain.pem" - "/etc/letsencrypt/live/{{ guestops_public_hostname }}/privkey.pem" register: guestops_certificates - name: Require the pre-provisioned TLS certificate ansible.builtin.assert: that: - guestops_certificates.results | map(attribute='stat.exists') | min - guestops_certificates.results | map(attribute='stat.isreg') | min fail_msg: A valid pre-provisioned Let's Encrypt certificate is required before enabling Nginx. - name: Install reviewed GuestOps Nginx site ansible.builtin.copy: src: "{{ playbook_dir }}/../nginx.conf" dest: /etc/nginx/sites-available/guestops.conf owner: root group: root mode: "0644" register: guestops_nginx_site - name: Enable GuestOps Nginx site ansible.builtin.file: src: /etc/nginx/sites-available/guestops.conf dest: /etc/nginx/sites-enabled/guestops.conf state: link register: guestops_nginx_enabled - name: Validate Nginx configuration ansible.builtin.command: argv: [nginx, -t] changed_when: false - name: Reload Nginx after reviewed configuration change ansible.builtin.service: name: nginx enabled: true state: reloaded when: guestops_nginx_site.changed or guestops_nginx_enabled.changed - name: Inspect host TCP listeners ansible.builtin.command: argv: [ss, -ltnH] register: guestops_tcp_listeners changed_when: false - name: Reject public API or MongoDB listeners ansible.builtin.assert: that: - guestops_tcp_listeners.stdout_lines | select('search', ':8080(\\s|$)') | reject('search', '127\\.0\\.0\\.1:8080(\\s|$)') | list | length == 0 - guestops_tcp_listeners.stdout_lines | select('search', ':27017(\\s|$)') | list | length == 0 fail_msg: API port 8080 must be loopback-only and MongoDB must have no host listener. - name: Verify public HTTP redirects to HTTPS ansible.builtin.uri: url: "http://{{ guestops_public_hostname }}/health/ready" method: GET follow_redirects: none status_code: [301, 302, 307, 308] return_content: false delegate_to: localhost become: false - name: Verify public HTTPS readiness and certificate trust ansible.builtin.uri: url: "{{ guestops_public_origin }}/health/ready" method: GET status_code: 200 return_content: true validate_certs: true register: guestops_public_readiness retries: 10 delay: 3 until: - guestops_public_readiness.status == 200 - guestops_public_readiness.json is defined - guestops_public_readiness.json.status == 'ready' delegate_to: localhost become: false - name: Select the current successful release ansible.builtin.file: src: "{{ guestops_release_root }}" dest: "{{ guestops_root }}/current" state: link force: true - name: Retain release record on the Ansible controller ansible.builtin.fetch: src: "{{ guestops_config_root }}/release-records/{{ guestops_release_commit }}.json" dest: "{{ guestops_evidence_directory }}/release-record.json" flat: true - name: Retain target source verification on the Ansible controller ansible.builtin.fetch: src: "{{ guestops_config_root }}/release-records/{{ guestops_release_commit }}.source-verification.json" dest: "{{ guestops_evidence_directory }}/source-verification.json" flat: true - name: Remove transferred staging files after successful deployment ansible.builtin.file: path: "{{ item }}" state: absent loop: - "{{ guestops_staging_archive }}" - "{{ guestops_staging_record }}"