cloudfall 0.2.1__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.2.1 → cloudfall-0.4.0}/CHANGELOG.md +45 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/PKG-INFO +8 -9
- {cloudfall-0.2.1 → cloudfall-0.4.0}/README.md +7 -8
- {cloudfall-0.2.1 → cloudfall-0.4.0}/docs/render-migration-guide.md +1 -1
- {cloudfall-0.2.1 → 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.2.1 → cloudfall-0.4.0}/sdk/src/cloudfall/cli.py +164 -37
- {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/src/cloudfall/commands.py +6 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/src/cloudfall/domain.py +8 -0
- cloudfall-0.4.0/sdk/src/cloudfall/observe.py +282 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/src/cloudfall/project.py +26 -260
- {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/src/cloudfall/validation.py +51 -15
- {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/tests/test_agent_tools.py +3 -4
- cloudfall-0.4.0/sdk/tests/test_ansible_reader.py +366 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/tests/test_authoring.py +3 -4
- cloudfall-0.4.0/sdk/tests/test_observe.py +266 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/tests/test_project.py +44 -176
- {cloudfall-0.2.1 → cloudfall-0.4.0}/uv.lock +1 -1
- {cloudfall-0.2.1 → cloudfall-0.4.0}/.ansible-lint +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/.github/workflows/ci.yml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/.github/workflows/release.yml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/.gitignore +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/ARCHITECTURE.md +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/CONTRIBUTING.md +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/LICENSE +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/MANIFESTO.md +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/ROADMAP.md +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/SECURITY.md +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/Taskfile.yml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/config/README.md +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/config/examples/alert-rules/postgresql-down.yaml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/config/examples/applications/crm.yaml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/config/examples/components/crm-backend.yaml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/config/examples/domains/crm-site.yaml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/config/examples/logging-stacks/operations.yaml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/config/examples/operator-policies/production.yaml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/config/examples/server-types/debian-application.yaml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/config/examples/servers/h1.yaml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/config/examples/servers/h2.yaml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/config/examples/services/postgresql-main.yaml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/config/examples/services/redis-cache.yaml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/config/examples/ssh-public-keys/example-admin.yaml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/config/schemas/v1/alert-rule.schema.json +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/config/schemas/v1/application.schema.json +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/config/schemas/v1/artifact.schema.json +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/config/schemas/v1/backup-receipt.schema.json +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/config/schemas/v1/common.schema.json +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/config/schemas/v1/component.schema.json +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/config/schemas/v1/deployment-receipt.schema.json +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/config/schemas/v1/domain.schema.json +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/config/schemas/v1/environment-receipt.schema.json +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/config/schemas/v1/logging-stack.schema.json +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/config/schemas/v1/observed-domain.schema.json +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/config/schemas/v1/observed-server.schema.json +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/config/schemas/v1/operator-policy.schema.json +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/config/schemas/v1/operator-proposal.schema.json +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/config/schemas/v1/release-receipt.schema.json +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/config/schemas/v1/server-type.schema.json +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/config/schemas/v1/server.schema.json +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/config/schemas/v1/service.schema.json +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/config/schemas/v1/ssh-public-key.schema.json +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/config/tests/invalid/missing-server/applications/crm.yaml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/config/tests/invalid/missing-server/components/crm-backend.yaml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/config/tests/invalid/missing-server/server-types/debian-application.yaml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/config/tests/invalid/missing-server/servers/h1.yaml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/config/tests/invalid/schema/components/component-unknown-field.yaml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/config/tests/observed/compliant/h1.json +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/config/tests/observed/compliant/h2.json +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/docs/ansible-context.md +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/docs/availability-design.md +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/docs/brownfield-design.md +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/docs/decisions/2026-09-14-no-ansible-replacement.md +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/docs/decisions/2026-09-14-no-nixos-base.md +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/docs/decisions/2026-09-14-no-rust-rewrite.md +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/docs/fleet-goals.md +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/docs/hybrid-storage-design.md +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/docs/landscape.md +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/docs/logging-service-guide.md +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/docs/neon-migration-guide.md +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/docs/new-server-storage-guide.md +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/docs/operator-guide.md +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/docs/proving-runs/2026-09-08-debian-container-m1.md +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/docs/proving-runs/2026-09-08-dns-cutover.md +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/docs/proving-runs/2026-09-08-guided-data-migration.md +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/docs/proving-runs/2026-09-08-hetzner-m1.md +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/docs/proving-runs/2026-09-08-hetzner-m2-m3.md +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/docs/proving-runs/2026-09-08-hetzner-m4-m5.md +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/docs/proving-runs/2026-09-08-render-cutover.md +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/docs/proving-runs/2026-09-10-hetzner-m7.md +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/docs/proving-runs/2026-09-10-hetzner-m8.md +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/docs/proving-runs/2026-09-10-hetzner-m9.md +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/docs/proving-runs/2026-09-11-hetzner-m10.md +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/docs/secrets-guide.md +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/README.md +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/ansible.cfg +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/playbooks/backup.yml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/playbooks/baseline.yml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/playbooks/bootstrap.yml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/playbooks/data.yml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/playbooks/deploy.yml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/playbooks/domains.yml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/playbooks/health.yml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/playbooks/inspect.yml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/playbooks/logging.yml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/playbooks/restart.yml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/playbooks/rollback.yml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/playbooks/services.yml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/playbooks/time.yml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_access/defaults/main.yml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_access/handlers/main.yml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_access/meta/argument_specs.yml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_access/meta/main.yml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_access/tasks/main.yml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_bootstrap/defaults/main.yml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_bootstrap/meta/argument_specs.yml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_bootstrap/meta/main.yml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_bootstrap/tasks/main.yml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_data_migration/defaults/main.yml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_data_migration/meta/argument_specs.yml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_data_migration/meta/main.yml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_data_migration/tasks/main.yml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_deploy/defaults/main.yml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_deploy/meta/argument_specs.yml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_deploy/meta/main.yml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_deploy/tasks/health.yml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_deploy/tasks/health_gate.yml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_deploy/tasks/main.yml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_deploy/tasks/restart.yml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_deploy/tasks/rollback.yml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_deploy/templates/component.service.j2 +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_firewall/defaults/main.yml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_firewall/handlers/main.yml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_firewall/meta/argument_specs.yml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_firewall/meta/main.yml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_firewall/tasks/main.yml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_firewall/templates/nftables.conf.j2 +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_inspect/defaults/main.yml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_inspect/meta/argument_specs.yml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_inspect/meta/main.yml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_inspect/tasks/main.yml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_logging_backend/defaults/main.yml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_logging_backend/handlers/main.yml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_logging_backend/meta/argument_specs.yml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_logging_backend/meta/main.yml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_logging_backend/tasks/main.yml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_logging_backend/templates/alertmanager-defaults.j2 +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_logging_backend/templates/alertmanager.yaml.j2 +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_logging_backend/templates/grafana.ini.j2 +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_logging_backend/templates/loki-datasource.yml.j2 +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_logging_backend/templates/loki-systemd-override.conf.j2 +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_logging_backend/templates/loki.yaml.j2 +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_logging_backend/templates/nginx-loki-gateway.conf.j2 +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_logging_backend/templates/prometheus-datasource.yml.j2 +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_logging_backend/templates/prometheus-defaults.j2 +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_logging_backend/templates/prometheus-rules.yaml.j2 +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_logging_backend/templates/prometheus.yaml.j2 +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_logging_collector/defaults/main.yml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_logging_collector/handlers/main.yml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_logging_collector/meta/argument_specs.yml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_logging_collector/meta/main.yml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_logging_collector/tasks/main.yml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_logging_collector/templates/alloy-systemd-override.conf.j2 +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_logging_collector/templates/config.alloy.j2 +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_nginx_site/defaults/main.yml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_nginx_site/handlers/main.yml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_nginx_site/meta/argument_specs.yml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_nginx_site/meta/main.yml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_nginx_site/tasks/main.yml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_nginx_site/templates/domain-site.conf.j2 +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_postgresql/defaults/main.yml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_postgresql/handlers/main.yml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_postgresql/meta/argument_specs.yml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_postgresql/meta/main.yml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_postgresql/tasks/main.yml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_postgresql/templates/backup.service.j2 +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_postgresql/templates/backup.sh.j2 +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_postgresql/templates/backup.timer.j2 +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_postgresql/templates/cloudfall.conf.j2 +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_postgresql/templates/restore-check.service.j2 +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_postgresql/templates/restore-check.sh.j2 +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_postgresql/templates/restore-check.timer.j2 +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_redis/defaults/main.yml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_redis/handlers/main.yml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_redis/meta/argument_specs.yml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_redis/meta/main.yml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_redis/tasks/main.yml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_redis/templates/backup.service.j2 +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_redis/templates/backup.sh.j2 +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_redis/templates/backup.timer.j2 +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_redis/templates/cloudfall.conf.j2 +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_redis/templates/restore-check.service.j2 +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_redis/templates/restore-check.sh.j2 +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_redis/templates/restore-check.timer.j2 +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_time/defaults/main.yml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_time/meta/argument_specs.yml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_time/meta/main.yml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_time/tasks/main.yml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_unattended_upgrades/defaults/main.yml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_unattended_upgrades/meta/argument_specs.yml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_unattended_upgrades/meta/main.yml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_unattended_upgrades/tasks/main.yml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/src/cloudfall_engine/__init__.py +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/src/cloudfall_engine/__main__.py +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/src/cloudfall_engine/ansible_inventory.py +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/src/cloudfall_engine/artifact.py +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/src/cloudfall_engine/cli.py +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/src/cloudfall_engine/playbook.py +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/src/cloudfall_engine/py.typed +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/tests/test_ansible_inventory.py +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/tests/test_artifact_builder.py +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/tests/test_deploy_templates.py +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/tests/test_firewall_templates.py +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/tests/test_logging_templates.py +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/tests/test_nginx_site_templates.py +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/tests/test_playbook.py +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/tests/test_postgresql_templates.py +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/evaluations/cloudfall/README.md +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/evaluations/cloudfall/environment.md +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/evaluations/cloudfall/findings.md +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/evaluations/cloudfall/issues.md +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/evaluations/cloudfall/linkedin.md +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/evaluations/cloudfall/readiness.md +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/evaluations/cloudfall/report-agent-dev.md +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/evaluations/cloudfall/report-dev.md +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/evaluations/cloudfall/report-index.md +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/evaluations/cloudfall/report-issues.md +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/evaluations/cloudfall/report-runtime.md +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/evaluations/cloudfall/trace.md +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/evaluations/cloudfall/x.md +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/examples/render-demo/README.md +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/examples/render-demo/pyproject.toml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/examples/render-demo/render.yaml +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/examples/render-demo/src/demo/__init__.py +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/examples/render-demo/src/demo/asgi.py +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/examples/render-demo/uv.lock +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/README.md +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/src/cloudfall/__init__.py +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/src/cloudfall/__main__.py +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/src/cloudfall/agent_tools.py +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/src/cloudfall/arguments.py +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/src/cloudfall/audit.py +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/src/cloudfall/authoring.py +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/src/cloudfall/cutover.py +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/src/cloudfall/dashboard.py +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/src/cloudfall/dashboard_server.py +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/src/cloudfall/importer.py +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/src/cloudfall/inventory.py +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/src/cloudfall/lifecycle.py +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/src/cloudfall/mcp_server.py +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/src/cloudfall/migrate.py +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/src/cloudfall/observation.py +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/src/cloudfall/operations.py +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/src/cloudfall/operator.py +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/src/cloudfall/py.typed +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/src/cloudfall/render_api.py +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/src/cloudfall/resources.py +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/src/cloudfall/secrets.py +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/src/cloudfall/service_evidence.py +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/tests/test_arguments.py +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/tests/test_audit.py +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/tests/test_commands.py +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/tests/test_cutover.py +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/tests/test_dashboard_server.py +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/tests/test_data_migration.py +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/tests/test_importer.py +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/tests/test_inventory.py +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/tests/test_lifecycle.py +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/tests/test_migrate.py +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/tests/test_operations_dashboard.py +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/tests/test_operator.py +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/tests/test_render_api.py +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/tests/test_resources.py +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/tests/test_secrets.py +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/tests/test_service_evidence.py +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/tests/test_validation.py +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/site/public/apple-touch-icon.png +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/site/public/favicon-32.png +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/site/public/favicon.svg +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/site/public/icon-192.png +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/site/public/icon-512.png +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/site/public/index.html +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/site/public/manifesto.html +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/site/public/og.png +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/site/public/site.webmanifest +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/site/src/worker.js +0 -0
- {cloudfall-0.2.1 → cloudfall-0.4.0}/site/wrangler.jsonc +0 -0
|
@@ -6,6 +6,51 @@ 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
|
+
|
|
35
|
+
## [0.3.0] — 2026-09-20
|
|
36
|
+
|
|
37
|
+
### Removed
|
|
38
|
+
|
|
39
|
+
- `cloudfall init --rev` and `--source`, and the machinery behind them:
|
|
40
|
+
`GitRevision`, `GitSourceUrl`, `GitPin`, `IndexPin`, `CheckoutState`, the
|
|
41
|
+
checkout inspection, and the `project_revision_unresolved`,
|
|
42
|
+
`project_revision_uncommitted` and `project_revision_unpublished` errors.
|
|
43
|
+
Cloudfall is on PyPI, so a project pins a release and resolves it like any
|
|
44
|
+
other dependency; `resolve_installed_version` replaces
|
|
45
|
+
`resolve_installed_pin`, and the `init` envelope reports `version` rather
|
|
46
|
+
than a `pin` object. To run an unreleased Cloudfall in a project, point the
|
|
47
|
+
dependency at a checkout with uv's own `[tool.uv.sources]`
|
|
48
|
+
|
|
49
|
+
### Changed
|
|
50
|
+
|
|
51
|
+
- The quickstart and the Render migration guide install Cloudfall from the
|
|
52
|
+
index rather than from git
|
|
53
|
+
|
|
9
54
|
## [0.2.1] — 2026-09-20
|
|
10
55
|
|
|
11
56
|
### Fixed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: cloudfall
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: AI-native control plane for dedicated Debian application fleets
|
|
5
5
|
Project-URL: Homepage, https://cloudfall.dev
|
|
6
6
|
Project-URL: Repository, https://github.com/romamo/cloudfall
|
|
@@ -198,10 +198,8 @@ Your servers, applications, evidence, and any playbooks of your own belong in
|
|
|
198
198
|
a project: a directory of its own, kept private. Cloudfall's wheel bundles the
|
|
199
199
|
schema catalog and the Ansible engine, so the project needs no checkout of
|
|
200
200
|
this repository. `cloudfall init` is the first command, and it pins the
|
|
201
|
-
project to the Cloudfall that ran it
|
|
202
|
-
|
|
203
|
-
directory argument it initializes the current directory, which must be
|
|
204
|
-
empty:
|
|
201
|
+
project to the released Cloudfall that ran it. Without a directory argument
|
|
202
|
+
it initializes the current directory, which must be empty:
|
|
205
203
|
|
|
206
204
|
```console
|
|
207
205
|
uvx cloudfall init my-project
|
|
@@ -230,10 +228,11 @@ dependencies = ["cloudfall==<version>"]
|
|
|
230
228
|
package = false
|
|
231
229
|
```
|
|
232
230
|
|
|
233
|
-
Bump that
|
|
234
|
-
|
|
235
|
-
a
|
|
236
|
-
|
|
231
|
+
Bump that version and run `uv sync` to move the project to a newer
|
|
232
|
+
Cloudfall. To run an unreleased Cloudfall in a project, point the dependency
|
|
233
|
+
at a checkout or a commit with uv's own
|
|
234
|
+
[`[tool.uv.sources]`](https://docs.astral.sh/uv/concepts/projects/dependencies/#dependency-sources);
|
|
235
|
+
Cloudfall itself only ever pins a release.
|
|
237
236
|
|
|
238
237
|
Only the kind directories are read as resources, so playbooks, roles, docs,
|
|
239
238
|
and tooling files may live anywhere else in the project. Commands find the
|
|
@@ -172,10 +172,8 @@ Your servers, applications, evidence, and any playbooks of your own belong in
|
|
|
172
172
|
a project: a directory of its own, kept private. Cloudfall's wheel bundles the
|
|
173
173
|
schema catalog and the Ansible engine, so the project needs no checkout of
|
|
174
174
|
this repository. `cloudfall init` is the first command, and it pins the
|
|
175
|
-
project to the Cloudfall that ran it
|
|
176
|
-
|
|
177
|
-
directory argument it initializes the current directory, which must be
|
|
178
|
-
empty:
|
|
175
|
+
project to the released Cloudfall that ran it. Without a directory argument
|
|
176
|
+
it initializes the current directory, which must be empty:
|
|
179
177
|
|
|
180
178
|
```console
|
|
181
179
|
uvx cloudfall init my-project
|
|
@@ -204,10 +202,11 @@ dependencies = ["cloudfall==<version>"]
|
|
|
204
202
|
package = false
|
|
205
203
|
```
|
|
206
204
|
|
|
207
|
-
Bump that
|
|
208
|
-
|
|
209
|
-
a
|
|
210
|
-
|
|
205
|
+
Bump that version and run `uv sync` to move the project to a newer
|
|
206
|
+
Cloudfall. To run an unreleased Cloudfall in a project, point the dependency
|
|
207
|
+
at a checkout or a commit with uv's own
|
|
208
|
+
[`[tool.uv.sources]`](https://docs.astral.sh/uv/concepts/projects/dependencies/#dependency-sources);
|
|
209
|
+
Cloudfall itself only ever pins a release.
|
|
211
210
|
|
|
212
211
|
Only the kind directories are read as resources, so playbooks, roles, docs,
|
|
213
212
|
and tooling files may live anywhere else in the project. Commands find the
|
|
@@ -55,7 +55,7 @@ Not imported today; each lands in the gap report instead of being guessed at:
|
|
|
55
55
|
- Your `render.yaml` blueprint and access to your application repositories
|
|
56
56
|
- Python 3.14 and [`uv`](https://docs.astral.sh/uv/) on the machine you run
|
|
57
57
|
Cloudfall from (your workstation works; no management server is required)
|
|
58
|
-
- A project: `uvx
|
|
58
|
+
- A project: `uvx cloudfall init my-project`,
|
|
59
59
|
then `cd my-project && uv sync`. Every command below runs from inside it
|
|
60
60
|
|
|
61
61
|
## Step 1: import the blueprint
|
|
@@ -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))
|