diff --git a/changelog.d/162-domain-routes-render.md b/changelog.d/162-domain-routes-render.md new file mode 100644 index 0000000..d6bfc90 --- /dev/null +++ b/changelog.d/162-domain-routes-render.md @@ -0,0 +1,3 @@ +### Added + +- `cloudfall domains apply` renders a Domain's `spec.routes`: a proxy route becomes a `location` that proxies to its `upstream` with the full request path, and a static route serves `/current/` of its component, answering `spaFallback` for an unknown path when it declares one and 404 when it doesn't. Without a `/` route, a path no route matches answers 404, not Nginx's default page. Locations render longest path first, whatever the declared order, where the single proxy location rendered before (the port 80 server without active TLS, the 443 server with it); the port 80 redirect and the ACME challenge location stay. The site role grants the Nginx worker (`www-data`) traverse-only access (POSIX ACL `user:www-data:--x`, installing `acl`) from the application root to the install root and its `releases/`, so it reads the release files but never `shared/env/.env`, and a deploy or `cloudfall rollback` switches what a static route serves without re-running the domains playbook or reloading Nginx. The domains playbook's early check now refuses only a Domain with neither `proxy.upstream` nor `routes`. A Domain with only `proxy.upstream` renders a byte-identical vhost (#162) diff --git a/docs/specs/003-domain-routes.md b/docs/specs/003-domain-routes.md index 459d83b..f497969 100644 --- a/docs/specs/003-domain-routes.md +++ b/docs/specs/003-domain-routes.md @@ -1,6 +1,6 @@ # S-003: Domain routes -status: approved +status: built serves: G-3 ## Problem @@ -135,3 +135,21 @@ Touches `config/schemas/v1/domain.schema.json`, `sdk/src/cloudfall/inventory.py` - cloudfall-dev/cloudfall#162: S-003-2, S-003-3, S-003-4, S-003-5, S-003-6, S-003-7, S-003-13, S-003-14, S-003-15 ## Verification + +No criterion has run against a real Nginx or a live Debian host yet. Validation ran through `cloudfall config validate`; the vhost was rendered by Ansible's own `template` module on localhost from the engine inventory of the example project with routes; what a request answers was worked out by a small model of Nginx's longest-prefix match, `alias`, `root` and `try_files` over a release tree under the test's temporary directory; the ACL tasks ran on localhost with stand-ins for `getfacl` and `setfacl`. `nginx -t`, real requests, a real POSIX ACL and the `www-data` user are unexercised. + +- S-003-1: `test_s003_1_proxy_and_spa_static_routes_pass_config_validate` ran `cloudfall config validate` on the example Domain with a `/api/` proxy route and a `/` static route with `spaFallback: index.html`, exit 0; `test_s003_1_a_domain_with_only_an_upstream_serializes_as_before`, no `routes` key, passed +- S-003-2: `test_s003_2_routes_render_where_the_single_proxy_rendered`, TLS on and off on 1.22.1 and 1.26.3: balanced braces, every directive ending in `;`, the routes in the 80 server without TLS and in the 443 server with it, each version's HTTP/2 spelling, passed; `nginx -t` was not run +- S-003-3: `test_s003_3_unknown_paths_fall_back_and_assets_are_served`: `/` renders `alias /current/web/dist/;` and `try_files $uri /index.html;`, and the model answered `/some/unknown/path` with `index.html`'s bytes and `/assets/app.js` with that file's, passed; no real GET +- S-003-4: `test_s003_4_api_requests_reach_the_upstream_with_their_full_path`: `/api/` renders `proxy_pass http://127.0.0.1:8310;` with no URI part, so the model passes `/api/health` unchanged, passed; no real upstream saw the request +- S-003-5: `test_s003_5_a_static_route_without_a_fallback_answers_404`: a `/docs/` route without `spaFallback` renders no `try_files`, and the model answered an existing file 200 and a missing one 404, passed; no real GET +- S-003-6: `test_s003_6_a_rollback_serves_the_previous_release_without_nginx` ran `rollback.yml`'s own symlink switch on localhost and the model served the previous release's `index.html` for `/`; the vhost names `current`, never a release id, and the rollback tasks never touch Nginx, passed; no real `cloudfall rollback` or Nginx +- S-003-7: `test_s003_7_an_upstream_only_domain_renders_byte_identical` compared Ansible's render against `engine/tests/golden/nginx-site/`, captured from the template on origin/main before the change, for TLS on and off on 1.22.1 and 1.26.3, byte-identical +- S-003-8: `test_s003_8_two_routes_for_one_path_are_refused`, exit `CONFIG_INVALID` with `domain_route_path_duplicate`, passed +- S-003-9: `test_s003_9_a_domain_with_upstream_and_routes_is_refused` (`domain_route_source_invalid`) and `test_s003_9_a_domain_with_neither_upstream_nor_routes_still_passes`, passed +- S-003-10: `test_s003_10_a_route_on_the_acme_challenge_is_refused` (`domain_route_path_reserved`) and `test_s003_10_an_invalid_route_path_is_refused` (a static path without `/`, a space, `;`, `{`: `domain_route_path_invalid`), passed +- S-003-11: `test_s003_11_a_static_route_naming_a_missing_component_is_refused` (`resource_reference_missing`) and `test_s003_11_a_static_component_off_the_proxy_server_is_refused` (`domain_route_component_not_on_proxy`), passed +- S-003-12: `test_s003_12_a_static_directory_or_fallback_outside_the_release_is_refused` (`..`, absolute, `.`) and `test_s003_12_route_value_objects_match_the_schema`, passed +- S-003-13: `test_s003_13_locations_render_longest_path_first_whatever_the_order`, TLS on and off on both versions: `/api/v2/`, `/docs/`, `/api/`, `/` in that order, and the same routes declared in reverse rendered identical bytes, passed +- S-003-14: `test_s003_14_the_worker_may_only_traverse_to_the_release` ran the role's collect and grant tasks on localhost with stand-in ACL tools: `user:www-data:--x` on `/srv/apps/crm`, `/srv/apps/crm/backend` and its `releases/` only, nothing on a second run, the env file left `root` `0640` and no user or group task in the role; `test_s003_14_an_upstream_only_domain_grants_nothing`, passed. A real `www-data` reading or failing to read a file was not checked +- S-003-15: `test_s003_15_the_acme_challenge_still_renders_on_a_domain_with_routes`, TLS on and off: the 80 server keeps `location /.well-known/acme-challenge/ { root /var/lib/letsencrypt; }`, and the model served a token from the webroot and 404 for a missing one, passed; no real certbot challenge diff --git a/engine/README.md b/engine/README.md index 2946d50..b2bd62e 100644 --- a/engine/README.md +++ b/engine/README.md @@ -258,9 +258,20 @@ task domains:deploy task domains:deploy ISSUE_CERTIFICATES=true ``` -`proxy.upstream` is optional in the schema, for a Domain the fleet routes with -its own proxy, but the site role needs it. Before it changes any server, the -playbook fails and names every Domain of the run that has no `upstream`. +`proxy.upstream` and `routes` are optional in the schema, for a Domain the +fleet routes with its own proxy, but the site role needs one of them. Before it +changes any server, the playbook fails and names every Domain of the run that +has neither. + +A Domain with `routes` renders one `location` per route, longest path first. +A proxy route passes the full request path to its `upstream`; a static route +serves `/current/` of its component, so a deploy or a +rollback switches what it serves with no Nginx reload, and answers its +`spaFallback` for an unknown path. Without a `/` route, a path no route +matches answers 404. The role installs `acl` and grants the +Nginx worker (`www-data`) traverse-only access (`user:www-data:--x`) from the +application root to the install root and its `releases/`; the worker joins no +group, so `shared/env/.env` stays unreadable to it. The site role installs Nginx and Certbot, serves the ACME challenge webroot, and converges in two phases: until a certificate exists the route serves diff --git a/engine/ansible/playbooks/domains.yml b/engine/ansible/playbooks/domains.yml index 1bfa21e..0ebf192 100644 --- a/engine/ansible/playbooks/domains.yml +++ b/engine/ansible/playbooks/domains.yml @@ -1,14 +1,14 @@ --- -# `upstream` is optional in the schema, since a fleet may route a Domain with -# its own proxy, but cloudfall_nginx_site cannot render one without it. The -# check runs across every host of the run before the serial play below, so a -# refused run changes no server. +# `upstream` and `routes` are optional in the schema, since a fleet may route a +# Domain with its own proxy, but cloudfall_nginx_site cannot render one with +# neither. The check runs across every host of the run before the serial play +# below, so a refused run changes no server. - name: Refuse Domains the site role cannot render hosts: cloudfall_servers gather_facts: false any_errors_fatal: true tasks: - - name: Require an upstream on every Domain this run renders + - name: Require an upstream or routes on every Domain this run renders ansible.builtin.assert: that: - cloudfall_domains_without_upstream | length == 0 @@ -16,8 +16,8 @@ {{ 'Domain' if cloudfall_domains_without_upstream | length == 1 else 'Domains' }} {{ cloudfall_domains_without_upstream | join(', ') }} {{ 'has' if cloudfall_domains_without_upstream | length == 1 - else 'have' }} no proxy.upstream, which cloudfall_nginx_site needs to - render {{ 'it' if cloudfall_domains_without_upstream | length == 1 + else 'have' }} no proxy.upstream or routes, which cloudfall_nginx_site + needs to render {{ 'it' if cloudfall_domains_without_upstream | length == 1 else 'them' }}; no server was changed quiet: true vars: @@ -39,6 +39,7 @@ else cloudfall_domains_in_run ) | rejectattr('proxy.upstream', 'defined') + | rejectattr('routes', 'defined') | map(attribute='id') | unique | sort diff --git a/engine/ansible/roles/cloudfall_nginx_site/defaults/main.yml b/engine/ansible/roles/cloudfall_nginx_site/defaults/main.yml index 60a1a87..17530a1 100644 --- a/engine/ansible/roles/cloudfall_nginx_site/defaults/main.yml +++ b/engine/ansible/roles/cloudfall_nginx_site/defaults/main.yml @@ -2,3 +2,5 @@ cloudfall_nginx_site_issue_certificate: false cloudfall_nginx_site_acme_webroot: /var/lib/letsencrypt cloudfall_nginx_site_receipt_directory: "" +# Debian's Nginx runs its workers as www-data. +cloudfall_nginx_site_worker_user: www-data diff --git a/engine/ansible/roles/cloudfall_nginx_site/meta/argument_specs.yml b/engine/ansible/roles/cloudfall_nginx_site/meta/argument_specs.yml index fe15ea4..7f17fa1 100644 --- a/engine/ansible/roles/cloudfall_nginx_site/meta/argument_specs.yml +++ b/engine/ansible/roles/cloudfall_nginx_site/meta/argument_specs.yml @@ -39,6 +39,44 @@ argument_specs: port: type: int required: true + routes: + type: list + elements: dict + options: + path: + type: str + required: true + upstream: + type: dict + options: + address: + type: str + required: true + port: + type: int + required: true + static: + type: dict + options: + component: + type: str + required: true + directory: + type: str + required: true + spaFallback: + type: str + servedDirectory: + type: path + required: true + traverseDirectories: + type: list + elements: path + required: true + mutually_exclusive: + - [upstream, static] + required_one_of: + - [upstream, static] origin: type: dict required: true @@ -67,3 +105,6 @@ argument_specs: cloudfall_nginx_site_receipt_directory: type: str default: "" + cloudfall_nginx_site_worker_user: + type: str + default: www-data diff --git a/engine/ansible/roles/cloudfall_nginx_site/tasks/main.yml b/engine/ansible/roles/cloudfall_nginx_site/tasks/main.yml index 9fb01e6..3f6f9ce 100644 --- a/engine/ansible/roles/cloudfall_nginx_site/tasks/main.yml +++ b/engine/ansible/roles/cloudfall_nginx_site/tasks/main.yml @@ -5,11 +5,13 @@ - ansible_facts["distribution"] == "Debian" - ansible_facts["service_mgr"] == "systemd" - cloudfall_nginx_site_domain.proxy.server == cloudfall_server_id - - cloudfall_nginx_site_domain.proxy.upstream is defined + - >- + cloudfall_nginx_site_domain.proxy.upstream is defined + or cloudfall_nginx_site_domain.routes is defined - cloudfall_nginx_site_domain.tls.mode in ["disabled", "required"] fail_msg: >- Domain deployment requires its declared Debian proxy server and a - declared upstream endpoint + declared upstream endpoint or routes - name: Install Nginx and Certbot ansible.builtin.apt: @@ -20,6 +22,22 @@ update_cache: true cache_valid_time: 3600 +- name: Collect the directories on the static routes' paths + ansible.builtin.set_fact: + cloudfall_nginx_site_traverse_directories: >- + {{ + cloudfall_nginx_site_domain.routes | default([]) + | selectattr('static', 'defined') + | map(attribute='static.traverseDirectories') + | flatten(levels=1) + | unique + | list + }} + +- name: Let the Nginx worker reach the static routes' releases + ansible.builtin.include_tasks: static_access.yml + when: cloudfall_nginx_site_traverse_directories | length > 0 + # The TLS server block spells HTTP/2 the way the installed Nginx parses it: # `http2 on;` exists only from 1.25.1, and Debian 12 ships 1.22.1. Nginx # prints its version on stderr. The probe also runs in check mode, since the diff --git a/engine/ansible/roles/cloudfall_nginx_site/tasks/static_access.yml b/engine/ansible/roles/cloudfall_nginx_site/tasks/static_access.yml new file mode 100644 index 0000000..5dfbd7f --- /dev/null +++ b/engine/ansible/roles/cloudfall_nginx_site/tasks/static_access.yml @@ -0,0 +1,50 @@ +--- +# Bootstrap creates the application root, the install root and `releases/` +# 0750 for the application user, so the Nginx worker cannot reach a static +# route's release. It gets traverse-only access (`x`, never `r`) along that +# path by a POSIX ACL entry for its own user; it never joins the +# application's group, which can read `shared/env/.env`. The +# release directories below `releases/` keep their archived modes, and +# `current` is followed on every request, so a deploy or a rollback needs no +# second grant. +- name: Install the POSIX ACL tools + ansible.builtin.apt: + name: acl + state: present + update_cache: true + cache_valid_time: 3600 + +- name: Read the ACL of each directory on a static route's path + ansible.builtin.command: + argv: + - /usr/bin/getfacl + - --absolute-names + - --omit-header + - "{{ item }}" + loop: "{{ cloudfall_nginx_site_traverse_directories }}" + register: cloudfall_nginx_site_traverse_acls + changed_when: false + # In check mode the ACL tools may not be installed yet. + failed_when: + - cloudfall_nginx_site_traverse_acls.rc != 0 + - not ansible_check_mode + check_mode: false + +- name: Let the Nginx worker traverse each directory on a static route's path + ansible.builtin.command: + argv: + - /usr/bin/setfacl + - --modify + - "user:{{ cloudfall_nginx_site_worker_user }}:--x" + - "{{ item }}" + loop: >- + {{ + cloudfall_nginx_site_traverse_acls.results + | rejectattr( + 'stdout', 'search', + '(?m)^user:' ~ cloudfall_nginx_site_worker_user ~ ':..x' + ) + | map(attribute='item') + | list + }} + changed_when: true diff --git a/engine/ansible/roles/cloudfall_nginx_site/templates/domain-site.conf.j2 b/engine/ansible/roles/cloudfall_nginx_site/templates/domain-site.conf.j2 index 6cb8c58..8ec49ca 100644 --- a/engine/ansible/roles/cloudfall_nginx_site/templates/domain-site.conf.j2 +++ b/engine/ansible/roles/cloudfall_nginx_site/templates/domain-site.conf.j2 @@ -3,13 +3,12 @@ [cloudfall_nginx_site_domain.primaryName] + cloudfall_nginx_site_domain.aliases ) | join(' ') %} -{% set upstream_address = cloudfall_nginx_site_domain.proxy.upstream.address %} -{% set upstream_host = '[' ~ upstream_address ~ ']' - if ':' in upstream_address else upstream_address %} -{% macro proxy_location() %} - location / { +{% macro proxy_location(path, upstream) %} +{% set upstream_host = '[' ~ upstream.address ~ ']' + if ':' in upstream.address else upstream.address %} + location {{ path }} { proxy_http_version 1.1; - proxy_pass http://{{ upstream_host }}:{{ cloudfall_nginx_site_domain.proxy.upstream.port }}; + proxy_pass http://{{ upstream_host }}:{{ upstream.port }}; proxy_pass_request_headers on; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; @@ -19,6 +18,58 @@ proxy_set_header X-Forwarded-Port $server_port; } {% endmacro %} +{# + A static route serves the component's current release, so a deploy or a + rollback that moves `current` changes what it serves without a reload. Its + path and the alias both end in `/`, which keeps the alias from reaching + outside the directory. +#} +{% macro static_location(path, static) %} + location {{ path }} { + alias {{ static.servedDirectory }}/; +{% if static.spaFallback is defined %} + try_files $uri {{ path }}{{ static.spaFallback }}; +{% endif %} + } +{% endmacro %} +{# + Routes render longest path first, then by path, so the vhost does not + depend on the declared order; Nginx matches the longest prefix either way. + A Domain with only proxy.upstream renders its one `location /` as before. +#} +{% macro locations() %} +{% if cloudfall_nginx_site_domain.routes is defined %} +{% set routes = cloudfall_nginx_site_domain.routes | sort(attribute='path') %} +{% set ordered = namespace(routes=[]) %} +{% for length in routes | map(attribute='path') | map('length') | unique | sort(reverse=true) %} +{% for route in routes if route.path | length == length %} +{% set ordered.routes = ordered.routes + [route] %} +{% endfor %} +{% endfor %} +{% for route in ordered.routes %} +{% if not loop.first %} + +{% endif %} +{% if route.static is defined %} +{{ static_location(route.path, route.static) -}} +{% else %} +{{ proxy_location(route.path, route.upstream) -}} +{% endif %} +{% endfor %} +{# + Without a `/` route, a path no route matches would fall to Nginx's default + root (Debian's welcome page, answering 200); it answers 404 instead. +#} +{% if routes | selectattr('path', 'equalto', '/') | list | length == 0 %} + + location / { + return 404; + } +{% endif %} +{% else %} +{{ proxy_location('/', cloudfall_nginx_site_domain.proxy.upstream) -}} +{% endif %} +{% endmacro %} server { listen 80; listen [::]:80; @@ -33,7 +84,7 @@ server { return 301 https://$host$request_uri; } {% else %} -{{ proxy_location() -}} +{{ locations() -}} {% endif %} } {% if cloudfall_nginx_site_tls_active %} @@ -54,6 +105,6 @@ server { ssl_protocols TLSv1.2 TLSv1.3; ssl_session_tickets off; -{{ proxy_location() -}} +{{ locations() -}} } {% endif %} diff --git a/engine/src/cloudfall_engine/ansible_inventory.py b/engine/src/cloudfall_engine/ansible_inventory.py index 39f5531..4a9f391 100644 --- a/engine/src/cloudfall_engine/ansible_inventory.py +++ b/engine/src/cloudfall_engine/ansible_inventory.py @@ -2,21 +2,24 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from pathlib import PurePosixPath +from typing import TYPE_CHECKING, cast from cloudfall.inventory import ( + DomainStaticRoute, firewall_rules_for_server, remote_access_rules_for_server, ) if TYPE_CHECKING: - from collections.abc import Iterable + from collections.abc import Iterable, Mapping from cloudfall.domain import ResourceId from cloudfall.inventory import ( AlertRuleInventory, ApplicationInventory, ComponentInventory, + DomainInventory, LoggingStackInventory, PlatformInventory, ServerInventory, @@ -24,6 +27,9 @@ ServiceInventory, ) +# cloudfall_bootstrap's default, below which validation holds every installRoot. +_APPLICATIONS_ROOT = PurePosixPath("/srv/apps") + def render_ansible_inventory(inventory: PlatformInventory) -> dict[str, object]: """Render static JSON accepted by Ansible's YAML inventory plugin.""" @@ -103,6 +109,9 @@ def _host_variables(inventory: PlatformInventory) -> dict[str, object]: server_types_by_id = { server_type.resource_id: server_type for server_type in inventory.server_types } + components_by_id = { + component.resource_id: component for component in inventory.components + } return { server.resource_id.value: { "ansible_host": server.address.value, @@ -146,7 +155,7 @@ def _host_variables(inventory: PlatformInventory) -> dict[str, object]: if service.server_id == server.resource_id ], "cloudfall_domains": [ - domain.as_dict() + _host_domain(domain, components_by_id) for domain in inventory.domains if domain.proxy.server_id == server.resource_id ], @@ -261,6 +270,51 @@ def _host_component( return variables +def _host_domain( + domain: DomainInventory, + components_by_id: Mapping[ResourceId, ComponentInventory], +) -> dict[str, object]: + """Serialize a Domain with each static route resolved on its proxy server. + + A static route serves ``/current/``, so a deploy or + a rollback that moves ``current`` changes what it serves without a reload. + ``traverseDirectories`` lists the directories, from the application root + to the install root and its ``releases/``, that bootstrap creates ``0750`` + and the nginx worker must pass through to reach the release. + """ + variables = domain.as_dict() + if not domain.routes: + return variables + routes: list[dict[str, object]] = [] + for route in domain.routes: + rendered = route.as_dict() + if isinstance(route, DomainStaticRoute): + component = components_by_id[route.component_id] + install_root = PurePosixPath(component.install_root.value) + static = cast("dict[str, object]", rendered["static"]) + static["servedDirectory"] = str( + install_root / "current" / route.directory.value + ) + static["traverseDirectories"] = _traverse_directories( + install_root, component.application_id + ) + routes.append(rendered) + variables["routes"] = routes + return variables + + +def _traverse_directories( + install_root: PurePosixPath, application_id: ResourceId +) -> list[str]: + application_root = _APPLICATIONS_ROOT / application_id.value + below_root = [ + directory + for directory in (*reversed(install_root.parents), install_root) + if directory.is_relative_to(application_root) + ] + return [str(directory) for directory in (*below_root, install_root / "releases")] + + def _application_is_on_server( application: ApplicationInventory, server: ServerInventory, diff --git a/engine/tests/golden/nginx-site/http-1.22.1.conf b/engine/tests/golden/nginx-site/http-1.22.1.conf new file mode 100644 index 0000000..ea9e684 --- /dev/null +++ b/engine/tests/golden/nginx-site/http-1.22.1.conf @@ -0,0 +1,22 @@ +# Managed by Cloudfall. Local changes will be overwritten. +server { + listen 80; + listen [::]:80; + server_name crm.example.test www.crm.example.test; + + location /.well-known/acme-challenge/ { + root /var/lib/letsencrypt; + } + + location / { + proxy_http_version 1.1; + proxy_pass http://127.0.0.1:8100; + proxy_pass_request_headers on; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Port $server_port; + } +} diff --git a/engine/tests/golden/nginx-site/http-1.26.3.conf b/engine/tests/golden/nginx-site/http-1.26.3.conf new file mode 100644 index 0000000..ea9e684 --- /dev/null +++ b/engine/tests/golden/nginx-site/http-1.26.3.conf @@ -0,0 +1,22 @@ +# Managed by Cloudfall. Local changes will be overwritten. +server { + listen 80; + listen [::]:80; + server_name crm.example.test www.crm.example.test; + + location /.well-known/acme-challenge/ { + root /var/lib/letsencrypt; + } + + location / { + proxy_http_version 1.1; + proxy_pass http://127.0.0.1:8100; + proxy_pass_request_headers on; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Port $server_port; + } +} diff --git a/engine/tests/golden/nginx-site/tls-1.22.1.conf b/engine/tests/golden/nginx-site/tls-1.22.1.conf new file mode 100644 index 0000000..0d7866b --- /dev/null +++ b/engine/tests/golden/nginx-site/tls-1.22.1.conf @@ -0,0 +1,37 @@ +# Managed by Cloudfall. Local changes will be overwritten. +server { + listen 80; + listen [::]:80; + server_name crm.example.test www.crm.example.test; + + location /.well-known/acme-challenge/ { + root /var/lib/letsencrypt; + } + + location / { + return 301 https://$host$request_uri; + } +} + +server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + server_name crm.example.test www.crm.example.test; + + ssl_certificate /etc/letsencrypt/live/crm.example.test/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/crm.example.test/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_session_tickets off; + + location / { + proxy_http_version 1.1; + proxy_pass http://127.0.0.1:8100; + proxy_pass_request_headers on; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Port $server_port; + } +} diff --git a/engine/tests/golden/nginx-site/tls-1.26.3.conf b/engine/tests/golden/nginx-site/tls-1.26.3.conf new file mode 100644 index 0000000..f47c26f --- /dev/null +++ b/engine/tests/golden/nginx-site/tls-1.26.3.conf @@ -0,0 +1,38 @@ +# Managed by Cloudfall. Local changes will be overwritten. +server { + listen 80; + listen [::]:80; + server_name crm.example.test www.crm.example.test; + + location /.well-known/acme-challenge/ { + root /var/lib/letsencrypt; + } + + location / { + return 301 https://$host$request_uri; + } +} + +server { + listen 443 ssl; + listen [::]:443 ssl; + http2 on; + server_name crm.example.test www.crm.example.test; + + ssl_certificate /etc/letsencrypt/live/crm.example.test/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/crm.example.test/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_session_tickets off; + + location / { + proxy_http_version 1.1; + proxy_pass http://127.0.0.1:8100; + proxy_pass_request_headers on; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Port $server_port; + } +} diff --git a/engine/tests/test_domains_playbook.py b/engine/tests/test_domains_playbook.py index 4cbe0a4..788c13b 100644 --- a/engine/tests/test_domains_playbook.py +++ b/engine/tests/test_domains_playbook.py @@ -15,7 +15,7 @@ from cloudfall_engine.playbook import PlaybookRun, playbook_command, resolve_playbook ENGINE = Path(__file__).parents[2] / "engine" -REFUSAL = "no proxy.upstream" +REFUSAL = "no proxy.upstream or routes" def _domain(domain_id: str, server: str, *, upstream: bool) -> dict[str, object]: @@ -104,7 +104,7 @@ def test_a_domain_without_upstream_stops_the_run_before_any_server( ) refusal = next(line for line in output.splitlines() if REFUSAL in line) - assert "Domains bare-one, bare-two have no proxy.upstream" in refusal + assert "Domains bare-one, bare-two have no proxy.upstream or routes" in refusal assert "render them;" in refusal assert "site-" not in refusal assert "UNREACHABLE" not in output @@ -128,6 +128,21 @@ def test_domains_that_render_pass_the_check_and_reach_their_servers( assert "UNREACHABLE" in output +def test_a_domain_with_routes_passes_the_check_and_reaches_its_server( + tmp_path: Path, +) -> None: + """S-003: the early check reads "proxy.upstream or routes".""" + routed = _domain("site-r", "edge-a", upstream=False) + routed["routes"] = [ + {"path": "/api/", "upstream": {"address": "127.0.0.1", "port": 8310}} + ] + + output = _run(tmp_path, {"edge-a": _host("edge-a", "192.0.2.10", [routed])}) + + assert REFUSAL not in output + assert "UNREACHABLE" in output + + def _shared_server() -> dict[str, object]: """One server proxying a Domain that renders and one that cannot.""" return { @@ -156,6 +171,6 @@ def test_a_one_domain_run_for_a_domain_without_upstream_is_refused( output = _run(tmp_path, _shared_server(), domain_id="bare-one") refusal = next(line for line in output.splitlines() if REFUSAL in line) - assert "Domain bare-one has no proxy.upstream" in refusal + assert "Domain bare-one has no proxy.upstream or routes" in refusal assert "render it;" in refusal assert "UNREACHABLE" not in output diff --git a/engine/tests/test_nginx_site_routes.py b/engine/tests/test_nginx_site_routes.py new file mode 100644 index 0000000..fa75b9c --- /dev/null +++ b/engine/tests/test_nginx_site_routes.py @@ -0,0 +1,611 @@ +"""Domain routes rendered by ``cloudfall_nginx_site`` (S-003). + +There is no Nginx here, so the vhost is rendered by Ansible's own template +module on localhost and read back: its locations, their order and their +directives. What a request answers is worked out by ``_serve``, a small model +of Nginx's longest-prefix match, ``alias`` and ``try_files`` over a release +tree under ``tmp_path``. The ACL tasks run on localhost with stand-ins for +``getfacl`` and ``setfacl``. ``nginx -t`` and real requests are left for a live +proving run. +""" + +from __future__ import annotations + +import json +import shutil +import subprocess +import sys +from pathlib import Path +from typing import cast + +import pytest +import yaml +from cloudfall.inventory import PlatformInventory +from cloudfall.validation import validate_config +from cloudfall_engine.ansible_inventory import render_ansible_inventory + +ROOT = Path(__file__).parents[2] +EXAMPLES = ROOT / "config" / "examples" +SCHEMAS = ROOT / "config" / "schemas" / "v1" +ROLE = ROOT / "engine" / "ansible" / "roles" / "cloudfall_nginx_site" +TEMPLATE = ROLE / "templates" / "domain-site.conf.j2" +DEPLOY_TASKS = ROOT / "engine" / "ansible" / "roles" / "cloudfall_deploy" / "tasks" +GOLDEN = Path(__file__).parent / "golden" / "nginx-site" +NGINX_VERSIONS = ("1.22.1", "1.26.3") +INSTALL_ROOT = "/srv/apps/crm/backend" + +_UPSTREAM = " upstream:\n address: 127.0.0.1\n port: 8100\n" +_SPA_ROUTES = """\ + routes: + - path: /api/ + upstream: { address: 127.0.0.1, port: 8310 } + - path: / + static: + component: crm-backend + directory: web/dist + spaFallback: index.html +""" +# The same routes as _SPA_ROUTES plus a nested one, declared shortest first. +_NESTED_ROUTES = """\ + routes: + - path: / + static: + component: crm-backend + directory: web/dist + spaFallback: index.html + - path: /api/ + upstream: { address: 127.0.0.1, port: 8310 } + - path: /api/v2/ + upstream: { address: 127.0.0.1, port: 8320 } + - path: /docs/ + static: + component: crm-backend + directory: web/docs +""" +# Routes with no `/` route: nothing the Domain declares answers `/`. +_NO_ROOT_ROUTES = """\ + routes: + - path: /api/ + upstream: { address: 127.0.0.1, port: 8310 } + - path: /docs/ + static: + component: crm-backend + directory: web/docs +""" +_NESTED_ROUTES_REVERSED = """\ + routes: + - path: /docs/ + static: + component: crm-backend + directory: web/docs + - path: /api/v2/ + upstream: { address: 127.0.0.1, port: 8320 } + - path: /api/ + upstream: { address: 127.0.0.1, port: 8310 } + - path: / + static: + component: crm-backend + directory: web/dist + spaFallback: index.html +""" + + +def _upstream_only_domain() -> dict[str, object]: + """Return the Domain the golden files were rendered from on origin/main.""" + return { + "id": "crm-site", + "primaryName": "crm.example.test", + "aliases": ["www.crm.example.test"], + "proxy": { + "server": "h2", + "configurationPath": "/etc/nginx/sites-available/crm.example.test.conf", + "service": "nginx.service", + "upstream": {"address": "127.0.0.1", "port": 8100}, + }, + "tls": {"mode": "required"}, + } + + +def _routed_domain(tmp_path: Path, routes: str) -> dict[str, object]: + """Render the example Domain with ``routes`` through the engine inventory.""" + project = tmp_path / f"project-{abs(hash(routes))}" + shutil.copytree(EXAMPLES, project) + path = project / "domains" / "crm-site.yaml" + text = path.read_text(encoding="utf-8") + assert _UPSTREAM in text + text = text.replace(_UPSTREAM, "").replace(" origin:\n", routes + " origin:\n") + path.write_text(text, encoding="utf-8") + inventory = render_ansible_inventory( + PlatformInventory.from_state(validate_config(project, SCHEMAS)) + ) + children = cast("dict[str, dict[str, object]]", inventory["all"])["children"] + servers = cast("dict[str, dict[str, dict[str, object]]]", children) + hosts = cast("dict[str, dict[str, object]]", servers["cloudfall_servers"]["hosts"]) + domains = hosts["h2"]["cloudfall_domains"] + (domain,) = cast("list[dict[str, object]]", domains) + return domain + + +def _ansible_playbook() -> str: + binary = shutil.which("ansible-playbook") + assert binary is not None + return binary + + +def _play(tmp_path: Path, name: str, play: dict[str, object]) -> str: + playbook = tmp_path / f"{name}.yml" + playbook.write_text( + yaml.safe_dump( + [ + {"hosts": "localhost", "connection": "local", "gather_facts": False} + | play + ] + ), + encoding="utf-8", + ) + done = subprocess.run( # noqa: S603 - fixed binary and arguments. + [_ansible_playbook(), "--inventory", "localhost,", str(playbook)], + check=False, + capture_output=True, + text=True, + timeout=300, + ) + assert done.returncode == 0, done.stdout + done.stderr + return done.stdout + + +def _render_all( + tmp_path: Path, domains: dict[str, dict[str, object]] +) -> dict[str, str]: + """Render each Domain with TLS on and off on both Nginx versions.""" + output = tmp_path / "rendered" + output.mkdir() + tasks: list[dict[str, object]] = [] + names: list[str] = [] + for label, domain in domains.items(): + for version in NGINX_VERSIONS: + for tls_active in (True, False): + name = f"{label}/{'tls' if tls_active else 'http'}-{version}.conf" + (output / label).mkdir(exist_ok=True) + names.append(name) + tasks.append( + { + "name": f"Render {name}", + "ansible.builtin.template": { + "src": str(TEMPLATE), + "dest": str(output / name), + "mode": "0644", + }, + "vars": { + "cloudfall_nginx_site_domain": domain, + "cloudfall_nginx_site_acme_webroot": "/var/lib/letsencrypt", + "cloudfall_nginx_site_tls_active": tls_active, + "cloudfall_nginx_site_version": version, + }, + } + ) + _play(tmp_path, "render", {"tasks": tasks}) + return {name: (output / name).read_text(encoding="utf-8") for name in names} + + +@pytest.fixture(scope="module") +def rendered(tmp_path_factory: pytest.TempPathFactory) -> dict[str, str]: + tmp_path = tmp_path_factory.mktemp("nginx-site-routes") + return _render_all( + tmp_path, + { + "upstream": _upstream_only_domain(), + "spa": _routed_domain(tmp_path, _SPA_ROUTES), + "nested": _routed_domain(tmp_path, _NESTED_ROUTES), + "reversed": _routed_domain(tmp_path, _NESTED_ROUTES_REVERSED), + "no-root": _routed_domain(tmp_path, _NO_ROOT_ROUTES), + }, + ) + + +def _servers(vhost: str) -> list[str]: + """Split a vhost into its top-level ``server { ... }`` blocks.""" + blocks: list[str] = [] + depth = 0 + start = 0 + for index, character in enumerate(vhost): + if character == "{": + if depth == 0: + start = vhost.rindex("server", 0, index) + depth += 1 + elif character == "}": + depth -= 1 + assert depth >= 0, vhost + if depth == 0: + blocks.append(vhost[start : index + 1]) + assert depth == 0, vhost + return blocks + + +def _locations(server: str) -> dict[str, list[str]]: + """Each ``location { ... }`` of a server block, in order.""" + found: dict[str, list[str]] = {} + lines = server.splitlines() + index = 0 + while index < len(lines): + line = lines[index].strip() + if line.startswith("location "): + path = line.removeprefix("location ").removesuffix(" {") + directives: list[str] = [] + index += 1 + while lines[index].strip() != "}": + directives.append(lines[index].strip()) + index += 1 + assert path not in found, server + found[path] = directives + index += 1 + return found + + +def _serving_server(vhost: str, *, tls_active: bool) -> str: + servers = _servers(vhost) + assert len(servers) == (2 if tls_active else 1) + return servers[-1] + + +def _serve(server: str, uri: str, filesystem: Path) -> tuple[int, bytes | str]: + """Answer ``uri`` the way Nginx matches and serves these locations. + + The longest prefix wins; a proxy location answers its upstream and the + path the upstream sees, a static one maps the rest of the URI under its + alias and falls back through ``try_files`` to an internal redirect. + ``filesystem`` stands in for ``/``. + """ + locations = _locations(server) + for _ in range(10): + path = max((p for p in locations if uri.startswith(p)), key=len) + directives = locations[path] + proxy = [d for d in directives if d.startswith("proxy_pass ")] + if proxy: + target = proxy[0].removeprefix("proxy_pass ").removesuffix(";") + # No URI part on proxy_pass: the request path passes unchanged. + assert target.count("/") == 2, target + return 200, f"{target}{uri}" + if directives[0].startswith("return "): + _, code, *location = directives[0].removesuffix(";").split() + return int(code), " ".join(location) + directive, directory = directives[0].removesuffix(";").split() + assert path.endswith("/") + if directive == "root": + # `root` appends the whole URI. + candidate = filesystem / (directory + uri).lstrip("/") + else: + assert directive == "alias" + assert directory.endswith("/") + candidate = filesystem / (directory + uri[len(path) :]).lstrip("/") + if candidate.is_dir() and (candidate / "index.html").is_file(): + return 200, (candidate / "index.html").read_bytes() + if candidate.is_file(): + return 200, candidate.read_bytes() + fallback = [d for d in directives if d.startswith("try_files ")] + if not fallback: + return 404, b"" + uri = fallback[0].split()[-1].removesuffix(";") + pytest.fail(f"internal redirection cycle for {uri}") + + +def _release_tree(filesystem: Path, releases: dict[str, str]) -> Path: + """Lay out ``releases//web/dist`` and point ``current`` at the last.""" + install_root = filesystem / INSTALL_ROOT.lstrip("/") + for release, marker in releases.items(): + dist = install_root / "releases" / release / "web" / "dist" + (dist / "assets").mkdir(parents=True) + (dist / "index.html").write_text(f"{marker}\n", encoding="utf-8") + (dist / "assets" / "app.js").write_text(f"// {marker}\n", encoding="utf-8") + (install_root / "current").symlink_to(install_root / "releases" / release) + env = install_root / "shared" / "env" + env.mkdir(parents=True) + (env / "crm-backend.env").write_text("SECRET=1\n", encoding="utf-8") + return install_root + + +# proves: S-003-7 +@pytest.mark.parametrize("nginx_version", NGINX_VERSIONS) +@pytest.mark.parametrize("tls", ["tls", "http"]) +def test_s003_7_an_upstream_only_domain_renders_byte_identical( + rendered: dict[str, str], tls: str, nginx_version: str +) -> None: + """The golden files are origin/main's render, captured before S-003.""" + name = f"{tls}-{nginx_version}.conf" + golden = (GOLDEN / name).read_text(encoding="utf-8") + + assert rendered[f"upstream/{name}"] == golden + + +# proves: S-003-2 +@pytest.mark.parametrize("nginx_version", NGINX_VERSIONS) +@pytest.mark.parametrize("tls_active", [True, False]) +def test_s003_2_routes_render_where_the_single_proxy_rendered( + rendered: dict[str, str], *, tls_active: bool, nginx_version: str +) -> None: + vhost = rendered[f"spa/{'tls' if tls_active else 'http'}-{nginx_version}.conf"] + servers = _servers(vhost) + plain = _locations(servers[0]) + + if tls_active: + assert plain["/"] == ["return 301 https://$host$request_uri;"] + assert ("listen 443 ssl http2;" in servers[1]) is (nginx_version == "1.22.1") + assert ("http2 on;" in servers[1]) is (nginx_version == "1.26.3") + serving = _locations(_serving_server(vhost, tls_active=tls_active)) + assert set(serving) >= {"/api/", "/"} + for directives in serving.values(): + assert directives + assert all(d.endswith(";") for d in directives), directives + assert "None" not in vhost + assert "{{" not in vhost + assert "{%" not in vhost + + +# proves: S-003-3 +@pytest.mark.parametrize("tls_active", [True, False]) +def test_s003_3_unknown_paths_fall_back_and_assets_are_served( + rendered: dict[str, str], tmp_path: Path, *, tls_active: bool +) -> None: + vhost = rendered[f"spa/{'tls' if tls_active else 'http'}-1.26.3.conf"] + server = _serving_server(vhost, tls_active=tls_active) + _release_tree(tmp_path, {"r1": "release one"}) + dist = tmp_path / INSTALL_ROOT.lstrip("/") / "current" / "web" / "dist" + + assert _locations(server)["/"] == [ + f"alias {INSTALL_ROOT}/current/web/dist/;", + "try_files $uri /index.html;", + ] + assert _serve(server, "/some/unknown/path", tmp_path) == ( + 200, + (dist / "index.html").read_bytes(), + ) + assert _serve(server, "/assets/app.js", tmp_path) == ( + 200, + (dist / "assets" / "app.js").read_bytes(), + ) + + +# proves: S-003-4 +@pytest.mark.parametrize("tls_active", [True, False]) +def test_s003_4_api_requests_reach_the_upstream_with_their_full_path( + rendered: dict[str, str], tmp_path: Path, *, tls_active: bool +) -> None: + vhost = rendered[f"spa/{'tls' if tls_active else 'http'}-1.22.1.conf"] + server = _serving_server(vhost, tls_active=tls_active) + + assert "proxy_pass http://127.0.0.1:8310;" in _locations(server)["/api/"] + assert _serve(server, "/api/health", tmp_path) == ( + 200, + "http://127.0.0.1:8310/api/health", + ) + + +# proves: S-003-5 +def test_s003_5_a_static_route_without_a_fallback_answers_404( + rendered: dict[str, str], tmp_path: Path +) -> None: + server = _serving_server(rendered["nested/http-1.26.3.conf"], tls_active=False) + docs = tmp_path / INSTALL_ROOT.lstrip("/") / "current" / "web" / "docs" + _release_tree(tmp_path, {"r1": "release one"}) + docs.mkdir() + (docs / "guide.html").write_text("guide\n", encoding="utf-8") + + assert _locations(server)["/docs/"] == [f"alias {INSTALL_ROOT}/current/web/docs/;"] + assert _serve(server, "/docs/guide.html", tmp_path) == (200, b"guide\n") + assert _serve(server, "/docs/missing.html", tmp_path) == (404, b"") + + +@pytest.mark.parametrize("tls_active", [True, False]) +def test_a_path_no_route_matches_answers_404( + rendered: dict[str, str], tmp_path: Path, *, tls_active: bool +) -> None: + """Without a `/` route, Nginx would serve its default root for `/`. + + On Debian that is `/usr/share/nginx/html`, whose welcome page answers 200, + so a Domain health check on `/` would pass against nothing the Domain + declares. + """ + vhost = rendered[f"no-root/{'tls' if tls_active else 'http'}-1.22.1.conf"] + server = _serving_server(vhost, tls_active=tls_active) + locations = _locations(server) + + assert locations["/"] == ["return 404;"] + assert list(locations)[-1] == "/" + assert _serve(server, "/", tmp_path) == (404, "") + assert _serve(server, "/elsewhere", tmp_path) == (404, "") + assert _serve(server, "/api/health", tmp_path) == ( + 200, + "http://127.0.0.1:8310/api/health", + ) + + +def _switch_current(tmp_path: Path, install_root: Path, release: str) -> None: + """Run rollback.yml's own symlink switch on localhost.""" + tasks = yaml.safe_load((DEPLOY_TASKS / "rollback.yml").read_text(encoding="utf-8")) + (switch,) = ( + task + for task in tasks + if task["name"] == "Switch the current release symlink to the rollback target" + ) + _play( + tmp_path, + "rollback", + { + "vars": { + "cloudfall_deploy_release_directory": str( + install_root / "releases" / release + ), + "cloudfall_deploy_current_link": str(install_root / "current"), + }, + "tasks": [switch], + }, + ) + + +# proves: S-003-6 +def test_s003_6_a_rollback_serves_the_previous_release_without_nginx( + rendered: dict[str, str], tmp_path: Path +) -> None: + vhost = rendered["spa/tls-1.26.3.conf"] + server = _serving_server(vhost, tls_active=True) + install_root = _release_tree(tmp_path, {"r1": "release one", "r2": "release two"}) + assert _serve(server, "/", tmp_path) == (200, b"release two\n") + + _switch_current(tmp_path, install_root, "r1") + + assert _serve(server, "/", tmp_path) == (200, b"release one\n") + # The vhost reads the release through `current`, never a release id, and + # the rollback tasks neither render the vhost nor reload Nginx. + assert "/releases/" not in vhost + rollback = (DEPLOY_TASKS / "rollback.yml").read_text(encoding="utf-8") + assert "nginx" not in rollback.lower() + + +# proves: S-003-13 +@pytest.mark.parametrize("nginx_version", NGINX_VERSIONS) +@pytest.mark.parametrize("tls", ["tls", "http"]) +def test_s003_13_locations_render_longest_path_first_whatever_the_order( + rendered: dict[str, str], tls: str, nginx_version: str +) -> None: + name = f"{tls}-{nginx_version}.conf" + vhost = rendered[f"nested/{name}"] + server = _serving_server(vhost, tls_active=tls == "tls") + + assert vhost == rendered[f"reversed/{name}"] + routes = [ + path for path in _locations(server) if path != "/.well-known/acme-challenge/" + ] + assert routes == ["/api/v2/", "/docs/", "/api/", "/"] + + +# proves: S-003-15 +@pytest.mark.parametrize("tls_active", [True, False]) +def test_s003_15_the_acme_challenge_still_renders_on_a_domain_with_routes( + rendered: dict[str, str], tmp_path: Path, *, tls_active: bool +) -> None: + vhost = rendered[f"spa/{'tls' if tls_active else 'http'}-1.22.1.conf"] + plain = _servers(vhost)[0] + challenge = tmp_path / "var" / "lib" / "letsencrypt" / ".well-known" + (challenge / "acme-challenge").mkdir(parents=True) + (challenge / "acme-challenge" / "token").write_text("proof\n", encoding="utf-8") + + assert _locations(plain)["/.well-known/acme-challenge/"] == [ + "root /var/lib/letsencrypt;" + ] + assert _serve(plain, "/.well-known/acme-challenge/token", tmp_path) == ( + 200, + b"proof\n", + ) + assert _serve(plain, "/.well-known/acme-challenge/missing", tmp_path) == ( + 404, + b"", + ) + + +def _stand_in(path: Path, body: str) -> Path: + path.write_text(f"#!{sys.executable}\n{body}", encoding="utf-8") + path.chmod(0o755) + return path + + +_GETFACL = """\ +import json, sys +state = json.load(open(sys.argv[0] + ".json")) +directory = sys.argv[-1] +print("user::rwx") +for entry in state.get(directory, []): + print(entry) +print("group::r-x") +print("other::---") +""" +_SETFACL = """\ +import json, sys +path = sys.argv[0].replace("setfacl", "getfacl") + ".json" +state = json.load(open(path)) +assert sys.argv[1] == "--modify", sys.argv +state.setdefault(sys.argv[3], []).append(sys.argv[2]) +json.dump(state, open(path, "w")) +with open(sys.argv[0] + ".log", "a") as log: + log.write(json.dumps(sys.argv[1:]) + "\\n") +""" + + +def _grant_play(tmp_path: Path, domain: dict[str, object]) -> list[list[str]]: + """Run the role's collect-and-grant tasks with stand-in ACL tools.""" + getfacl = _stand_in(tmp_path / "getfacl", _GETFACL) + setfacl = _stand_in(tmp_path / "setfacl", _SETFACL) + state = Path(f"{getfacl}.json") + if not state.exists(): + state.write_text("{}", encoding="utf-8") + main = yaml.safe_load((ROLE / "tasks" / "main.yml").read_text(encoding="utf-8")) + (collect,) = ( + t + for t in main + if t["name"] == "Collect the directories on the static routes' paths" + ) + access = yaml.safe_load( + (ROLE / "tasks" / "static_access.yml").read_text(encoding="utf-8") + ) + commands = [t for t in access if "ansible.builtin.command" in t] + for task, binary in zip(commands, (getfacl, setfacl), strict=True): + task["ansible.builtin.command"]["argv"][0] = str(binary) + _play( + tmp_path, + "grant", + { + "vars": { + "cloudfall_nginx_site_domain": domain, + "cloudfall_nginx_site_worker_user": "www-data", + }, + "tasks": [collect, *commands], + }, + ) + log = Path(f"{setfacl}.log") + calls = ( + [json.loads(line) for line in log.read_text(encoding="utf-8").splitlines()] + if log.exists() + else [] + ) + log.unlink(missing_ok=True) + return calls + + +# proves: S-003-14 +def test_s003_14_the_worker_may_only_traverse_to_the_release(tmp_path: Path) -> None: + domain = _routed_domain(tmp_path, _NESTED_ROUTES) + routes = cast("list[dict[str, dict[str, object]]]", domain["routes"]) + statics = [route["static"] for route in routes if "static" in route] + path = [ + "/srv/apps/crm", + "/srv/apps/crm/backend", + "/srv/apps/crm/backend/releases", + ] + assert [s["servedDirectory"] for s in statics] == [ + f"{INSTALL_ROOT}/current/web/dist", + f"{INSTALL_ROOT}/current/web/docs", + ] + assert all(s["traverseDirectories"] == path for s in statics) + + first = _grant_play(tmp_path, domain) + second = _grant_play(tmp_path, domain) + + assert first == [["--modify", "user:www-data:--x", d] for d in path] + assert second == [] + # Nothing under shared/ is touched, and the worker joins no group: the + # env file stays root: 0640, which www-data cannot read. + deploy = yaml.safe_load((DEPLOY_TASKS / "main.yml").read_text(encoding="utf-8")) + (env,) = ( + t for t in deploy if t["name"] == "Install the component environment file" + ) + assert env["ansible.builtin.copy"]["mode"] == "0640" + assert env["ansible.builtin.copy"]["owner"] == "root" + role_tasks = "".join( + p.read_text(encoding="utf-8") for p in (ROLE / "tasks").glob("*.yml") + ) + assert "ansible.builtin.user" not in role_tasks + assert "ansible.builtin.group" not in role_tasks + + +# proves: S-003-14 +def test_s003_14_an_upstream_only_domain_grants_nothing(tmp_path: Path) -> None: + assert _grant_play(tmp_path, _upstream_only_domain()) == [] diff --git a/sdk/tests/test_domains_apply.py b/sdk/tests/test_domains_apply.py index 0feb8da..f3e8342 100644 --- a/sdk/tests/test_domains_apply.py +++ b/sdk/tests/test_domains_apply.py @@ -550,7 +550,7 @@ def _recap_hosts(output: str) -> list[str]: DOMAINS_TASKS = { "Gathering Facts", - "Require an upstream on every Domain this run renders", + "Require an upstream or routes on every Domain this run renders", "Render declared domains as managed virtual hosts", "cloudfall_nginx_site : Record what this host rendered", "cloudfall_nginx_site : Create the receipt directory",