diff --git a/changelog.d/174-no-templating-config-values.md b/changelog.d/174-no-templating-config-values.md new file mode 100644 index 0000000..b706c9c --- /dev/null +++ b/changelog.d/174-no-templating-config-values.md @@ -0,0 +1,3 @@ +### Security + +- A declared config value is never run as an Ansible template: `cloudfall-engine inventory render` and the overlay `cloudfall observe` writes mark every string unsafe (`{"__ansible_unsafe": ...}`), so a value holding `{{ ... }}` reaches the unit file, the release command and the health check as written. Before, a `service.command`, `healthCheck.command` or `release.command` argument such as `{{ lookup('pipe', '...') }}` ran on the management host during `cloudfall health` or `cloudfall deploy`, and an honest one such as `docker inspect --format {{.State.Health.Status}} app` failed with a template syntax error. A declared value that relied on Ansible evaluating `{{ ... }}` is now literal, and the rendered inventory's strings read as `{"__ansible_unsafe": "..."}` in its JSON and in `ansible-inventory --list` (#174) diff --git a/engine/src/cloudfall_engine/cli.py b/engine/src/cloudfall_engine/cli.py index 0d11d86..38e0405 100644 --- a/engine/src/cloudfall_engine/cli.py +++ b/engine/src/cloudfall_engine/cli.py @@ -17,6 +17,7 @@ from pathlib import Path from typing import TYPE_CHECKING, Self, cast +from cloudfall.ansible_data import as_ansible_data, unmarked from cloudfall.commands import snake_case_keys from cloudfall.domain import ( ARTIFACT_CLONE_TIMEOUT_SECONDS, @@ -223,8 +224,14 @@ class RenderedInventory: ], ) def render(args: RenderArgs, _ctx: Ctx, project: Project) -> RenderedInventory: - """Render the inventory, and write it when ``--output`` names a file.""" - rendered = render_ansible_inventory(project.inventory) + """Render the inventory, and write it when ``--output`` names a file. + + Every declared string is marked unsafe, so Ansible never templates it. + """ + rendered = cast( + "dict[str, object]", + as_ansible_data(render_ansible_inventory(project.inventory)), + ) if args.output is None: return RenderedInventory(rendered) output = project.path(args.output) @@ -595,8 +602,12 @@ def _rendered_owned_databases( if not inventory_file.is_file(): return () try: + # The rendered strings are marked unsafe for Ansible (#174). document = cast( - "object", json.loads(inventory_file.read_text(encoding="utf-8")) + "object", + json.loads( + inventory_file.read_text(encoding="utf-8"), object_hook=unmarked + ), ) except (UnicodeDecodeError, json.JSONDecodeError): return () diff --git a/engine/tests/golden/examples/ansible-inventory.json b/engine/tests/golden/examples/ansible-inventory.json index c89883a..df33ea5 100644 --- a/engine/tests/golden/examples/ansible-inventory.json +++ b/engine/tests/golden/examples/ansible-inventory.json @@ -1 +1 @@ -{"all": {"children": {"cloudfall_applications": {"children": {"application_crm": {"hosts": {"h1": {}, "h2": {}}}}}, "cloudfall_components": {"children": {"component_crm_backend": {"hosts": {"h1": {}, "h2": {}}}}}, "cloudfall_domain_origins": {"hosts": {"h1": {}}}, "cloudfall_environments": {"children": {"environment_production": {"hosts": {"h1": {}, "h2": {}}}}}, "cloudfall_logging_backends": {"hosts": {"h1": {}}}, "cloudfall_logging_collectors": {"hosts": {"h1": {}, "h2": {}}}, "cloudfall_release_servers": {"hosts": {}}, "cloudfall_servers": {"hosts": {"h1": {"ansible_host": "h1.example.internal", "ansible_port": 22, "ansible_user": "cloudfall", "cloudfall_alert_rules": [{"environment": "production", "expr": "pg_up{service=\"postgresql-main\"} == 0", "for": "1m", "id": "postgresql-down", "severity": "critical", "summary": "PostgreSQL postgresql-main is not answering its exporter"}], "cloudfall_applications": [{"approval": "manual", "id": "crm", "user": "crm"}], "cloudfall_components": [{"application": "crm", "application_user": "crm", "healthCheck": {"attempts": 5, "expectedStatuses": [200], "path": "/health", "port": 8100, "scheme": "http", "timeoutSeconds": 5, "type": "http"}, "id": "crm-backend", "install_root": "/srv/apps/crm/backend", "repository": {"url": "https://github.com/example/crm-backend.git"}, "retainUntilCleanup": true, "runtime": {"packageManager": "uv", "type": "python", "version": "3.14"}, "service": {"command": [".venv/bin/uvicorn", "crm.asgi:application", "--host", "127.0.0.1", "--port", "8100"], "manager": "systemd", "name": "crm-backend"}}], "cloudfall_domains": [], "cloudfall_environment": "production", "cloudfall_firewall": {"allowedInbound": [{"description": "ssh", "port": 22, "protocol": "tcp"}, {"description": "http", "port": 80, "protocol": "tcp"}, {"description": "https", "port": 443, "protocol": "tcp"}], "policy": "default-deny"}, "cloudfall_hostname": "h1.example.internal", "cloudfall_labels": {"region": "eu-central", "workload": "application"}, "cloudfall_lifecycle": "active", "cloudfall_logging_backend": {"alerting": {"alertmanager": {"listenAddress": "127.0.0.1", "port": 9093}, "receivers": [{"id": "operations-webhook", "webhook": {"url": "https://hooks.example.internal/cloudfall"}}, {"email": {"authPasswordFile": "/etc/cloudfall/logging/smtp-password", "authUsername": "alerts@example.internal", "from": "alerts@example.internal", "smarthost": "smtp.example.internal:587", "to": "oncall@example.internal"}, "id": "operations-email"}]}, "backend": {"listenAddress": "127.0.0.1", "port": 3100, "retentionHours": 720, "server": "h1", "storage": {"path": "/var/lib/loki-cloudfall", "type": "filesystem"}}, "collectors": {"clientTls": {"caPath": "/etc/cloudfall/logging/ca.crt", "certificatePath": "/etc/cloudfall/logging/client.crt", "keyPath": "/etc/cloudfall/logging/client.key"}, "files": [{"application": "crm", "component": "crm-backend", "id": "crm-backend", "path": "/var/log/crm/backend/*.log", "servers": ["h1", "h2"]}], "journal": {"enabled": true, "maxAgeHours": 24}, "servers": ["h1", "h2"]}, "environment": "production", "gateway": {"listenAddress": "0.0.0.0", "port": 3101, "serverName": "logs.example.internal", "tls": {"certificatePath": "/etc/cloudfall/logging/server.crt", "clientCaPath": "/etc/cloudfall/logging/client-ca.crt", "keyPath": "/etc/cloudfall/logging/server.key"}}, "grafana": {"adminPasswordPath": "/etc/cloudfall/logging/grafana-admin-password", "listenAddress": "127.0.0.1", "port": 3000}, "id": "operations", "metrics": {"backend": {"listenAddress": "127.0.0.1", "port": 9090, "retentionHours": 360, "storagePath": "/var/lib/prometheus-cloudfall"}, "collection": {"intervalSeconds": 60}}, "migration": {"mode": "parallel", "preserveLegacyAgents": true}, "software": {"alloyPackageVersion": "1.17.1-1", "grafanaPackageVersion": "13.1.0", "lokiPackageVersion": "3.7.2"}}, "cloudfall_logging_collector": {"alerting": {"alertmanager": {"listenAddress": "127.0.0.1", "port": 9093}, "receivers": [{"id": "operations-webhook", "webhook": {"url": "https://hooks.example.internal/cloudfall"}}, {"email": {"authPasswordFile": "/etc/cloudfall/logging/smtp-password", "authUsername": "alerts@example.internal", "from": "alerts@example.internal", "smarthost": "smtp.example.internal:587", "to": "oncall@example.internal"}, "id": "operations-email"}]}, "backend": {"listenAddress": "127.0.0.1", "port": 3100, "retentionHours": 720, "server": "h1", "storage": {"path": "/var/lib/loki-cloudfall", "type": "filesystem"}}, "collectors": {"clientTls": {"caPath": "/etc/cloudfall/logging/ca.crt", "certificatePath": "/etc/cloudfall/logging/client.crt", "keyPath": "/etc/cloudfall/logging/client.key"}, "files": [{"application": "crm", "component": "crm-backend", "id": "crm-backend", "path": "/var/log/crm/backend/*.log", "servers": ["h1", "h2"]}], "journal": {"enabled": true, "maxAgeHours": 24}, "servers": ["h1", "h2"]}, "environment": "production", "gateway": {"listenAddress": "0.0.0.0", "port": 3101, "serverName": "logs.example.internal", "tls": {"certificatePath": "/etc/cloudfall/logging/server.crt", "clientCaPath": "/etc/cloudfall/logging/client-ca.crt", "keyPath": "/etc/cloudfall/logging/server.key"}}, "grafana": {"adminPasswordPath": "/etc/cloudfall/logging/grafana-admin-password", "listenAddress": "127.0.0.1", "port": 3000}, "id": "operations", "metrics": {"backend": {"listenAddress": "127.0.0.1", "port": 9090, "retentionHours": 360, "storagePath": "/var/lib/prometheus-cloudfall"}, "collection": {"intervalSeconds": 60}}, "migration": {"mode": "parallel", "preserveLegacyAgents": true}, "software": {"alloyPackageVersion": "1.17.1-1", "grafanaPackageVersion": "13.1.0", "lokiPackageVersion": "3.7.2"}}, "cloudfall_origin_domains": [{"aliases": ["www.crm.example.test"], "edge": {"mode": "dns-only", "provider": "cloudflare"}, "healthCheck": {"expectedStatuses": [200], "path": "/health", "scheme": "https", "timeoutSeconds": 5}, "id": "crm-site", "origin": {"configurationPath": "/etc/nginx/sites-available/crm-origin.conf", "port": 8100, "scheme": "http", "server": "h1", "serverName": "crm.example.test", "service": "nginx.service"}, "primaryName": "crm.example.test", "proxy": {"configurationPath": "/etc/nginx/sites-available/crm.example.test.conf", "server": "h2", "service": "nginx.service", "upstream": {"address": "127.0.0.1", "port": 8100}}, "tls": {"mode": "required"}}], "cloudfall_server_id": "h1", "cloudfall_server_type": {"configuration": {"files": [{"capture": "hash", "group": "root", "mode": "0644", "owner": "root", "path": "/etc/ssh/sshd_config"}]}, "firewall": {"allowedInbound": [{"description": "http", "port": 80, "protocol": "tcp"}, {"description": "https", "port": 443, "protocol": "tcp"}], "policy": "default-deny"}, "id": "debian-application", "os": {"distribution": "Debian", "serviceManager": "systemd", "versions": ["12", "13"]}, "packages": {"forbidden": ["telnetd"], "required": [{"name": "acl"}, {"name": "ca-certificates"}, {"name": "curl"}, {"name": "git"}, {"name": "rsync"}, {"name": "unzip"}]}, "services": {"required": [{"name": "ssh.service", "state": "running", "status": "enabled"}, {"name": "apt-daily.timer", "state": "running", "status": "enabled"}]}, "storage": {"mounts": [{"filesystem": "ext4", "minimumBytes": 850000000000, "path": "/"}], "softwareRaid": {"level": "raid1", "minimumActiveDevices": 2, "minimumUsableBytes": 900000000000}}}, "cloudfall_services": [{"backup": {"directory": "/var/backups/cloudfall/postgresql-main", "onCalendar": "*-*-* 02:00:00 UTC", "restoreCheckOnCalendar": "*-*-* 05:00:00 UTC", "retentionDays": 14}, "bind": {"address": "127.0.0.1", "port": 5432}, "environment": "production", "id": "postgresql-main", "metrics": {"enabled": true}, "postgresql": {"databases": [{"application": "crm", "name": "crm", "owner": "crm"}], "majorVersion": "17"}, "server": "h1", "serviceKind": "postgresql"}, {"backup": {"directory": "/var/backups/cloudfall/redis-cache", "onCalendar": "*-*-* 03:00:00 UTC", "restoreCheckOnCalendar": "*-*-* 05:30:00 UTC", "retentionDays": 7}, "bind": {"address": "127.0.0.1", "port": 6379}, "environment": "production", "id": "redis-cache", "metrics": {"enabled": true}, "redis": {"appendOnly": true, "maxmemoryMb": 256}, "server": "h1", "serviceKind": "redis"}], "cloudfall_ssh_public_keys": [{"algorithm": "ssh-rsa", "environment": "production", "id": "example-admin", "lifecycle": "active", "owner": "example-admin", "publicKey": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCqmOqPPapyG6hL5FlR/KOK2ENEvi/2qlhoRtrgK7JfafHXb1i4WJwf5FMGrPE4DBRfDQDwokABdvUPITZOymFMgiGMmFKGnp0xq+/B0wTTOtdnr8GC3sCmSRwuFDrroVWwfjIqyC/wlpoPXpSvyc1VKBuXOe/gLv1tTzDH3Jc+3fUYaUCj+uIVO65n0N2ajNg9ibBsCjiUp9CrWEoprqxR4lz3TaiPuRtMxWQlQ2NKPSTdUZWqg9KVXm3TS8xgf4Ox2dFwedVMcucC8UBBAwetgylXNZMv1yAMYD3QDokLi0AC+XnNR+UhtFXr9Y3VveQfH4YKnv0Fhr2opCyN99Lr example"}]}, "h2": {"ansible_host": "h2.example.internal", "ansible_port": 22, "ansible_user": "cloudfall", "cloudfall_applications": [{"approval": "manual", "id": "crm", "user": "crm"}], "cloudfall_components": [{"application": "crm", "application_user": "crm", "healthCheck": {"attempts": 5, "expectedStatuses": [200], "path": "/health", "port": 8100, "scheme": "http", "timeoutSeconds": 5, "type": "http"}, "id": "crm-backend", "install_root": "/srv/apps/crm/backend", "repository": {"url": "https://github.com/example/crm-backend.git"}, "retainUntilCleanup": true, "runtime": {"packageManager": "uv", "type": "python", "version": "3.14"}, "service": {"command": [".venv/bin/uvicorn", "crm.asgi:application", "--host", "127.0.0.1", "--port", "8100"], "manager": "systemd", "name": "crm-backend"}}], "cloudfall_domains": [{"aliases": ["www.crm.example.test"], "edge": {"mode": "dns-only", "provider": "cloudflare"}, "healthCheck": {"expectedStatuses": [200], "path": "/health", "scheme": "https", "timeoutSeconds": 5}, "id": "crm-site", "origin": {"configurationPath": "/etc/nginx/sites-available/crm-origin.conf", "port": 8100, "scheme": "http", "server": "h1", "serverName": "crm.example.test", "service": "nginx.service"}, "primaryName": "crm.example.test", "proxy": {"configurationPath": "/etc/nginx/sites-available/crm.example.test.conf", "server": "h2", "service": "nginx.service", "upstream": {"address": "127.0.0.1", "port": 8100}}, "tls": {"mode": "required"}}], "cloudfall_environment": "production", "cloudfall_firewall": {"allowedInbound": [{"description": "ssh", "port": 22, "protocol": "tcp"}, {"description": "http", "port": 80, "protocol": "tcp"}, {"description": "https", "port": 443, "protocol": "tcp"}], "policy": "default-deny"}, "cloudfall_hostname": "h2.example.internal", "cloudfall_labels": {"region": "eu-central", "workload": "application"}, "cloudfall_lifecycle": "active", "cloudfall_logging_collector": {"alerting": {"alertmanager": {"listenAddress": "127.0.0.1", "port": 9093}, "receivers": [{"id": "operations-webhook", "webhook": {"url": "https://hooks.example.internal/cloudfall"}}, {"email": {"authPasswordFile": "/etc/cloudfall/logging/smtp-password", "authUsername": "alerts@example.internal", "from": "alerts@example.internal", "smarthost": "smtp.example.internal:587", "to": "oncall@example.internal"}, "id": "operations-email"}]}, "backend": {"listenAddress": "127.0.0.1", "port": 3100, "retentionHours": 720, "server": "h1", "storage": {"path": "/var/lib/loki-cloudfall", "type": "filesystem"}}, "collectors": {"clientTls": {"caPath": "/etc/cloudfall/logging/ca.crt", "certificatePath": "/etc/cloudfall/logging/client.crt", "keyPath": "/etc/cloudfall/logging/client.key"}, "files": [{"application": "crm", "component": "crm-backend", "id": "crm-backend", "path": "/var/log/crm/backend/*.log", "servers": ["h1", "h2"]}], "journal": {"enabled": true, "maxAgeHours": 24}, "servers": ["h1", "h2"]}, "environment": "production", "gateway": {"listenAddress": "0.0.0.0", "port": 3101, "serverName": "logs.example.internal", "tls": {"certificatePath": "/etc/cloudfall/logging/server.crt", "clientCaPath": "/etc/cloudfall/logging/client-ca.crt", "keyPath": "/etc/cloudfall/logging/server.key"}}, "grafana": {"adminPasswordPath": "/etc/cloudfall/logging/grafana-admin-password", "listenAddress": "127.0.0.1", "port": 3000}, "id": "operations", "metrics": {"backend": {"listenAddress": "127.0.0.1", "port": 9090, "retentionHours": 360, "storagePath": "/var/lib/prometheus-cloudfall"}, "collection": {"intervalSeconds": 60}}, "migration": {"mode": "parallel", "preserveLegacyAgents": true}, "software": {"alloyPackageVersion": "1.17.1-1", "grafanaPackageVersion": "13.1.0", "lokiPackageVersion": "3.7.2"}}, "cloudfall_origin_domains": [], "cloudfall_server_id": "h2", "cloudfall_server_type": {"configuration": {"files": [{"capture": "hash", "group": "root", "mode": "0644", "owner": "root", "path": "/etc/ssh/sshd_config"}]}, "firewall": {"allowedInbound": [{"description": "http", "port": 80, "protocol": "tcp"}, {"description": "https", "port": 443, "protocol": "tcp"}], "policy": "default-deny"}, "id": "debian-application", "os": {"distribution": "Debian", "serviceManager": "systemd", "versions": ["12", "13"]}, "packages": {"forbidden": ["telnetd"], "required": [{"name": "acl"}, {"name": "ca-certificates"}, {"name": "curl"}, {"name": "git"}, {"name": "rsync"}, {"name": "unzip"}]}, "services": {"required": [{"name": "ssh.service", "state": "running", "status": "enabled"}, {"name": "apt-daily.timer", "state": "running", "status": "enabled"}]}, "storage": {"mounts": [{"filesystem": "ext4", "minimumBytes": 850000000000, "path": "/"}], "softwareRaid": {"level": "raid1", "minimumActiveDevices": 2, "minimumUsableBytes": 900000000000}}}, "cloudfall_services": [], "cloudfall_ssh_public_keys": [{"algorithm": "ssh-rsa", "environment": "production", "id": "example-admin", "lifecycle": "active", "owner": "example-admin", "publicKey": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCqmOqPPapyG6hL5FlR/KOK2ENEvi/2qlhoRtrgK7JfafHXb1i4WJwf5FMGrPE4DBRfDQDwokABdvUPITZOymFMgiGMmFKGnp0xq+/B0wTTOtdnr8GC3sCmSRwuFDrroVWwfjIqyC/wlpoPXpSvyc1VKBuXOe/gLv1tTzDH3Jc+3fUYaUCj+uIVO65n0N2ajNg9ibBsCjiUp9CrWEoprqxR4lz3TaiPuRtMxWQlQ2NKPSTdUZWqg9KVXm3TS8xgf4Ox2dFwedVMcucC8UBBAwetgylXNZMv1yAMYD3QDokLi0AC+XnNR+UhtFXr9Y3VveQfH4YKnv0Fhr2opCyN99Lr example"}]}}}, "ungrouped": {"hosts": {}}}}} +{"all": {"children": {"cloudfall_applications": {"children": {"application_crm": {"hosts": {"h1": {}, "h2": {}}}}}, "cloudfall_components": {"children": {"component_crm_backend": {"hosts": {"h1": {}, "h2": {}}}}}, "cloudfall_domain_origins": {"hosts": {"h1": {}}}, "cloudfall_environments": {"children": {"environment_production": {"hosts": {"h1": {}, "h2": {}}}}}, "cloudfall_logging_backends": {"hosts": {"h1": {}}}, "cloudfall_logging_collectors": {"hosts": {"h1": {}, "h2": {}}}, "cloudfall_release_servers": {"hosts": {}}, "cloudfall_servers": {"hosts": {"h1": {"ansible_host": {"__ansible_unsafe": "h1.example.internal"}, "ansible_port": 22, "ansible_user": {"__ansible_unsafe": "cloudfall"}, "cloudfall_alert_rules": [{"environment": {"__ansible_unsafe": "production"}, "expr": {"__ansible_unsafe": "pg_up{service=\"postgresql-main\"} == 0"}, "for": {"__ansible_unsafe": "1m"}, "id": {"__ansible_unsafe": "postgresql-down"}, "severity": {"__ansible_unsafe": "critical"}, "summary": {"__ansible_unsafe": "PostgreSQL postgresql-main is not answering its exporter"}}], "cloudfall_applications": [{"approval": {"__ansible_unsafe": "manual"}, "id": {"__ansible_unsafe": "crm"}, "user": {"__ansible_unsafe": "crm"}}], "cloudfall_components": [{"application": {"__ansible_unsafe": "crm"}, "application_user": {"__ansible_unsafe": "crm"}, "healthCheck": {"attempts": 5, "expectedStatuses": [200], "path": {"__ansible_unsafe": "/health"}, "port": 8100, "scheme": {"__ansible_unsafe": "http"}, "timeoutSeconds": 5, "type": {"__ansible_unsafe": "http"}}, "id": {"__ansible_unsafe": "crm-backend"}, "install_root": {"__ansible_unsafe": "/srv/apps/crm/backend"}, "repository": {"url": {"__ansible_unsafe": "https://github.com/example/crm-backend.git"}}, "retainUntilCleanup": true, "runtime": {"packageManager": {"__ansible_unsafe": "uv"}, "type": {"__ansible_unsafe": "python"}, "version": {"__ansible_unsafe": "3.14"}}, "service": {"command": [{"__ansible_unsafe": ".venv/bin/uvicorn"}, {"__ansible_unsafe": "crm.asgi:application"}, {"__ansible_unsafe": "--host"}, {"__ansible_unsafe": "127.0.0.1"}, {"__ansible_unsafe": "--port"}, {"__ansible_unsafe": "8100"}], "manager": {"__ansible_unsafe": "systemd"}, "name": {"__ansible_unsafe": "crm-backend"}}}], "cloudfall_domains": [], "cloudfall_environment": {"__ansible_unsafe": "production"}, "cloudfall_firewall": {"allowedInbound": [{"description": {"__ansible_unsafe": "ssh"}, "port": 22, "protocol": {"__ansible_unsafe": "tcp"}}, {"description": {"__ansible_unsafe": "http"}, "port": 80, "protocol": {"__ansible_unsafe": "tcp"}}, {"description": {"__ansible_unsafe": "https"}, "port": 443, "protocol": {"__ansible_unsafe": "tcp"}}], "policy": {"__ansible_unsafe": "default-deny"}}, "cloudfall_hostname": {"__ansible_unsafe": "h1.example.internal"}, "cloudfall_labels": {"region": {"__ansible_unsafe": "eu-central"}, "workload": {"__ansible_unsafe": "application"}}, "cloudfall_lifecycle": {"__ansible_unsafe": "active"}, "cloudfall_logging_backend": {"alerting": {"alertmanager": {"listenAddress": {"__ansible_unsafe": "127.0.0.1"}, "port": 9093}, "receivers": [{"id": {"__ansible_unsafe": "operations-webhook"}, "webhook": {"url": {"__ansible_unsafe": "https://hooks.example.internal/cloudfall"}}}, {"email": {"authPasswordFile": {"__ansible_unsafe": "/etc/cloudfall/logging/smtp-password"}, "authUsername": {"__ansible_unsafe": "alerts@example.internal"}, "from": {"__ansible_unsafe": "alerts@example.internal"}, "smarthost": {"__ansible_unsafe": "smtp.example.internal:587"}, "to": {"__ansible_unsafe": "oncall@example.internal"}}, "id": {"__ansible_unsafe": "operations-email"}}]}, "backend": {"listenAddress": {"__ansible_unsafe": "127.0.0.1"}, "port": 3100, "retentionHours": 720, "server": {"__ansible_unsafe": "h1"}, "storage": {"path": {"__ansible_unsafe": "/var/lib/loki-cloudfall"}, "type": {"__ansible_unsafe": "filesystem"}}}, "collectors": {"clientTls": {"caPath": {"__ansible_unsafe": "/etc/cloudfall/logging/ca.crt"}, "certificatePath": {"__ansible_unsafe": "/etc/cloudfall/logging/client.crt"}, "keyPath": {"__ansible_unsafe": "/etc/cloudfall/logging/client.key"}}, "files": [{"application": {"__ansible_unsafe": "crm"}, "component": {"__ansible_unsafe": "crm-backend"}, "id": {"__ansible_unsafe": "crm-backend"}, "path": {"__ansible_unsafe": "/var/log/crm/backend/*.log"}, "servers": [{"__ansible_unsafe": "h1"}, {"__ansible_unsafe": "h2"}]}], "journal": {"enabled": true, "maxAgeHours": 24}, "servers": [{"__ansible_unsafe": "h1"}, {"__ansible_unsafe": "h2"}]}, "environment": {"__ansible_unsafe": "production"}, "gateway": {"listenAddress": {"__ansible_unsafe": "0.0.0.0"}, "port": 3101, "serverName": {"__ansible_unsafe": "logs.example.internal"}, "tls": {"certificatePath": {"__ansible_unsafe": "/etc/cloudfall/logging/server.crt"}, "clientCaPath": {"__ansible_unsafe": "/etc/cloudfall/logging/client-ca.crt"}, "keyPath": {"__ansible_unsafe": "/etc/cloudfall/logging/server.key"}}}, "grafana": {"adminPasswordPath": {"__ansible_unsafe": "/etc/cloudfall/logging/grafana-admin-password"}, "listenAddress": {"__ansible_unsafe": "127.0.0.1"}, "port": 3000}, "id": {"__ansible_unsafe": "operations"}, "metrics": {"backend": {"listenAddress": {"__ansible_unsafe": "127.0.0.1"}, "port": 9090, "retentionHours": 360, "storagePath": {"__ansible_unsafe": "/var/lib/prometheus-cloudfall"}}, "collection": {"intervalSeconds": 60}}, "migration": {"mode": {"__ansible_unsafe": "parallel"}, "preserveLegacyAgents": true}, "software": {"alloyPackageVersion": {"__ansible_unsafe": "1.17.1-1"}, "grafanaPackageVersion": {"__ansible_unsafe": "13.1.0"}, "lokiPackageVersion": {"__ansible_unsafe": "3.7.2"}}}, "cloudfall_logging_collector": {"alerting": {"alertmanager": {"listenAddress": {"__ansible_unsafe": "127.0.0.1"}, "port": 9093}, "receivers": [{"id": {"__ansible_unsafe": "operations-webhook"}, "webhook": {"url": {"__ansible_unsafe": "https://hooks.example.internal/cloudfall"}}}, {"email": {"authPasswordFile": {"__ansible_unsafe": "/etc/cloudfall/logging/smtp-password"}, "authUsername": {"__ansible_unsafe": "alerts@example.internal"}, "from": {"__ansible_unsafe": "alerts@example.internal"}, "smarthost": {"__ansible_unsafe": "smtp.example.internal:587"}, "to": {"__ansible_unsafe": "oncall@example.internal"}}, "id": {"__ansible_unsafe": "operations-email"}}]}, "backend": {"listenAddress": {"__ansible_unsafe": "127.0.0.1"}, "port": 3100, "retentionHours": 720, "server": {"__ansible_unsafe": "h1"}, "storage": {"path": {"__ansible_unsafe": "/var/lib/loki-cloudfall"}, "type": {"__ansible_unsafe": "filesystem"}}}, "collectors": {"clientTls": {"caPath": {"__ansible_unsafe": "/etc/cloudfall/logging/ca.crt"}, "certificatePath": {"__ansible_unsafe": "/etc/cloudfall/logging/client.crt"}, "keyPath": {"__ansible_unsafe": "/etc/cloudfall/logging/client.key"}}, "files": [{"application": {"__ansible_unsafe": "crm"}, "component": {"__ansible_unsafe": "crm-backend"}, "id": {"__ansible_unsafe": "crm-backend"}, "path": {"__ansible_unsafe": "/var/log/crm/backend/*.log"}, "servers": [{"__ansible_unsafe": "h1"}, {"__ansible_unsafe": "h2"}]}], "journal": {"enabled": true, "maxAgeHours": 24}, "servers": [{"__ansible_unsafe": "h1"}, {"__ansible_unsafe": "h2"}]}, "environment": {"__ansible_unsafe": "production"}, "gateway": {"listenAddress": {"__ansible_unsafe": "0.0.0.0"}, "port": 3101, "serverName": {"__ansible_unsafe": "logs.example.internal"}, "tls": {"certificatePath": {"__ansible_unsafe": "/etc/cloudfall/logging/server.crt"}, "clientCaPath": {"__ansible_unsafe": "/etc/cloudfall/logging/client-ca.crt"}, "keyPath": {"__ansible_unsafe": "/etc/cloudfall/logging/server.key"}}}, "grafana": {"adminPasswordPath": {"__ansible_unsafe": "/etc/cloudfall/logging/grafana-admin-password"}, "listenAddress": {"__ansible_unsafe": "127.0.0.1"}, "port": 3000}, "id": {"__ansible_unsafe": "operations"}, "metrics": {"backend": {"listenAddress": {"__ansible_unsafe": "127.0.0.1"}, "port": 9090, "retentionHours": 360, "storagePath": {"__ansible_unsafe": "/var/lib/prometheus-cloudfall"}}, "collection": {"intervalSeconds": 60}}, "migration": {"mode": {"__ansible_unsafe": "parallel"}, "preserveLegacyAgents": true}, "software": {"alloyPackageVersion": {"__ansible_unsafe": "1.17.1-1"}, "grafanaPackageVersion": {"__ansible_unsafe": "13.1.0"}, "lokiPackageVersion": {"__ansible_unsafe": "3.7.2"}}}, "cloudfall_origin_domains": [{"aliases": [{"__ansible_unsafe": "www.crm.example.test"}], "edge": {"mode": {"__ansible_unsafe": "dns-only"}, "provider": {"__ansible_unsafe": "cloudflare"}}, "healthCheck": {"expectedStatuses": [200], "path": {"__ansible_unsafe": "/health"}, "scheme": {"__ansible_unsafe": "https"}, "timeoutSeconds": 5}, "id": {"__ansible_unsafe": "crm-site"}, "origin": {"configurationPath": {"__ansible_unsafe": "/etc/nginx/sites-available/crm-origin.conf"}, "port": 8100, "scheme": {"__ansible_unsafe": "http"}, "server": {"__ansible_unsafe": "h1"}, "serverName": {"__ansible_unsafe": "crm.example.test"}, "service": {"__ansible_unsafe": "nginx.service"}}, "primaryName": {"__ansible_unsafe": "crm.example.test"}, "proxy": {"configurationPath": {"__ansible_unsafe": "/etc/nginx/sites-available/crm.example.test.conf"}, "server": {"__ansible_unsafe": "h2"}, "service": {"__ansible_unsafe": "nginx.service"}, "upstream": {"address": {"__ansible_unsafe": "127.0.0.1"}, "port": 8100}}, "tls": {"mode": {"__ansible_unsafe": "required"}}}], "cloudfall_server_id": {"__ansible_unsafe": "h1"}, "cloudfall_server_type": {"configuration": {"files": [{"capture": {"__ansible_unsafe": "hash"}, "group": {"__ansible_unsafe": "root"}, "mode": {"__ansible_unsafe": "0644"}, "owner": {"__ansible_unsafe": "root"}, "path": {"__ansible_unsafe": "/etc/ssh/sshd_config"}}]}, "firewall": {"allowedInbound": [{"description": {"__ansible_unsafe": "http"}, "port": 80, "protocol": {"__ansible_unsafe": "tcp"}}, {"description": {"__ansible_unsafe": "https"}, "port": 443, "protocol": {"__ansible_unsafe": "tcp"}}], "policy": {"__ansible_unsafe": "default-deny"}}, "id": {"__ansible_unsafe": "debian-application"}, "os": {"distribution": {"__ansible_unsafe": "Debian"}, "serviceManager": {"__ansible_unsafe": "systemd"}, "versions": [{"__ansible_unsafe": "12"}, {"__ansible_unsafe": "13"}]}, "packages": {"forbidden": [{"__ansible_unsafe": "telnetd"}], "required": [{"name": {"__ansible_unsafe": "acl"}}, {"name": {"__ansible_unsafe": "ca-certificates"}}, {"name": {"__ansible_unsafe": "curl"}}, {"name": {"__ansible_unsafe": "git"}}, {"name": {"__ansible_unsafe": "rsync"}}, {"name": {"__ansible_unsafe": "unzip"}}]}, "services": {"required": [{"name": {"__ansible_unsafe": "ssh.service"}, "state": {"__ansible_unsafe": "running"}, "status": {"__ansible_unsafe": "enabled"}}, {"name": {"__ansible_unsafe": "apt-daily.timer"}, "state": {"__ansible_unsafe": "running"}, "status": {"__ansible_unsafe": "enabled"}}]}, "storage": {"mounts": [{"filesystem": {"__ansible_unsafe": "ext4"}, "minimumBytes": 850000000000, "path": {"__ansible_unsafe": "/"}}], "softwareRaid": {"level": {"__ansible_unsafe": "raid1"}, "minimumActiveDevices": 2, "minimumUsableBytes": 900000000000}}}, "cloudfall_services": [{"backup": {"directory": {"__ansible_unsafe": "/var/backups/cloudfall/postgresql-main"}, "onCalendar": {"__ansible_unsafe": "*-*-* 02:00:00 UTC"}, "restoreCheckOnCalendar": {"__ansible_unsafe": "*-*-* 05:00:00 UTC"}, "retentionDays": 14}, "bind": {"address": {"__ansible_unsafe": "127.0.0.1"}, "port": 5432}, "environment": {"__ansible_unsafe": "production"}, "id": {"__ansible_unsafe": "postgresql-main"}, "metrics": {"enabled": true}, "postgresql": {"databases": [{"application": {"__ansible_unsafe": "crm"}, "name": {"__ansible_unsafe": "crm"}, "owner": {"__ansible_unsafe": "crm"}}], "majorVersion": {"__ansible_unsafe": "17"}}, "server": {"__ansible_unsafe": "h1"}, "serviceKind": {"__ansible_unsafe": "postgresql"}}, {"backup": {"directory": {"__ansible_unsafe": "/var/backups/cloudfall/redis-cache"}, "onCalendar": {"__ansible_unsafe": "*-*-* 03:00:00 UTC"}, "restoreCheckOnCalendar": {"__ansible_unsafe": "*-*-* 05:30:00 UTC"}, "retentionDays": 7}, "bind": {"address": {"__ansible_unsafe": "127.0.0.1"}, "port": 6379}, "environment": {"__ansible_unsafe": "production"}, "id": {"__ansible_unsafe": "redis-cache"}, "metrics": {"enabled": true}, "redis": {"appendOnly": true, "maxmemoryMb": 256}, "server": {"__ansible_unsafe": "h1"}, "serviceKind": {"__ansible_unsafe": "redis"}}], "cloudfall_ssh_public_keys": [{"algorithm": {"__ansible_unsafe": "ssh-rsa"}, "environment": {"__ansible_unsafe": "production"}, "id": {"__ansible_unsafe": "example-admin"}, "lifecycle": {"__ansible_unsafe": "active"}, "owner": {"__ansible_unsafe": "example-admin"}, "publicKey": {"__ansible_unsafe": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCqmOqPPapyG6hL5FlR/KOK2ENEvi/2qlhoRtrgK7JfafHXb1i4WJwf5FMGrPE4DBRfDQDwokABdvUPITZOymFMgiGMmFKGnp0xq+/B0wTTOtdnr8GC3sCmSRwuFDrroVWwfjIqyC/wlpoPXpSvyc1VKBuXOe/gLv1tTzDH3Jc+3fUYaUCj+uIVO65n0N2ajNg9ibBsCjiUp9CrWEoprqxR4lz3TaiPuRtMxWQlQ2NKPSTdUZWqg9KVXm3TS8xgf4Ox2dFwedVMcucC8UBBAwetgylXNZMv1yAMYD3QDokLi0AC+XnNR+UhtFXr9Y3VveQfH4YKnv0Fhr2opCyN99Lr example"}}]}, "h2": {"ansible_host": {"__ansible_unsafe": "h2.example.internal"}, "ansible_port": 22, "ansible_user": {"__ansible_unsafe": "cloudfall"}, "cloudfall_applications": [{"approval": {"__ansible_unsafe": "manual"}, "id": {"__ansible_unsafe": "crm"}, "user": {"__ansible_unsafe": "crm"}}], "cloudfall_components": [{"application": {"__ansible_unsafe": "crm"}, "application_user": {"__ansible_unsafe": "crm"}, "healthCheck": {"attempts": 5, "expectedStatuses": [200], "path": {"__ansible_unsafe": "/health"}, "port": 8100, "scheme": {"__ansible_unsafe": "http"}, "timeoutSeconds": 5, "type": {"__ansible_unsafe": "http"}}, "id": {"__ansible_unsafe": "crm-backend"}, "install_root": {"__ansible_unsafe": "/srv/apps/crm/backend"}, "repository": {"url": {"__ansible_unsafe": "https://github.com/example/crm-backend.git"}}, "retainUntilCleanup": true, "runtime": {"packageManager": {"__ansible_unsafe": "uv"}, "type": {"__ansible_unsafe": "python"}, "version": {"__ansible_unsafe": "3.14"}}, "service": {"command": [{"__ansible_unsafe": ".venv/bin/uvicorn"}, {"__ansible_unsafe": "crm.asgi:application"}, {"__ansible_unsafe": "--host"}, {"__ansible_unsafe": "127.0.0.1"}, {"__ansible_unsafe": "--port"}, {"__ansible_unsafe": "8100"}], "manager": {"__ansible_unsafe": "systemd"}, "name": {"__ansible_unsafe": "crm-backend"}}}], "cloudfall_domains": [{"aliases": [{"__ansible_unsafe": "www.crm.example.test"}], "edge": {"mode": {"__ansible_unsafe": "dns-only"}, "provider": {"__ansible_unsafe": "cloudflare"}}, "healthCheck": {"expectedStatuses": [200], "path": {"__ansible_unsafe": "/health"}, "scheme": {"__ansible_unsafe": "https"}, "timeoutSeconds": 5}, "id": {"__ansible_unsafe": "crm-site"}, "origin": {"configurationPath": {"__ansible_unsafe": "/etc/nginx/sites-available/crm-origin.conf"}, "port": 8100, "scheme": {"__ansible_unsafe": "http"}, "server": {"__ansible_unsafe": "h1"}, "serverName": {"__ansible_unsafe": "crm.example.test"}, "service": {"__ansible_unsafe": "nginx.service"}}, "primaryName": {"__ansible_unsafe": "crm.example.test"}, "proxy": {"configurationPath": {"__ansible_unsafe": "/etc/nginx/sites-available/crm.example.test.conf"}, "server": {"__ansible_unsafe": "h2"}, "service": {"__ansible_unsafe": "nginx.service"}, "upstream": {"address": {"__ansible_unsafe": "127.0.0.1"}, "port": 8100}}, "tls": {"mode": {"__ansible_unsafe": "required"}}}], "cloudfall_environment": {"__ansible_unsafe": "production"}, "cloudfall_firewall": {"allowedInbound": [{"description": {"__ansible_unsafe": "ssh"}, "port": 22, "protocol": {"__ansible_unsafe": "tcp"}}, {"description": {"__ansible_unsafe": "http"}, "port": 80, "protocol": {"__ansible_unsafe": "tcp"}}, {"description": {"__ansible_unsafe": "https"}, "port": 443, "protocol": {"__ansible_unsafe": "tcp"}}], "policy": {"__ansible_unsafe": "default-deny"}}, "cloudfall_hostname": {"__ansible_unsafe": "h2.example.internal"}, "cloudfall_labels": {"region": {"__ansible_unsafe": "eu-central"}, "workload": {"__ansible_unsafe": "application"}}, "cloudfall_lifecycle": {"__ansible_unsafe": "active"}, "cloudfall_logging_collector": {"alerting": {"alertmanager": {"listenAddress": {"__ansible_unsafe": "127.0.0.1"}, "port": 9093}, "receivers": [{"id": {"__ansible_unsafe": "operations-webhook"}, "webhook": {"url": {"__ansible_unsafe": "https://hooks.example.internal/cloudfall"}}}, {"email": {"authPasswordFile": {"__ansible_unsafe": "/etc/cloudfall/logging/smtp-password"}, "authUsername": {"__ansible_unsafe": "alerts@example.internal"}, "from": {"__ansible_unsafe": "alerts@example.internal"}, "smarthost": {"__ansible_unsafe": "smtp.example.internal:587"}, "to": {"__ansible_unsafe": "oncall@example.internal"}}, "id": {"__ansible_unsafe": "operations-email"}}]}, "backend": {"listenAddress": {"__ansible_unsafe": "127.0.0.1"}, "port": 3100, "retentionHours": 720, "server": {"__ansible_unsafe": "h1"}, "storage": {"path": {"__ansible_unsafe": "/var/lib/loki-cloudfall"}, "type": {"__ansible_unsafe": "filesystem"}}}, "collectors": {"clientTls": {"caPath": {"__ansible_unsafe": "/etc/cloudfall/logging/ca.crt"}, "certificatePath": {"__ansible_unsafe": "/etc/cloudfall/logging/client.crt"}, "keyPath": {"__ansible_unsafe": "/etc/cloudfall/logging/client.key"}}, "files": [{"application": {"__ansible_unsafe": "crm"}, "component": {"__ansible_unsafe": "crm-backend"}, "id": {"__ansible_unsafe": "crm-backend"}, "path": {"__ansible_unsafe": "/var/log/crm/backend/*.log"}, "servers": [{"__ansible_unsafe": "h1"}, {"__ansible_unsafe": "h2"}]}], "journal": {"enabled": true, "maxAgeHours": 24}, "servers": [{"__ansible_unsafe": "h1"}, {"__ansible_unsafe": "h2"}]}, "environment": {"__ansible_unsafe": "production"}, "gateway": {"listenAddress": {"__ansible_unsafe": "0.0.0.0"}, "port": 3101, "serverName": {"__ansible_unsafe": "logs.example.internal"}, "tls": {"certificatePath": {"__ansible_unsafe": "/etc/cloudfall/logging/server.crt"}, "clientCaPath": {"__ansible_unsafe": "/etc/cloudfall/logging/client-ca.crt"}, "keyPath": {"__ansible_unsafe": "/etc/cloudfall/logging/server.key"}}}, "grafana": {"adminPasswordPath": {"__ansible_unsafe": "/etc/cloudfall/logging/grafana-admin-password"}, "listenAddress": {"__ansible_unsafe": "127.0.0.1"}, "port": 3000}, "id": {"__ansible_unsafe": "operations"}, "metrics": {"backend": {"listenAddress": {"__ansible_unsafe": "127.0.0.1"}, "port": 9090, "retentionHours": 360, "storagePath": {"__ansible_unsafe": "/var/lib/prometheus-cloudfall"}}, "collection": {"intervalSeconds": 60}}, "migration": {"mode": {"__ansible_unsafe": "parallel"}, "preserveLegacyAgents": true}, "software": {"alloyPackageVersion": {"__ansible_unsafe": "1.17.1-1"}, "grafanaPackageVersion": {"__ansible_unsafe": "13.1.0"}, "lokiPackageVersion": {"__ansible_unsafe": "3.7.2"}}}, "cloudfall_origin_domains": [], "cloudfall_server_id": {"__ansible_unsafe": "h2"}, "cloudfall_server_type": {"configuration": {"files": [{"capture": {"__ansible_unsafe": "hash"}, "group": {"__ansible_unsafe": "root"}, "mode": {"__ansible_unsafe": "0644"}, "owner": {"__ansible_unsafe": "root"}, "path": {"__ansible_unsafe": "/etc/ssh/sshd_config"}}]}, "firewall": {"allowedInbound": [{"description": {"__ansible_unsafe": "http"}, "port": 80, "protocol": {"__ansible_unsafe": "tcp"}}, {"description": {"__ansible_unsafe": "https"}, "port": 443, "protocol": {"__ansible_unsafe": "tcp"}}], "policy": {"__ansible_unsafe": "default-deny"}}, "id": {"__ansible_unsafe": "debian-application"}, "os": {"distribution": {"__ansible_unsafe": "Debian"}, "serviceManager": {"__ansible_unsafe": "systemd"}, "versions": [{"__ansible_unsafe": "12"}, {"__ansible_unsafe": "13"}]}, "packages": {"forbidden": [{"__ansible_unsafe": "telnetd"}], "required": [{"name": {"__ansible_unsafe": "acl"}}, {"name": {"__ansible_unsafe": "ca-certificates"}}, {"name": {"__ansible_unsafe": "curl"}}, {"name": {"__ansible_unsafe": "git"}}, {"name": {"__ansible_unsafe": "rsync"}}, {"name": {"__ansible_unsafe": "unzip"}}]}, "services": {"required": [{"name": {"__ansible_unsafe": "ssh.service"}, "state": {"__ansible_unsafe": "running"}, "status": {"__ansible_unsafe": "enabled"}}, {"name": {"__ansible_unsafe": "apt-daily.timer"}, "state": {"__ansible_unsafe": "running"}, "status": {"__ansible_unsafe": "enabled"}}]}, "storage": {"mounts": [{"filesystem": {"__ansible_unsafe": "ext4"}, "minimumBytes": 850000000000, "path": {"__ansible_unsafe": "/"}}], "softwareRaid": {"level": {"__ansible_unsafe": "raid1"}, "minimumActiveDevices": 2, "minimumUsableBytes": 900000000000}}}, "cloudfall_services": [], "cloudfall_ssh_public_keys": [{"algorithm": {"__ansible_unsafe": "ssh-rsa"}, "environment": {"__ansible_unsafe": "production"}, "id": {"__ansible_unsafe": "example-admin"}, "lifecycle": {"__ansible_unsafe": "active"}, "owner": {"__ansible_unsafe": "example-admin"}, "publicKey": {"__ansible_unsafe": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCqmOqPPapyG6hL5FlR/KOK2ENEvi/2qlhoRtrgK7JfafHXb1i4WJwf5FMGrPE4DBRfDQDwokABdvUPITZOymFMgiGMmFKGnp0xq+/B0wTTOtdnr8GC3sCmSRwuFDrroVWwfjIqyC/wlpoPXpSvyc1VKBuXOe/gLv1tTzDH3Jc+3fUYaUCj+uIVO65n0N2ajNg9ibBsCjiUp9CrWEoprqxR4lz3TaiPuRtMxWQlQ2NKPSTdUZWqg9KVXm3TS8xgf4Ox2dFwedVMcucC8UBBAwetgylXNZMv1yAMYD3QDokLi0AC+XnNR+UhtFXr9Y3VveQfH4YKnv0Fhr2opCyN99Lr example"}}]}}}, "ungrouped": {"hosts": {}}}}} diff --git a/engine/tests/test_ansible_inventory.py b/engine/tests/test_ansible_inventory.py index f250cbd..374413f 100644 --- a/engine/tests/test_ansible_inventory.py +++ b/engine/tests/test_ansible_inventory.py @@ -359,10 +359,10 @@ def test_engine_cli_emits_ansible_json( assert document["ok"] is True assert document["data"]["output"] is None inventory = document["data"]["inventory"] - assert ( - inventory["all"]["children"]["cloudfall_servers"]["hosts"]["h1"]["ansible_user"] - == "cloudfall" - ) + h1 = inventory["all"]["children"]["cloudfall_servers"]["hosts"]["h1"] + # Declared strings are marked unsafe so Ansible never templates them (#174). + assert h1["ansible_user"] == {"__ansible_unsafe": "cloudfall"} + assert h1["ansible_port"] == 22 assert ( "component_crm_backend" in inventory["all"]["children"]["cloudfall_components"]["children"] diff --git a/engine/tests/test_examples_render_unchanged.py b/engine/tests/test_examples_render_unchanged.py index 0c63a55..bd869c0 100644 --- a/engine/tests/test_examples_render_unchanged.py +++ b/engine/tests/test_examples_render_unchanged.py @@ -11,6 +11,7 @@ from pathlib import Path from typing import TYPE_CHECKING, cast +from cloudfall.ansible_data import unmarked from cloudfall_engine.cli import main from jinja2 import Environment, FileSystemLoader, StrictUndefined @@ -58,7 +59,11 @@ def render_examples(output: Path) -> dict[str, str]: assert exit_code == 0 inventory_text = inventory_path.read_text(encoding="utf-8") rendered = {"ansible-inventory.json": inventory_text} - document = cast("dict[str, dict[str, object]]", json.loads(inventory_text)) + # Read the values back as Ansible's loader does: an unsafe string is text. + document = cast( + "dict[str, dict[str, object]]", + json.loads(inventory_text, object_hook=unmarked), + ) servers = cast( "dict[str, dict[str, dict[str, dict[str, object]]]]", document["all"]["children"], diff --git a/engine/tests/test_services_passwords.py b/engine/tests/test_services_passwords.py index 016ce7d..e29c27b 100644 --- a/engine/tests/test_services_passwords.py +++ b/engine/tests/test_services_passwords.py @@ -11,10 +11,11 @@ import sys import tempfile from pathlib import Path -from typing import Any +from typing import Any, cast import pytest import yaml +from cloudfall.ansible_data import as_ansible_data from cloudfall.inventory import PlatformInventory from cloudfall.validation import validate_config from cloudfall_engine.ansible_inventory import render_ansible_inventory @@ -67,8 +68,14 @@ def _inventory(tmp_path: Path) -> Path: (project / "services" / "postgresql-billing.yaml").write_text( yaml.safe_dump(service), encoding="utf-8" ) - rendered: dict[str, Any] = render_ansible_inventory( - PlatformInventory.from_state(validate_config(project, SCHEMAS)) + # Marked as `cloudfall-engine inventory render` writes it (#174). + rendered = cast( + "dict[str, Any]", + as_ansible_data( + render_ansible_inventory( + PlatformInventory.from_state(validate_config(project, SCHEMAS)) + ) + ), ) host = rendered["all"]["children"]["cloudfall_servers"]["hosts"]["h1"] host.update( diff --git a/engine/tests/test_unsafe_config_values.py b/engine/tests/test_unsafe_config_values.py new file mode 100644 index 0000000..aeb17ac --- /dev/null +++ b/engine/tests/test_unsafe_config_values.py @@ -0,0 +1,348 @@ +"""A declared config string reaches Ansible as data, never as a template. + +Each test declares a value holding a Jinja expression that would touch a +marker file on the management host, renders the inventory the way a run +does (``cloudfall-engine inventory render``), and runs the play that reads +the value under ``ansible-playbook`` against local hosts. The marker must +never appear, and the text must reach its target exactly as declared. +""" + +from __future__ import annotations + +import json +import shutil +import sys +from pathlib import Path +from typing import cast + +import yaml +from cloudfall.inventory import PlatformInventory +from cloudfall.observe import write_overlay +from cloudfall.validation import validate_config +from cloudfall_engine.cli import main + +ROOT = Path(__file__).parents[2] +SCHEMAS = ROOT / "config" / "schemas" / "v1" +EXAMPLES = ROOT / "config" / "examples" +ENGINE = ROOT / "engine" +PLAYBOOKS = ENGINE / "ansible" / "playbooks" +UNIT_TEMPLATE = ( + ENGINE / "ansible" / "roles" / "cloudfall_deploy" / "templates" +) / "component.service.j2" +HONEST = "docker inspect --format {{.State.Health.Status}} app" +FAKE_SYSTEMD_RUN = """#!/bin/sh +printf '%s\\n' "$@" > "$0.argv" +""" + + +def _payload(marker: Path) -> str: + return "{{ lookup('pipe', 'touch " + str(marker) + "') }}" + + +def _project(tmp_path: Path, spec: dict[str, object]) -> Path: + """Copy the example project with ``spec`` merged into ``crm-backend``'s.""" + project = tmp_path / "project" + shutil.copytree(EXAMPLES, project) + path = project / "components" / "crm-backend.yaml" + document = yaml.safe_load(path.read_text(encoding="utf-8")) + document["spec"].update(spec) + path.write_text(yaml.safe_dump(document), encoding="utf-8") + return project + + +def _local(hosts: dict[str, dict[str, object]]) -> None: + """Point every host at this machine, as the engine's own play tests do.""" + for host in hosts.values(): + host.update( + { + "ansible_connection": "local", + "ansible_python_interpreter": sys.executable, + "ansible_become": False, + } + ) + + +def _rendered_inventory(tmp_path: Path, spec: dict[str, object]) -> Path: + """Render the inventory as a run does, then point its hosts at this machine.""" + inventory = tmp_path / "inventory.json" + exit_code = main( + [ + "inventory", + "render", + "--project", + str(_project(tmp_path, spec)), + "--schemas", + str(SCHEMAS), + "--output", + str(inventory), + ] + ) + assert exit_code == 0 + document = json.loads(inventory.read_text(encoding="utf-8")) + hosts = document["all"]["children"]["cloudfall_servers"]["hosts"] + _local(hosts) + inventory.write_text(json.dumps(document), encoding="utf-8") + return inventory + + +def _play(tmp_path: Path, tasks: list[dict[str, object]]) -> Path: + """One play that hands the selected component over as the playbooks do.""" + playbook = tmp_path / "probe.yml" + playbook.write_text( + yaml.safe_dump( + [ + { + "name": "Read the selected component as the playbooks do", + "hosts": "h1", + "gather_facts": False, + "tasks": [ + { + "name": "Hand the component over", + "ansible.builtin.include_tasks": str( + _component_tasks(tmp_path, tasks) + ), + "vars": { + "cloudfall_deploy_component": ( + "{{ cloudfall_component_item }}" + ) + }, + "loop": ( + "{{ cloudfall_components | default([])" + " | selectattr('id', 'equalto', 'crm-backend')" + " | list }}" + ), + "loop_control": {"loop_var": "cloudfall_component_item"}, + } + ], + } + ] + ), + encoding="utf-8", + ) + return playbook + + +def _component_tasks(tmp_path: Path, tasks: list[dict[str, object]]) -> Path: + path = tmp_path / "component-tasks.yml" + path.write_text(yaml.safe_dump(tasks), encoding="utf-8") + return path + + +def _run(playbook: Path, inventory: Path, extra_vars: dict[str, object]) -> int: + return main( + [ + "playbook", + "run", + str(playbook), + "--engine", + str(ENGINE), + "--inventory", + str(inventory), + "--extra-vars", + json.dumps(extra_vars), + ] + ) + + +def test_a_health_check_command_is_not_templated_by_the_health_play( + tmp_path: Path, +) -> None: + """``health.yml`` validates the role's arguments without running the value.""" + marker = tmp_path / "pwned" + inventory = _rendered_inventory( + tmp_path, + { + "healthCheck": { + "type": "command", + "command": ["/bin/true", _payload(marker)], + "timeoutSeconds": 5, + "attempts": 1, + } + }, + ) + + exit_code = main( + [ + "playbook", + "run", + "health", + "--engine", + str(ENGINE), + "--inventory", + str(inventory), + "--extra-vars", + json.dumps({"cloudfall_deploy_component_id": "crm-backend"}), + ] + ) + + # The role refuses a command health check until #164 runs it. + assert exit_code != 0 + assert not marker.exists() + + +def test_a_health_check_command_reaches_the_check_as_declared( + tmp_path: Path, +) -> None: + marker = tmp_path / "pwned" + declared = ["/bin/sh", "-c", HONEST, _payload(marker)] + inventory = _rendered_inventory( + tmp_path, + { + "healthCheck": { + "type": "command", + "command": declared, + "timeoutSeconds": 5, + "attempts": 1, + } + }, + ) + seen = tmp_path / "health-command.json" + playbook = _play( + tmp_path, + [ + { + "name": "Keep the health check command", + "ansible.builtin.copy": { + "content": ( + "{{ cloudfall_deploy_component.healthCheck.command | to_json }}" + ), + "dest": str(seen), + "mode": "0644", + }, + } + ], + ) + + assert _run(playbook, inventory, {}) == 0 + assert not marker.exists() + assert json.loads(seen.read_text(encoding="utf-8")) == declared + + +def test_a_service_command_reaches_the_unit_file_as_declared( + tmp_path: Path, +) -> None: + marker = tmp_path / "pwned" + inventory = _rendered_inventory( + tmp_path, + { + "service": { + "manager": "systemd", + "name": "crm-backend", + "command": [".venv/bin/app", _payload(marker), "{{.Status}}"], + } + }, + ) + unit = tmp_path / "crm-backend.service" + playbook = _play( + tmp_path, + [ + { + "name": "Render the role's unit template", + "ansible.builtin.template": { + "src": str(UNIT_TEMPLATE), + "dest": str(unit), + "mode": "0644", + }, + } + ], + ) + + assert _run(playbook, inventory, {}) == 0 + assert not marker.exists() + (exec_start,) = [ + line + for line in unit.read_text(encoding="utf-8").splitlines() + if line.startswith("ExecStart=") + ] + assert exec_start == ( + "ExecStart=/srv/apps/crm/backend/current/.venv/bin/app" + f' "{_payload(marker)}" "{{{{.Status}}}}"' + ) + + +def test_a_release_command_reaches_its_run_as_declared(tmp_path: Path) -> None: + """The role's release command tasks run the argv as written.""" + marker = tmp_path / "pwned" + inventory = _rendered_inventory( + tmp_path, + {"release": {"command": ["/bin/echo", _payload(marker)]}}, + ) + systemd_run = tmp_path / "systemd-run" + systemd_run.write_text(FAKE_SYSTEMD_RUN, encoding="utf-8") + systemd_run.chmod(0o755) + playbook = _play( + tmp_path, + [ + { + "name": "Run the role's release command tasks", + "ansible.builtin.include_role": { + "name": "cloudfall_deploy", + "tasks_from": "release_command", + }, + "vars": { + "cloudfall_deploy_release": "20261027T000000Z-abcdef1", + "cloudfall_deploy_release_directory": str(tmp_path / "release"), + "cloudfall_deploy_shared_directory": str(tmp_path / "shared"), + "cloudfall_deploy_systemd_run": str(systemd_run), + }, + } + ], + ) + + assert _run(playbook, inventory, {}) == 0 + assert not marker.exists() + argv = (tmp_path / "systemd-run.argv").read_text(encoding="utf-8").splitlines() + assert argv[-2:] == ["/bin/echo", _payload(marker)] + + +def test_a_package_version_in_the_observe_overlay_is_not_templated( + tmp_path: Path, +) -> None: + """The overlay ``cloudfall observe`` writes hands its strings over as data.""" + marker = tmp_path / "pwned" + project = tmp_path / "project" + shutil.copytree(EXAMPLES, project) + path = project / "server-types" / "debian-application.yaml" + document = yaml.safe_load(path.read_text(encoding="utf-8")) + document["spec"]["packages"]["required"][0]["version"] = _payload(marker) + path.write_text(yaml.safe_dump(document), encoding="utf-8") + inventory = PlatformInventory.from_state(validate_config(project, SCHEMAS)) + overlay = json.loads( + write_overlay(inventory, tmp_path / "overlay").read_text(encoding="utf-8") + ) + hosts = cast( + "dict[str, dict[str, object]]", + overlay["all"]["children"]["cloudfall_servers"]["hosts"], + ) + _local(hosts) + combined = tmp_path / "inventory.json" + combined.write_text(json.dumps(overlay), encoding="utf-8") + seen = tmp_path / "server-type.json" + playbook = tmp_path / "probe.yml" + playbook.write_text( + yaml.safe_dump( + [ + { + "name": "Read the observed server type", + "hosts": "h1", + "gather_facts": False, + "tasks": [ + { + "name": "Keep the server type", + "ansible.builtin.copy": { + "content": "{{ cloudfall_server_type | to_json }}", + "dest": str(seen), + "mode": "0644", + }, + } + ], + } + ] + ), + encoding="utf-8", + ) + + assert _run(playbook, combined, {}) == 0 + assert not marker.exists() + server_type = json.loads(seen.read_text(encoding="utf-8")) + assert server_type["packages"]["required"][0]["version"] == _payload(marker) diff --git a/sdk/src/cloudfall/ansible_data.py b/sdk/src/cloudfall/ansible_data.py new file mode 100644 index 0000000..443ebe8 --- /dev/null +++ b/sdk/src/cloudfall/ansible_data.py @@ -0,0 +1,45 @@ +"""Hand declared config to Ansible as data, never as a template. + +Ansible templates every string it reads from an inventory source or extra +vars, so a declared value holding ``{{ ... }}`` would run as a Jinja +expression on the management host. Every string Cloudfall writes for +Ansible is marked unsafe in the JSON form Ansible's loader reads +(``{"__ansible_unsafe": ...}``), so it reaches its target as written: a +literal ``{{`` in a unit file or a check's argv. +""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence + +UNSAFE_KEY = "__ansible_unsafe" + + +def as_ansible_data(value: object) -> object: + """Return ``value`` with every string marked unsafe, keys left as they are. + + Mappings and sequences are copied with their strings marked; numbers, + booleans and null pass through. Keys are not templated by Ansible, and + JSON keys cannot carry the marker. + """ + if isinstance(value, str): + return {UNSAFE_KEY: value} + if isinstance(value, Mapping): + return {key: as_ansible_data(item) for key, item in value.items()} + if isinstance(value, Sequence): + return [as_ansible_data(item) for item in value] + if value is None or isinstance(value, bool | int | float): + return value + message = f"cannot hand {type(value).__name__} to Ansible as data" + raise TypeError(message) + + +def unmarked(value: dict[str, object]) -> object: + """Read a marked string back as text; a ``json.loads`` ``object_hook``. + + Cloudfall code that reads a document written for Ansible sees the + values as Ansible's loader does. + """ + if list(value) == [UNSAFE_KEY]: + return value[UNSAFE_KEY] + return value diff --git a/sdk/src/cloudfall/observe.py b/sdk/src/cloudfall/observe.py index 353976d..b8cfe63 100644 --- a/sdk/src/cloudfall/observe.py +++ b/sdk/src/cloudfall/observe.py @@ -23,6 +23,7 @@ from typing import TYPE_CHECKING from cloudfall.ansible_api import InventorySource, read_inventory +from cloudfall.ansible_data import as_ansible_data if TYPE_CHECKING: from collections.abc import Callable, Mapping, Sequence @@ -173,7 +174,10 @@ def write_overlay(inventory: PlatformInventory, directory: Path) -> Path: """Write the overlay source and return its path.""" directory.mkdir(parents=True, exist_ok=True) path = directory / OVERLAY_FILE - document = json.dumps(overlay_document(inventory), indent=2, sort_keys=True) + # Declared strings are marked unsafe, so Ansible never templates them. + document = json.dumps( + as_ansible_data(overlay_document(inventory)), indent=2, sort_keys=True + ) path.write_text(f"{document}\n", encoding="utf-8") return path diff --git a/sdk/tests/test_observe.py b/sdk/tests/test_observe.py index 7a01e2e..5e05f0b 100644 --- a/sdk/tests/test_observe.py +++ b/sdk/tests/test_observe.py @@ -91,8 +91,9 @@ def test_ansible_merges_the_overlay_over_the_team_inventory(tmp_path: Path) -> N host_vars = parsed["_meta"]["hostvars"]["web-1"] assert host_vars["ansible_host"] == "10.0.0.1" assert host_vars["ansible_user"] == "ansible" - assert host_vars["cloudfall_server_id"] == "web-1" - assert host_vars["cloudfall_server_type"]["id"] == "web" + # The overlay's strings stay unsafe in Ansible, never templated (#174). + assert host_vars["cloudfall_server_id"] == {"__ansible_unsafe": "web-1"} + assert host_vars["cloudfall_server_type"]["id"] == {"__ansible_unsafe": "web"} assert "cloudfall" in host_vars