cloudfall 0.3.0__tar.gz → 0.4.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {cloudfall-0.3.0 → cloudfall-0.4.0}/CHANGELOG.md +26 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/PKG-INFO +1 -1
- {cloudfall-0.3.0 → cloudfall-0.4.0}/pyproject.toml +7 -1
- cloudfall-0.4.0/sdk/src/cloudfall/ansible_api.py +356 -0
- cloudfall-0.4.0/sdk/src/cloudfall/ansible_reader.py +281 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/sdk/src/cloudfall/cli.py +165 -10
- {cloudfall-0.3.0 → cloudfall-0.4.0}/sdk/src/cloudfall/commands.py +6 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/sdk/src/cloudfall/domain.py +8 -0
- cloudfall-0.4.0/sdk/src/cloudfall/observe.py +282 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/sdk/src/cloudfall/validation.py +51 -15
- cloudfall-0.4.0/sdk/tests/test_ansible_reader.py +366 -0
- cloudfall-0.4.0/sdk/tests/test_observe.py +266 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/uv.lock +1 -1
- {cloudfall-0.3.0 → cloudfall-0.4.0}/.ansible-lint +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/.github/workflows/ci.yml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/.github/workflows/release.yml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/.gitignore +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/ARCHITECTURE.md +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/CONTRIBUTING.md +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/LICENSE +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/MANIFESTO.md +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/README.md +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/ROADMAP.md +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/SECURITY.md +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/Taskfile.yml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/config/README.md +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/config/examples/alert-rules/postgresql-down.yaml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/config/examples/applications/crm.yaml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/config/examples/components/crm-backend.yaml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/config/examples/domains/crm-site.yaml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/config/examples/logging-stacks/operations.yaml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/config/examples/operator-policies/production.yaml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/config/examples/server-types/debian-application.yaml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/config/examples/servers/h1.yaml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/config/examples/servers/h2.yaml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/config/examples/services/postgresql-main.yaml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/config/examples/services/redis-cache.yaml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/config/examples/ssh-public-keys/example-admin.yaml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/config/schemas/v1/alert-rule.schema.json +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/config/schemas/v1/application.schema.json +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/config/schemas/v1/artifact.schema.json +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/config/schemas/v1/backup-receipt.schema.json +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/config/schemas/v1/common.schema.json +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/config/schemas/v1/component.schema.json +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/config/schemas/v1/deployment-receipt.schema.json +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/config/schemas/v1/domain.schema.json +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/config/schemas/v1/environment-receipt.schema.json +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/config/schemas/v1/logging-stack.schema.json +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/config/schemas/v1/observed-domain.schema.json +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/config/schemas/v1/observed-server.schema.json +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/config/schemas/v1/operator-policy.schema.json +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/config/schemas/v1/operator-proposal.schema.json +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/config/schemas/v1/release-receipt.schema.json +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/config/schemas/v1/server-type.schema.json +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/config/schemas/v1/server.schema.json +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/config/schemas/v1/service.schema.json +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/config/schemas/v1/ssh-public-key.schema.json +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/config/tests/invalid/missing-server/applications/crm.yaml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/config/tests/invalid/missing-server/components/crm-backend.yaml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/config/tests/invalid/missing-server/server-types/debian-application.yaml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/config/tests/invalid/missing-server/servers/h1.yaml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/config/tests/invalid/schema/components/component-unknown-field.yaml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/config/tests/observed/compliant/h1.json +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/config/tests/observed/compliant/h2.json +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/docs/ansible-context.md +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/docs/availability-design.md +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/docs/brownfield-design.md +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/docs/decisions/2026-09-14-no-ansible-replacement.md +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/docs/decisions/2026-09-14-no-nixos-base.md +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/docs/decisions/2026-09-14-no-rust-rewrite.md +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/docs/fleet-goals.md +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/docs/hybrid-storage-design.md +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/docs/landscape.md +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/docs/logging-service-guide.md +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/docs/neon-migration-guide.md +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/docs/new-server-storage-guide.md +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/docs/operator-guide.md +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/docs/proving-runs/2026-09-08-debian-container-m1.md +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/docs/proving-runs/2026-09-08-dns-cutover.md +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/docs/proving-runs/2026-09-08-guided-data-migration.md +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/docs/proving-runs/2026-09-08-hetzner-m1.md +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/docs/proving-runs/2026-09-08-hetzner-m2-m3.md +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/docs/proving-runs/2026-09-08-hetzner-m4-m5.md +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/docs/proving-runs/2026-09-08-render-cutover.md +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/docs/proving-runs/2026-09-10-hetzner-m7.md +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/docs/proving-runs/2026-09-10-hetzner-m8.md +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/docs/proving-runs/2026-09-10-hetzner-m9.md +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/docs/proving-runs/2026-09-11-hetzner-m10.md +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/docs/render-migration-guide.md +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/docs/secrets-guide.md +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/README.md +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/ansible.cfg +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/playbooks/backup.yml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/playbooks/baseline.yml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/playbooks/bootstrap.yml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/playbooks/data.yml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/playbooks/deploy.yml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/playbooks/domains.yml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/playbooks/health.yml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/playbooks/inspect.yml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/playbooks/logging.yml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/playbooks/restart.yml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/playbooks/rollback.yml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/playbooks/services.yml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/playbooks/time.yml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_access/defaults/main.yml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_access/handlers/main.yml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_access/meta/argument_specs.yml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_access/meta/main.yml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_access/tasks/main.yml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_bootstrap/defaults/main.yml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_bootstrap/meta/argument_specs.yml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_bootstrap/meta/main.yml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_bootstrap/tasks/main.yml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_data_migration/defaults/main.yml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_data_migration/meta/argument_specs.yml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_data_migration/meta/main.yml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_data_migration/tasks/main.yml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_deploy/defaults/main.yml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_deploy/meta/argument_specs.yml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_deploy/meta/main.yml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_deploy/tasks/health.yml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_deploy/tasks/health_gate.yml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_deploy/tasks/main.yml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_deploy/tasks/restart.yml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_deploy/tasks/rollback.yml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_deploy/templates/component.service.j2 +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_firewall/defaults/main.yml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_firewall/handlers/main.yml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_firewall/meta/argument_specs.yml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_firewall/meta/main.yml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_firewall/tasks/main.yml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_firewall/templates/nftables.conf.j2 +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_inspect/defaults/main.yml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_inspect/meta/argument_specs.yml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_inspect/meta/main.yml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_inspect/tasks/main.yml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_logging_backend/defaults/main.yml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_logging_backend/handlers/main.yml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_logging_backend/meta/argument_specs.yml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_logging_backend/meta/main.yml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_logging_backend/tasks/main.yml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_logging_backend/templates/alertmanager-defaults.j2 +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_logging_backend/templates/alertmanager.yaml.j2 +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_logging_backend/templates/grafana.ini.j2 +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_logging_backend/templates/loki-datasource.yml.j2 +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_logging_backend/templates/loki-systemd-override.conf.j2 +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_logging_backend/templates/loki.yaml.j2 +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_logging_backend/templates/nginx-loki-gateway.conf.j2 +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_logging_backend/templates/prometheus-datasource.yml.j2 +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_logging_backend/templates/prometheus-defaults.j2 +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_logging_backend/templates/prometheus-rules.yaml.j2 +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_logging_backend/templates/prometheus.yaml.j2 +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_logging_collector/defaults/main.yml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_logging_collector/handlers/main.yml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_logging_collector/meta/argument_specs.yml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_logging_collector/meta/main.yml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_logging_collector/tasks/main.yml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_logging_collector/templates/alloy-systemd-override.conf.j2 +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_logging_collector/templates/config.alloy.j2 +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_nginx_site/defaults/main.yml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_nginx_site/handlers/main.yml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_nginx_site/meta/argument_specs.yml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_nginx_site/meta/main.yml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_nginx_site/tasks/main.yml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_nginx_site/templates/domain-site.conf.j2 +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_postgresql/defaults/main.yml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_postgresql/handlers/main.yml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_postgresql/meta/argument_specs.yml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_postgresql/meta/main.yml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_postgresql/tasks/main.yml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_postgresql/templates/backup.service.j2 +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_postgresql/templates/backup.sh.j2 +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_postgresql/templates/backup.timer.j2 +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_postgresql/templates/cloudfall.conf.j2 +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_postgresql/templates/restore-check.service.j2 +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_postgresql/templates/restore-check.sh.j2 +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_postgresql/templates/restore-check.timer.j2 +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_redis/defaults/main.yml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_redis/handlers/main.yml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_redis/meta/argument_specs.yml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_redis/meta/main.yml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_redis/tasks/main.yml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_redis/templates/backup.service.j2 +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_redis/templates/backup.sh.j2 +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_redis/templates/backup.timer.j2 +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_redis/templates/cloudfall.conf.j2 +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_redis/templates/restore-check.service.j2 +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_redis/templates/restore-check.sh.j2 +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_redis/templates/restore-check.timer.j2 +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_time/defaults/main.yml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_time/meta/argument_specs.yml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_time/meta/main.yml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_time/tasks/main.yml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_unattended_upgrades/defaults/main.yml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_unattended_upgrades/meta/argument_specs.yml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_unattended_upgrades/meta/main.yml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_unattended_upgrades/tasks/main.yml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/src/cloudfall_engine/__init__.py +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/src/cloudfall_engine/__main__.py +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/src/cloudfall_engine/ansible_inventory.py +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/src/cloudfall_engine/artifact.py +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/src/cloudfall_engine/cli.py +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/src/cloudfall_engine/playbook.py +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/src/cloudfall_engine/py.typed +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/tests/test_ansible_inventory.py +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/tests/test_artifact_builder.py +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/tests/test_deploy_templates.py +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/tests/test_firewall_templates.py +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/tests/test_logging_templates.py +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/tests/test_nginx_site_templates.py +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/tests/test_playbook.py +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/engine/tests/test_postgresql_templates.py +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/evaluations/cloudfall/README.md +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/evaluations/cloudfall/environment.md +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/evaluations/cloudfall/findings.md +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/evaluations/cloudfall/issues.md +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/evaluations/cloudfall/linkedin.md +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/evaluations/cloudfall/readiness.md +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/evaluations/cloudfall/report-agent-dev.md +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/evaluations/cloudfall/report-dev.md +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/evaluations/cloudfall/report-index.md +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/evaluations/cloudfall/report-issues.md +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/evaluations/cloudfall/report-runtime.md +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/evaluations/cloudfall/trace.md +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/evaluations/cloudfall/x.md +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/examples/render-demo/README.md +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/examples/render-demo/pyproject.toml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/examples/render-demo/render.yaml +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/examples/render-demo/src/demo/__init__.py +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/examples/render-demo/src/demo/asgi.py +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/examples/render-demo/uv.lock +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/sdk/README.md +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/sdk/src/cloudfall/__init__.py +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/sdk/src/cloudfall/__main__.py +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/sdk/src/cloudfall/agent_tools.py +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/sdk/src/cloudfall/arguments.py +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/sdk/src/cloudfall/audit.py +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/sdk/src/cloudfall/authoring.py +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/sdk/src/cloudfall/cutover.py +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/sdk/src/cloudfall/dashboard.py +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/sdk/src/cloudfall/dashboard_server.py +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/sdk/src/cloudfall/importer.py +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/sdk/src/cloudfall/inventory.py +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/sdk/src/cloudfall/lifecycle.py +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/sdk/src/cloudfall/mcp_server.py +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/sdk/src/cloudfall/migrate.py +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/sdk/src/cloudfall/observation.py +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/sdk/src/cloudfall/operations.py +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/sdk/src/cloudfall/operator.py +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/sdk/src/cloudfall/project.py +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/sdk/src/cloudfall/py.typed +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/sdk/src/cloudfall/render_api.py +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/sdk/src/cloudfall/resources.py +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/sdk/src/cloudfall/secrets.py +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/sdk/src/cloudfall/service_evidence.py +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/sdk/tests/test_agent_tools.py +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/sdk/tests/test_arguments.py +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/sdk/tests/test_audit.py +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/sdk/tests/test_authoring.py +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/sdk/tests/test_commands.py +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/sdk/tests/test_cutover.py +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/sdk/tests/test_dashboard_server.py +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/sdk/tests/test_data_migration.py +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/sdk/tests/test_importer.py +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/sdk/tests/test_inventory.py +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/sdk/tests/test_lifecycle.py +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/sdk/tests/test_migrate.py +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/sdk/tests/test_operations_dashboard.py +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/sdk/tests/test_operator.py +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/sdk/tests/test_project.py +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/sdk/tests/test_render_api.py +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/sdk/tests/test_resources.py +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/sdk/tests/test_secrets.py +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/sdk/tests/test_service_evidence.py +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/sdk/tests/test_validation.py +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/site/public/apple-touch-icon.png +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/site/public/favicon-32.png +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/site/public/favicon.svg +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/site/public/icon-192.png +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/site/public/icon-512.png +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/site/public/index.html +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/site/public/manifesto.html +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/site/public/og.png +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/site/public/site.webmanifest +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/site/src/worker.js +0 -0
- {cloudfall-0.3.0 → cloudfall-0.4.0}/site/wrangler.jsonc +0 -0
|
@@ -6,6 +6,32 @@ Notable changes to Cloudfall. The format follows
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.4.0] — 2026-09-21
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- Brownfield fleet reader: `cloudfall audit` and `cloudfall inventory show`
|
|
14
|
+
read the fleet from the team's own Ansible inventory, with `--inventory`
|
|
15
|
+
or from the inventory an `ansible.cfg` in the current directory names.
|
|
16
|
+
Hosts, groups and connection settings come from Ansible; a `cloudfall`
|
|
17
|
+
block per host, `cloudfall_defaults` per group and the
|
|
18
|
+
`cloudfall_server_types` catalog carry what Ansible does not model. The
|
|
19
|
+
documents are validated against the same schemas a project directory
|
|
20
|
+
gets, so `PlatformInventory` and everything built on it is unchanged
|
|
21
|
+
- `cloudfall.ansible_api` is the only module that imports ansible-core. It
|
|
22
|
+
reads in process and falls back to the `ansible-inventory` command,
|
|
23
|
+
reporting which served the read
|
|
24
|
+
- `cloudfall observe` collects one read-only snapshot per server through
|
|
25
|
+
the team's inventory plus an ephemeral overlay holding only the
|
|
26
|
+
`cloudfall_servers` group and the two variables the inspect role cannot
|
|
27
|
+
derive, so the audit loop closes without a Cloudfall project. Their own
|
|
28
|
+
`ansible.cfg` keeps deciding how Ansible connects; only the roles path
|
|
29
|
+
is forced. `--limit` takes an Ansible host pattern, which Ansible
|
|
30
|
+
resolves, and the run is judged against the hosts it asked for
|
|
31
|
+
- `StateValidator.validate_documents` validates resource documents
|
|
32
|
+
assembled in memory, with the schemas, reference checks and error codes
|
|
33
|
+
documents on disk get
|
|
34
|
+
|
|
9
35
|
## [0.3.0] — 2026-09-20
|
|
10
36
|
|
|
11
37
|
### Removed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "cloudfall"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.4.0"
|
|
4
4
|
description = "AI-native control plane for dedicated Debian application fleets"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "AGPL-3.0-or-later"
|
|
@@ -88,3 +88,9 @@ ignore = [
|
|
|
88
88
|
python_version = "3.14"
|
|
89
89
|
strict = true
|
|
90
90
|
files = ["sdk/src", "sdk/tests", "engine/src", "engine/tests"]
|
|
91
|
+
|
|
92
|
+
# ansible-core ships no type information. Only cloudfall.ansible_api imports
|
|
93
|
+
# it, and it converts what it reads into typed value objects at that edge.
|
|
94
|
+
[[tool.mypy.overrides]]
|
|
95
|
+
module = ["ansible.*"]
|
|
96
|
+
ignore_missing_imports = true
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
"""The one module that imports ansible-core.
|
|
2
|
+
|
|
3
|
+
ansible-core's Python API carries no stability promise, so every import of
|
|
4
|
+
``ansible.*`` lives here and the rest of the SDK sees typed value objects
|
|
5
|
+
instead. Reading an inventory in process costs one import (about 150 ms per
|
|
6
|
+
CLI invocation) and is in memory afterwards, which matters because the
|
|
7
|
+
agent's hot path reads the fleet far more often than it runs a playbook.
|
|
8
|
+
|
|
9
|
+
When the in-process loader fails on an ansible-core release, the same
|
|
10
|
+
function falls back to the ``ansible-inventory`` command and says so in
|
|
11
|
+
``AnsibleInventory.in_process``, so a broken internal API degrades to a
|
|
12
|
+
slower read rather than to no read at all.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import configparser
|
|
18
|
+
import json
|
|
19
|
+
import re
|
|
20
|
+
import shutil
|
|
21
|
+
import subprocess
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import TYPE_CHECKING
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from collections.abc import Iterable, Mapping
|
|
28
|
+
|
|
29
|
+
_HOSTNAME_PATTERN = re.compile(r"^[^\s]{1,253}$")
|
|
30
|
+
_GROUP_NAME_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
31
|
+
_INVENTORY_TIMEOUT_SECONDS = 120
|
|
32
|
+
_META_KEY = "_meta"
|
|
33
|
+
_HOSTVARS_KEY = "hostvars"
|
|
34
|
+
_HOSTS_KEY = "hosts"
|
|
35
|
+
_ALL_GROUP = "all"
|
|
36
|
+
|
|
37
|
+
ANSIBLE_CONFIG_FILE = "ansible.cfg"
|
|
38
|
+
"""Ansible's own configuration file, which names the team's inventory."""
|
|
39
|
+
|
|
40
|
+
_CONFIG_SECTION = "defaults"
|
|
41
|
+
_CONFIG_INVENTORY_KEY = "inventory"
|
|
42
|
+
_ERROR_CONFIG_UNREADABLE = "ansible_config_unreadable"
|
|
43
|
+
_ERROR_SOURCE_MISSING = "ansible_inventory_missing"
|
|
44
|
+
_ERROR_INVENTORY_UNREADABLE = "ansible_inventory_unreadable"
|
|
45
|
+
_ERROR_COMMAND_UNAVAILABLE = "ansible_command_unavailable"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class AnsibleReadError(RuntimeError):
|
|
49
|
+
"""Fail-fast inventory read error with a stable machine-readable code."""
|
|
50
|
+
|
|
51
|
+
def __init__(self, code: str, message: str) -> None:
|
|
52
|
+
"""Record the failure code and human-readable detail."""
|
|
53
|
+
self.code = code
|
|
54
|
+
self.detail = message
|
|
55
|
+
super().__init__(f"{code}: {message}")
|
|
56
|
+
|
|
57
|
+
def as_dict(self) -> dict[str, object]:
|
|
58
|
+
"""Serialize the error envelope for system boundaries."""
|
|
59
|
+
return {
|
|
60
|
+
"status": "error",
|
|
61
|
+
"error": {"code": self.code, "message": self.detail},
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class _InProcessReadError(RuntimeError):
|
|
66
|
+
"""ansible-core's Python API could not serve the read on this release."""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True, slots=True)
|
|
70
|
+
class InventorySource:
|
|
71
|
+
"""An Ansible inventory: one file, or a directory of them."""
|
|
72
|
+
|
|
73
|
+
value: Path
|
|
74
|
+
|
|
75
|
+
def __post_init__(self) -> None:
|
|
76
|
+
"""Enforce that the source exists at construction time."""
|
|
77
|
+
if not self.value.exists():
|
|
78
|
+
message = f"inventory source does not exist: {self.value}"
|
|
79
|
+
raise AnsibleReadError(_ERROR_SOURCE_MISSING, message)
|
|
80
|
+
|
|
81
|
+
@classmethod
|
|
82
|
+
def from_boundary(cls, value: object) -> InventorySource:
|
|
83
|
+
"""Coerce a boundary value while keeping internal APIs strictly typed."""
|
|
84
|
+
if not isinstance(value, str | Path):
|
|
85
|
+
message = f"inventory source must be a path, got {type(value).__name__}"
|
|
86
|
+
raise TypeError(message)
|
|
87
|
+
return cls(Path(value).expanduser())
|
|
88
|
+
|
|
89
|
+
def __str__(self) -> str:
|
|
90
|
+
"""Return the serialized path."""
|
|
91
|
+
return str(self.value)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass(frozen=True, slots=True)
|
|
95
|
+
class InventoryHostname:
|
|
96
|
+
"""Name of a host as the team's inventory spells it."""
|
|
97
|
+
|
|
98
|
+
value: str
|
|
99
|
+
|
|
100
|
+
def __post_init__(self) -> None:
|
|
101
|
+
"""Enforce one non-empty, whitespace-free token."""
|
|
102
|
+
if not _HOSTNAME_PATTERN.fullmatch(self.value):
|
|
103
|
+
message = f"inventory hostname must be one bare token: {self.value!r}"
|
|
104
|
+
raise ValueError(message)
|
|
105
|
+
|
|
106
|
+
@classmethod
|
|
107
|
+
def from_boundary(cls, value: object) -> InventoryHostname:
|
|
108
|
+
"""Coerce a boundary value while keeping internal APIs strictly typed."""
|
|
109
|
+
if not isinstance(value, str):
|
|
110
|
+
message = f"inventory hostname must be a string, got {type(value).__name__}"
|
|
111
|
+
raise TypeError(message)
|
|
112
|
+
return cls(value.strip())
|
|
113
|
+
|
|
114
|
+
def __str__(self) -> str:
|
|
115
|
+
"""Return the serialized name."""
|
|
116
|
+
return self.value
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass(frozen=True, slots=True)
|
|
120
|
+
class GroupName:
|
|
121
|
+
"""Name of an inventory group a host belongs to."""
|
|
122
|
+
|
|
123
|
+
value: str
|
|
124
|
+
|
|
125
|
+
def __post_init__(self) -> None:
|
|
126
|
+
"""Enforce the identifier shape Ansible requires of a group."""
|
|
127
|
+
if not _GROUP_NAME_PATTERN.fullmatch(self.value):
|
|
128
|
+
message = f"group name must be an Ansible identifier: {self.value!r}"
|
|
129
|
+
raise ValueError(message)
|
|
130
|
+
|
|
131
|
+
@classmethod
|
|
132
|
+
def from_boundary(cls, value: object) -> GroupName:
|
|
133
|
+
"""Coerce a boundary value while keeping internal APIs strictly typed."""
|
|
134
|
+
if not isinstance(value, str):
|
|
135
|
+
message = f"group name must be a string, got {type(value).__name__}"
|
|
136
|
+
raise TypeError(message)
|
|
137
|
+
return cls(value.strip())
|
|
138
|
+
|
|
139
|
+
def __str__(self) -> str:
|
|
140
|
+
"""Return the serialized name."""
|
|
141
|
+
return self.value
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@dataclass(frozen=True, slots=True)
|
|
145
|
+
class AnsibleHost:
|
|
146
|
+
"""One host of the team's inventory with its merged variables."""
|
|
147
|
+
|
|
148
|
+
name: InventoryHostname
|
|
149
|
+
groups: tuple[GroupName, ...]
|
|
150
|
+
variables: Mapping[str, object]
|
|
151
|
+
|
|
152
|
+
def variable(self, name: str) -> object | None:
|
|
153
|
+
"""Return one merged variable, or ``None`` when the host has none."""
|
|
154
|
+
return self.variables.get(name)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@dataclass(frozen=True, slots=True)
|
|
158
|
+
class AnsibleInventory:
|
|
159
|
+
"""Every host of one inventory source, read once."""
|
|
160
|
+
|
|
161
|
+
source: InventorySource
|
|
162
|
+
hosts: tuple[AnsibleHost, ...]
|
|
163
|
+
in_process: bool
|
|
164
|
+
"""The Python API served the read; ``False`` means the command did."""
|
|
165
|
+
|
|
166
|
+
def as_dict(self) -> dict[str, object]:
|
|
167
|
+
"""Serialize the read for system boundaries, without variables."""
|
|
168
|
+
return {
|
|
169
|
+
"source": str(self.source),
|
|
170
|
+
"inProcess": self.in_process,
|
|
171
|
+
"hosts": [
|
|
172
|
+
{
|
|
173
|
+
"name": str(host.name),
|
|
174
|
+
"groups": [str(group) for group in host.groups],
|
|
175
|
+
}
|
|
176
|
+
for host in self.hosts
|
|
177
|
+
],
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def inventory_from_config(directory: Path) -> InventorySource | None:
|
|
182
|
+
"""Return the inventory an ``ansible.cfg`` in this directory names.
|
|
183
|
+
|
|
184
|
+
The team's own configuration is the only place Cloudfall looks, so a
|
|
185
|
+
brownfield repository needs no Cloudfall-side setting to be read from.
|
|
186
|
+
``None`` means the directory is not an Ansible control repository.
|
|
187
|
+
Ansible accepts a comma-separated list of sources here; the first one
|
|
188
|
+
is the fleet, and a second source is a merge Cloudfall does not model.
|
|
189
|
+
"""
|
|
190
|
+
configuration = directory / ANSIBLE_CONFIG_FILE
|
|
191
|
+
if not configuration.is_file():
|
|
192
|
+
return None
|
|
193
|
+
parser = configparser.ConfigParser()
|
|
194
|
+
try:
|
|
195
|
+
parser.read(configuration, encoding="utf-8")
|
|
196
|
+
except configparser.Error as error:
|
|
197
|
+
message = f"{configuration} is not readable as an ini file: {error}"
|
|
198
|
+
raise AnsibleReadError(_ERROR_CONFIG_UNREADABLE, message) from error
|
|
199
|
+
declared = parser.get(_CONFIG_SECTION, _CONFIG_INVENTORY_KEY, fallback="").strip()
|
|
200
|
+
if not declared:
|
|
201
|
+
return None
|
|
202
|
+
first = declared.split(",")[0].strip()
|
|
203
|
+
if not first:
|
|
204
|
+
return None
|
|
205
|
+
candidate = Path(first).expanduser()
|
|
206
|
+
if not candidate.is_absolute():
|
|
207
|
+
candidate = directory / candidate
|
|
208
|
+
return InventorySource(candidate)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def read_inventory(
|
|
212
|
+
source: InventorySource, *, limit: str | None = None, in_process: bool = True
|
|
213
|
+
) -> AnsibleInventory:
|
|
214
|
+
"""Return every host of the inventory with Ansible's own precedence applied.
|
|
215
|
+
|
|
216
|
+
The Python API is tried first and the ``ansible-inventory`` command is
|
|
217
|
+
the fallback, so a release that moves the internal API costs latency
|
|
218
|
+
rather than the read. Pass ``in_process=False`` to take the command
|
|
219
|
+
without trying the API, which is what an operator does when a release
|
|
220
|
+
has broken it. ``limit`` narrows the read with Ansible's own host
|
|
221
|
+
pattern, so a caller never has to interpret one itself.
|
|
222
|
+
"""
|
|
223
|
+
if not in_process:
|
|
224
|
+
return _read_via_command(source, limit)
|
|
225
|
+
try:
|
|
226
|
+
return _read_in_process(source, limit)
|
|
227
|
+
except (ImportError, _InProcessReadError):
|
|
228
|
+
return _read_via_command(source, limit)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _read_in_process(
|
|
232
|
+
source: InventorySource, limit: str | None = None
|
|
233
|
+
) -> AnsibleInventory:
|
|
234
|
+
from ansible.errors import AnsibleError # noqa: PLC0415 - lazy by design
|
|
235
|
+
from ansible.inventory.manager import ( # noqa: PLC0415 - lazy by design
|
|
236
|
+
InventoryManager,
|
|
237
|
+
)
|
|
238
|
+
from ansible.parsing.dataloader import ( # noqa: PLC0415 - lazy by design
|
|
239
|
+
DataLoader,
|
|
240
|
+
)
|
|
241
|
+
from ansible.vars.manager import VariableManager # noqa: PLC0415 - lazy by design
|
|
242
|
+
|
|
243
|
+
try:
|
|
244
|
+
loader = DataLoader()
|
|
245
|
+
manager = InventoryManager(loader=loader, sources=[str(source)])
|
|
246
|
+
if limit is not None:
|
|
247
|
+
manager.subset(limit)
|
|
248
|
+
variables = VariableManager(loader=loader, inventory=manager)
|
|
249
|
+
hosts = tuple(
|
|
250
|
+
AnsibleHost(
|
|
251
|
+
name=InventoryHostname.from_boundary(host.name),
|
|
252
|
+
groups=_group_names(str(group.name) for group in host.get_groups()),
|
|
253
|
+
variables=_variables(variables.get_vars(host=host)),
|
|
254
|
+
)
|
|
255
|
+
for host in manager.get_hosts()
|
|
256
|
+
)
|
|
257
|
+
except AnsibleError as error:
|
|
258
|
+
raise _InProcessReadError(str(error)) from error
|
|
259
|
+
return AnsibleInventory(source=source, hosts=_sorted(hosts), in_process=True)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _read_via_command(
|
|
263
|
+
source: InventorySource, limit: str | None = None
|
|
264
|
+
) -> AnsibleInventory:
|
|
265
|
+
executable = shutil.which("ansible-inventory")
|
|
266
|
+
if executable is None:
|
|
267
|
+
message = (
|
|
268
|
+
"the ansible-core Python API could not read the inventory and "
|
|
269
|
+
"ansible-inventory is not on PATH"
|
|
270
|
+
)
|
|
271
|
+
raise AnsibleReadError(_ERROR_COMMAND_UNAVAILABLE, message)
|
|
272
|
+
argv = [executable, "--inventory", str(source), "--list"]
|
|
273
|
+
if limit is not None:
|
|
274
|
+
argv.extend(("--limit", limit))
|
|
275
|
+
completed = subprocess.run( # noqa: S603 - resolved binary, fixed arguments.
|
|
276
|
+
argv,
|
|
277
|
+
check=False,
|
|
278
|
+
capture_output=True,
|
|
279
|
+
text=True,
|
|
280
|
+
timeout=_INVENTORY_TIMEOUT_SECONDS,
|
|
281
|
+
)
|
|
282
|
+
if completed.returncode != 0:
|
|
283
|
+
message = (
|
|
284
|
+
f"ansible-inventory failed with exit code {completed.returncode}: "
|
|
285
|
+
f"{completed.stderr.strip()}"
|
|
286
|
+
)
|
|
287
|
+
raise AnsibleReadError(_ERROR_INVENTORY_UNREADABLE, message)
|
|
288
|
+
return AnsibleInventory(
|
|
289
|
+
source=source,
|
|
290
|
+
hosts=_sorted(_hosts_from_listing(completed.stdout)),
|
|
291
|
+
in_process=False,
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _hosts_from_listing(listing: str) -> tuple[AnsibleHost, ...]:
|
|
296
|
+
try:
|
|
297
|
+
parsed = json.loads(listing)
|
|
298
|
+
except json.JSONDecodeError as error:
|
|
299
|
+
message = f"ansible-inventory did not emit JSON: {error}"
|
|
300
|
+
raise AnsibleReadError(_ERROR_INVENTORY_UNREADABLE, message) from error
|
|
301
|
+
if not isinstance(parsed, dict):
|
|
302
|
+
message = "ansible-inventory did not emit an inventory object"
|
|
303
|
+
raise AnsibleReadError(_ERROR_INVENTORY_UNREADABLE, message)
|
|
304
|
+
document: Mapping[str, object] = parsed
|
|
305
|
+
hostvars = _hostvars(document)
|
|
306
|
+
groups: dict[str, list[str]] = {}
|
|
307
|
+
for key, value in document.items():
|
|
308
|
+
if key == _META_KEY or not isinstance(value, dict):
|
|
309
|
+
continue
|
|
310
|
+
members = value.get(_HOSTS_KEY)
|
|
311
|
+
if not isinstance(members, list):
|
|
312
|
+
continue
|
|
313
|
+
for member in members:
|
|
314
|
+
if isinstance(member, str):
|
|
315
|
+
groups.setdefault(member, []).append(key)
|
|
316
|
+
return tuple(
|
|
317
|
+
AnsibleHost(
|
|
318
|
+
name=InventoryHostname.from_boundary(name),
|
|
319
|
+
# `all` holds children rather than hosts in the listing, while
|
|
320
|
+
# every host belongs to it; the in-process read reports it.
|
|
321
|
+
groups=_group_names({_ALL_GROUP, *groups.get(name, [])}),
|
|
322
|
+
variables=_variables(variables),
|
|
323
|
+
)
|
|
324
|
+
for name, variables in hostvars.items()
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _hostvars(document: Mapping[str, object]) -> Mapping[str, Mapping[str, object]]:
|
|
329
|
+
meta = document.get(_META_KEY)
|
|
330
|
+
if not isinstance(meta, dict):
|
|
331
|
+
message = "ansible-inventory emitted no _meta section"
|
|
332
|
+
raise AnsibleReadError(_ERROR_INVENTORY_UNREADABLE, message)
|
|
333
|
+
hostvars = meta.get(_HOSTVARS_KEY)
|
|
334
|
+
if not isinstance(hostvars, dict):
|
|
335
|
+
message = "ansible-inventory emitted no _meta.hostvars section"
|
|
336
|
+
raise AnsibleReadError(_ERROR_INVENTORY_UNREADABLE, message)
|
|
337
|
+
return {
|
|
338
|
+
str(name): _variables(variables)
|
|
339
|
+
for name, variables in hostvars.items()
|
|
340
|
+
if isinstance(variables, dict)
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _variables(raw: object) -> Mapping[str, object]:
|
|
345
|
+
if not isinstance(raw, dict):
|
|
346
|
+
return {}
|
|
347
|
+
return {str(key): value for key, value in raw.items()}
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _group_names(names: Iterable[str]) -> tuple[GroupName, ...]:
|
|
351
|
+
groups = tuple(GroupName.from_boundary(name) for name in names)
|
|
352
|
+
return tuple(sorted(groups, key=lambda group: group.value))
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _sorted(hosts: tuple[AnsibleHost, ...]) -> tuple[AnsibleHost, ...]:
|
|
356
|
+
return tuple(sorted(hosts, key=lambda host: host.name.value))
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""Read a fleet from the team's own Ansible inventory.
|
|
2
|
+
|
|
3
|
+
Cloudfall declares no servers in a brownfield repository. The inventory is
|
|
4
|
+
the config: hosts, groups and connection settings come from Ansible, and
|
|
5
|
+
everything Ansible does not model comes from a ``cloudfall`` block in the
|
|
6
|
+
team's own ``host_vars``, with shared defaults in ``cloudfall_defaults``
|
|
7
|
+
and the server-type catalog in ``cloudfall_server_types``.
|
|
8
|
+
|
|
9
|
+
Ansible replaces rather than merges a variable of the same name, so a
|
|
10
|
+
``cloudfall`` block in ``group_vars`` would silently lose to one in
|
|
11
|
+
``host_vars``. The two names keep that from happening: ``cloudfall`` is
|
|
12
|
+
per host, ``cloudfall_defaults`` is per group, and Cloudfall merges them
|
|
13
|
+
itself with the host winning.
|
|
14
|
+
|
|
15
|
+
The documents this builds are the same shape as the YAML a greenfield
|
|
16
|
+
project holds, so they go through the same schemas and the same reference
|
|
17
|
+
checks, and every consumer of ``PlatformInventory`` works unchanged.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import re
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from typing import TYPE_CHECKING
|
|
25
|
+
|
|
26
|
+
from cloudfall.ansible_api import AnsibleReadError
|
|
27
|
+
from cloudfall.domain import RawDocument, ResourceKind, SourceLocation
|
|
28
|
+
from cloudfall.validation import SchemaCatalog, StateValidator
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from collections.abc import Mapping
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
from cloudfall.ansible_api import AnsibleHost, AnsibleInventory
|
|
35
|
+
from cloudfall.validation import ValidatedConfig
|
|
36
|
+
|
|
37
|
+
API_VERSION = "cloudfall/v1"
|
|
38
|
+
"""Envelope version of the documents the reader builds."""
|
|
39
|
+
|
|
40
|
+
DECLARATION_VARIABLE = "cloudfall"
|
|
41
|
+
"""Host variable holding one host's Cloudfall declaration."""
|
|
42
|
+
|
|
43
|
+
DEFAULTS_VARIABLE = "cloudfall_defaults"
|
|
44
|
+
"""Group variable holding declaration defaults a host may override."""
|
|
45
|
+
|
|
46
|
+
CATALOG_VARIABLE = "cloudfall_server_types"
|
|
47
|
+
"""Group variable holding the server-type catalog the hosts point into."""
|
|
48
|
+
|
|
49
|
+
_ADDRESS_VARIABLE = "ansible_host"
|
|
50
|
+
_SSH_USER_VARIABLE = "ansible_user"
|
|
51
|
+
_SSH_PORT_VARIABLE = "ansible_port"
|
|
52
|
+
_DEFAULT_SSH_PORT = 22
|
|
53
|
+
_DESCRIPTION_FIELD = "description"
|
|
54
|
+
_LABELS_FIELD = "labels"
|
|
55
|
+
_HOSTNAME_FIELD = "hostname"
|
|
56
|
+
_SNAKE_BOUNDARY = re.compile(r"_([a-z0-9])")
|
|
57
|
+
_HOST_VARS_SUFFIXES = (".yml", ".yaml")
|
|
58
|
+
|
|
59
|
+
_ERROR_DECLARATION_INVALID = "ansible_declaration_invalid"
|
|
60
|
+
_ERROR_CATALOG_INVALID = "ansible_catalog_invalid"
|
|
61
|
+
_ERROR_CATALOG_CONFLICT = "ansible_catalog_conflict"
|
|
62
|
+
_ERROR_NO_DECLARATIONS = "ansible_no_declarations"
|
|
63
|
+
|
|
64
|
+
# `labels` holds the team's own keys, which are data rather than field
|
|
65
|
+
# names, so the snake-to-camel rewrite must not touch inside it.
|
|
66
|
+
_OPAQUE_FIELDS = frozenset({_LABELS_FIELD})
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True, slots=True)
|
|
70
|
+
class FleetRead:
|
|
71
|
+
"""What one inventory yielded: a validated fleet, and what it skipped."""
|
|
72
|
+
|
|
73
|
+
config: ValidatedConfig
|
|
74
|
+
managed: tuple[str, ...]
|
|
75
|
+
"""Hosts that carry a ``cloudfall`` declaration, in inventory order."""
|
|
76
|
+
|
|
77
|
+
unmanaged: tuple[str, ...]
|
|
78
|
+
"""Hosts the inventory holds that Cloudfall was not told to manage."""
|
|
79
|
+
|
|
80
|
+
in_process: bool
|
|
81
|
+
"""ansible-core's Python API served the read rather than the command."""
|
|
82
|
+
|
|
83
|
+
def as_dict(self) -> dict[str, object]:
|
|
84
|
+
"""Serialize the read for system boundaries."""
|
|
85
|
+
return {
|
|
86
|
+
"status": "ok",
|
|
87
|
+
"inProcess": self.in_process,
|
|
88
|
+
"managed": list(self.managed),
|
|
89
|
+
"unmanaged": list(self.unmanaged),
|
|
90
|
+
"byKind": self.config.counts_by_kind(),
|
|
91
|
+
"resources": self.config.resource_count,
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def read_fleet(inventory: AnsibleInventory, schema_directory: Path) -> FleetRead:
|
|
96
|
+
"""Turn an Ansible inventory into validated Cloudfall resources."""
|
|
97
|
+
managed = tuple(
|
|
98
|
+
host for host in inventory.hosts if _declaration(host) is not None
|
|
99
|
+
)
|
|
100
|
+
unmanaged = tuple(
|
|
101
|
+
str(host.name) for host in inventory.hosts if _declaration(host) is None
|
|
102
|
+
)
|
|
103
|
+
if not managed:
|
|
104
|
+
message = (
|
|
105
|
+
f"no host in {inventory.source} declares a `{DECLARATION_VARIABLE}` "
|
|
106
|
+
"block, so there is no fleet to read"
|
|
107
|
+
)
|
|
108
|
+
raise AnsibleReadError(_ERROR_NO_DECLARATIONS, message)
|
|
109
|
+
|
|
110
|
+
documents = [
|
|
111
|
+
*_server_type_documents(managed, inventory.source.value),
|
|
112
|
+
*(_server_document(host, inventory.source.value) for host in managed),
|
|
113
|
+
]
|
|
114
|
+
validator = StateValidator(SchemaCatalog(schema_directory))
|
|
115
|
+
return FleetRead(
|
|
116
|
+
config=validator.validate_documents(documents),
|
|
117
|
+
managed=tuple(str(host.name) for host in managed),
|
|
118
|
+
unmanaged=unmanaged,
|
|
119
|
+
in_process=inventory.in_process,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _server_document(host: AnsibleHost, source: Path) -> RawDocument:
|
|
124
|
+
declaration = _declaration(host)
|
|
125
|
+
if declaration is None: # pragma: no cover - callers filter first
|
|
126
|
+
message = f"host {host.name} declares no `{DECLARATION_VARIABLE}` block"
|
|
127
|
+
raise AnsibleReadError(_ERROR_DECLARATION_INVALID, message)
|
|
128
|
+
declared = _camelized(_merged_declaration(host, declaration))
|
|
129
|
+
spec: dict[str, object] = {
|
|
130
|
+
key: value
|
|
131
|
+
for key, value in declared.items()
|
|
132
|
+
if key not in {_DESCRIPTION_FIELD, _HOSTNAME_FIELD}
|
|
133
|
+
}
|
|
134
|
+
spec[_HOSTNAME_FIELD] = declared.get(_HOSTNAME_FIELD, str(host.name))
|
|
135
|
+
spec["address"] = _address(host)
|
|
136
|
+
spec["ssh"] = _ssh(host)
|
|
137
|
+
metadata: dict[str, object] = {"id": str(host.name)}
|
|
138
|
+
description = declared.get(_DESCRIPTION_FIELD)
|
|
139
|
+
if description is not None:
|
|
140
|
+
metadata[_DESCRIPTION_FIELD] = description
|
|
141
|
+
content = {
|
|
142
|
+
"apiVersion": API_VERSION,
|
|
143
|
+
"kind": ResourceKind.SERVER.value,
|
|
144
|
+
"metadata": metadata,
|
|
145
|
+
"spec": spec,
|
|
146
|
+
}
|
|
147
|
+
return RawDocument(content=content, source=_host_source(host, source))
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _server_type_documents(
|
|
151
|
+
hosts: tuple[AnsibleHost, ...], source: Path
|
|
152
|
+
) -> tuple[RawDocument, ...]:
|
|
153
|
+
catalog: dict[str, Mapping[str, object]] = {}
|
|
154
|
+
for host in hosts:
|
|
155
|
+
raw = host.variable(CATALOG_VARIABLE)
|
|
156
|
+
if raw is None:
|
|
157
|
+
continue
|
|
158
|
+
if not isinstance(raw, dict):
|
|
159
|
+
message = (
|
|
160
|
+
f"`{CATALOG_VARIABLE}` on host {host.name} must be a mapping of "
|
|
161
|
+
f"server type name to definition, got {type(raw).__name__}"
|
|
162
|
+
)
|
|
163
|
+
raise AnsibleReadError(_ERROR_CATALOG_INVALID, message)
|
|
164
|
+
for name, definition in raw.items():
|
|
165
|
+
if not isinstance(definition, dict):
|
|
166
|
+
message = (
|
|
167
|
+
f"`{CATALOG_VARIABLE}.{name}` must be a mapping, got "
|
|
168
|
+
f"{type(definition).__name__}"
|
|
169
|
+
)
|
|
170
|
+
raise AnsibleReadError(_ERROR_CATALOG_INVALID, message)
|
|
171
|
+
identifier = str(name)
|
|
172
|
+
existing = catalog.get(identifier)
|
|
173
|
+
if existing is not None and existing != definition:
|
|
174
|
+
message = (
|
|
175
|
+
f"server type {identifier} is defined differently for "
|
|
176
|
+
f"host {host.name} than for an earlier host; one catalog "
|
|
177
|
+
"must describe the fleet"
|
|
178
|
+
)
|
|
179
|
+
raise AnsibleReadError(_ERROR_CATALOG_CONFLICT, message)
|
|
180
|
+
catalog[identifier] = definition
|
|
181
|
+
return tuple(
|
|
182
|
+
RawDocument(
|
|
183
|
+
content=_server_type_content(identifier, catalog[identifier]),
|
|
184
|
+
source=SourceLocation(path=source, document_number=0),
|
|
185
|
+
)
|
|
186
|
+
for identifier in sorted(catalog)
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _server_type_content(
|
|
191
|
+
identifier: str, definition: Mapping[str, object]
|
|
192
|
+
) -> Mapping[str, object]:
|
|
193
|
+
declared = _camelized(definition)
|
|
194
|
+
spec = {
|
|
195
|
+
key: value for key, value in declared.items() if key != _DESCRIPTION_FIELD
|
|
196
|
+
}
|
|
197
|
+
metadata: dict[str, object] = {"id": identifier}
|
|
198
|
+
description = declared.get(_DESCRIPTION_FIELD)
|
|
199
|
+
if description is not None:
|
|
200
|
+
metadata[_DESCRIPTION_FIELD] = description
|
|
201
|
+
return {
|
|
202
|
+
"apiVersion": API_VERSION,
|
|
203
|
+
"kind": ResourceKind.HOST_PROFILE.value,
|
|
204
|
+
"metadata": metadata,
|
|
205
|
+
"spec": spec,
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _declaration(host: AnsibleHost) -> Mapping[str, object] | None:
|
|
210
|
+
raw = host.variable(DECLARATION_VARIABLE)
|
|
211
|
+
if raw is None:
|
|
212
|
+
return None
|
|
213
|
+
if not isinstance(raw, dict):
|
|
214
|
+
message = (
|
|
215
|
+
f"`{DECLARATION_VARIABLE}` on host {host.name} must be a mapping, "
|
|
216
|
+
f"got {type(raw).__name__}"
|
|
217
|
+
)
|
|
218
|
+
raise AnsibleReadError(_ERROR_DECLARATION_INVALID, message)
|
|
219
|
+
return {str(key): value for key, value in raw.items()}
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _merged_declaration(
|
|
223
|
+
host: AnsibleHost, declaration: Mapping[str, object]
|
|
224
|
+
) -> Mapping[str, object]:
|
|
225
|
+
defaults = host.variable(DEFAULTS_VARIABLE)
|
|
226
|
+
if defaults is None:
|
|
227
|
+
return declaration
|
|
228
|
+
if not isinstance(defaults, dict):
|
|
229
|
+
message = (
|
|
230
|
+
f"`{DEFAULTS_VARIABLE}` reaching host {host.name} must be a "
|
|
231
|
+
f"mapping, got {type(defaults).__name__}"
|
|
232
|
+
)
|
|
233
|
+
raise AnsibleReadError(_ERROR_DECLARATION_INVALID, message)
|
|
234
|
+
merged = {str(key): value for key, value in defaults.items()}
|
|
235
|
+
merged.update(declaration)
|
|
236
|
+
return merged
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _address(host: AnsibleHost) -> object:
|
|
240
|
+
declared = host.variable(_ADDRESS_VARIABLE)
|
|
241
|
+
return declared if declared is not None else str(host.name)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _ssh(host: AnsibleHost) -> Mapping[str, object]:
|
|
245
|
+
user = host.variable(_SSH_USER_VARIABLE)
|
|
246
|
+
port = host.variable(_SSH_PORT_VARIABLE)
|
|
247
|
+
ssh: dict[str, object] = {"port": port if port is not None else _DEFAULT_SSH_PORT}
|
|
248
|
+
if user is not None:
|
|
249
|
+
ssh["user"] = user
|
|
250
|
+
return ssh
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _host_source(host: AnsibleHost, source: Path) -> SourceLocation:
|
|
254
|
+
"""Point errors at the host's own file when the inventory has one."""
|
|
255
|
+
for suffix in _HOST_VARS_SUFFIXES:
|
|
256
|
+
candidate = source / "host_vars" / f"{host.name}{suffix}"
|
|
257
|
+
if candidate.is_file():
|
|
258
|
+
return SourceLocation(path=candidate, document_number=1)
|
|
259
|
+
return SourceLocation(path=source, document_number=0)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _camelized(value: Mapping[str, object]) -> Mapping[str, object]:
|
|
263
|
+
"""Rewrite Ansible's snake_case field names as the schemas' camelCase."""
|
|
264
|
+
return {
|
|
265
|
+
_camel(str(key)): _camelized_value(str(key), item)
|
|
266
|
+
for key, item in value.items()
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _camelized_value(key: str, value: object) -> object:
|
|
271
|
+
if key in _OPAQUE_FIELDS:
|
|
272
|
+
return value
|
|
273
|
+
if isinstance(value, dict):
|
|
274
|
+
return _camelized({str(item): entry for item, entry in value.items()})
|
|
275
|
+
if isinstance(value, list):
|
|
276
|
+
return [_camelized_value(key, item) for item in value]
|
|
277
|
+
return value
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _camel(name: str) -> str:
|
|
281
|
+
return _SNAKE_BOUNDARY.sub(lambda match: match.group(1).upper(), name)
|