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.
Files changed (287) hide show
  1. {cloudfall-0.2.1 → cloudfall-0.4.0}/CHANGELOG.md +45 -0
  2. {cloudfall-0.2.1 → cloudfall-0.4.0}/PKG-INFO +8 -9
  3. {cloudfall-0.2.1 → cloudfall-0.4.0}/README.md +7 -8
  4. {cloudfall-0.2.1 → cloudfall-0.4.0}/docs/render-migration-guide.md +1 -1
  5. {cloudfall-0.2.1 → cloudfall-0.4.0}/pyproject.toml +7 -1
  6. cloudfall-0.4.0/sdk/src/cloudfall/ansible_api.py +356 -0
  7. cloudfall-0.4.0/sdk/src/cloudfall/ansible_reader.py +281 -0
  8. {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/src/cloudfall/cli.py +164 -37
  9. {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/src/cloudfall/commands.py +6 -0
  10. {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/src/cloudfall/domain.py +8 -0
  11. cloudfall-0.4.0/sdk/src/cloudfall/observe.py +282 -0
  12. {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/src/cloudfall/project.py +26 -260
  13. {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/src/cloudfall/validation.py +51 -15
  14. {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/tests/test_agent_tools.py +3 -4
  15. cloudfall-0.4.0/sdk/tests/test_ansible_reader.py +366 -0
  16. {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/tests/test_authoring.py +3 -4
  17. cloudfall-0.4.0/sdk/tests/test_observe.py +266 -0
  18. {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/tests/test_project.py +44 -176
  19. {cloudfall-0.2.1 → cloudfall-0.4.0}/uv.lock +1 -1
  20. {cloudfall-0.2.1 → cloudfall-0.4.0}/.ansible-lint +0 -0
  21. {cloudfall-0.2.1 → cloudfall-0.4.0}/.github/workflows/ci.yml +0 -0
  22. {cloudfall-0.2.1 → cloudfall-0.4.0}/.github/workflows/release.yml +0 -0
  23. {cloudfall-0.2.1 → cloudfall-0.4.0}/.gitignore +0 -0
  24. {cloudfall-0.2.1 → cloudfall-0.4.0}/ARCHITECTURE.md +0 -0
  25. {cloudfall-0.2.1 → cloudfall-0.4.0}/CONTRIBUTING.md +0 -0
  26. {cloudfall-0.2.1 → cloudfall-0.4.0}/LICENSE +0 -0
  27. {cloudfall-0.2.1 → cloudfall-0.4.0}/MANIFESTO.md +0 -0
  28. {cloudfall-0.2.1 → cloudfall-0.4.0}/ROADMAP.md +0 -0
  29. {cloudfall-0.2.1 → cloudfall-0.4.0}/SECURITY.md +0 -0
  30. {cloudfall-0.2.1 → cloudfall-0.4.0}/Taskfile.yml +0 -0
  31. {cloudfall-0.2.1 → cloudfall-0.4.0}/config/README.md +0 -0
  32. {cloudfall-0.2.1 → cloudfall-0.4.0}/config/examples/alert-rules/postgresql-down.yaml +0 -0
  33. {cloudfall-0.2.1 → cloudfall-0.4.0}/config/examples/applications/crm.yaml +0 -0
  34. {cloudfall-0.2.1 → cloudfall-0.4.0}/config/examples/components/crm-backend.yaml +0 -0
  35. {cloudfall-0.2.1 → cloudfall-0.4.0}/config/examples/domains/crm-site.yaml +0 -0
  36. {cloudfall-0.2.1 → cloudfall-0.4.0}/config/examples/logging-stacks/operations.yaml +0 -0
  37. {cloudfall-0.2.1 → cloudfall-0.4.0}/config/examples/operator-policies/production.yaml +0 -0
  38. {cloudfall-0.2.1 → cloudfall-0.4.0}/config/examples/server-types/debian-application.yaml +0 -0
  39. {cloudfall-0.2.1 → cloudfall-0.4.0}/config/examples/servers/h1.yaml +0 -0
  40. {cloudfall-0.2.1 → cloudfall-0.4.0}/config/examples/servers/h2.yaml +0 -0
  41. {cloudfall-0.2.1 → cloudfall-0.4.0}/config/examples/services/postgresql-main.yaml +0 -0
  42. {cloudfall-0.2.1 → cloudfall-0.4.0}/config/examples/services/redis-cache.yaml +0 -0
  43. {cloudfall-0.2.1 → cloudfall-0.4.0}/config/examples/ssh-public-keys/example-admin.yaml +0 -0
  44. {cloudfall-0.2.1 → cloudfall-0.4.0}/config/schemas/v1/alert-rule.schema.json +0 -0
  45. {cloudfall-0.2.1 → cloudfall-0.4.0}/config/schemas/v1/application.schema.json +0 -0
  46. {cloudfall-0.2.1 → cloudfall-0.4.0}/config/schemas/v1/artifact.schema.json +0 -0
  47. {cloudfall-0.2.1 → cloudfall-0.4.0}/config/schemas/v1/backup-receipt.schema.json +0 -0
  48. {cloudfall-0.2.1 → cloudfall-0.4.0}/config/schemas/v1/common.schema.json +0 -0
  49. {cloudfall-0.2.1 → cloudfall-0.4.0}/config/schemas/v1/component.schema.json +0 -0
  50. {cloudfall-0.2.1 → cloudfall-0.4.0}/config/schemas/v1/deployment-receipt.schema.json +0 -0
  51. {cloudfall-0.2.1 → cloudfall-0.4.0}/config/schemas/v1/domain.schema.json +0 -0
  52. {cloudfall-0.2.1 → cloudfall-0.4.0}/config/schemas/v1/environment-receipt.schema.json +0 -0
  53. {cloudfall-0.2.1 → cloudfall-0.4.0}/config/schemas/v1/logging-stack.schema.json +0 -0
  54. {cloudfall-0.2.1 → cloudfall-0.4.0}/config/schemas/v1/observed-domain.schema.json +0 -0
  55. {cloudfall-0.2.1 → cloudfall-0.4.0}/config/schemas/v1/observed-server.schema.json +0 -0
  56. {cloudfall-0.2.1 → cloudfall-0.4.0}/config/schemas/v1/operator-policy.schema.json +0 -0
  57. {cloudfall-0.2.1 → cloudfall-0.4.0}/config/schemas/v1/operator-proposal.schema.json +0 -0
  58. {cloudfall-0.2.1 → cloudfall-0.4.0}/config/schemas/v1/release-receipt.schema.json +0 -0
  59. {cloudfall-0.2.1 → cloudfall-0.4.0}/config/schemas/v1/server-type.schema.json +0 -0
  60. {cloudfall-0.2.1 → cloudfall-0.4.0}/config/schemas/v1/server.schema.json +0 -0
  61. {cloudfall-0.2.1 → cloudfall-0.4.0}/config/schemas/v1/service.schema.json +0 -0
  62. {cloudfall-0.2.1 → cloudfall-0.4.0}/config/schemas/v1/ssh-public-key.schema.json +0 -0
  63. {cloudfall-0.2.1 → cloudfall-0.4.0}/config/tests/invalid/missing-server/applications/crm.yaml +0 -0
  64. {cloudfall-0.2.1 → cloudfall-0.4.0}/config/tests/invalid/missing-server/components/crm-backend.yaml +0 -0
  65. {cloudfall-0.2.1 → cloudfall-0.4.0}/config/tests/invalid/missing-server/server-types/debian-application.yaml +0 -0
  66. {cloudfall-0.2.1 → cloudfall-0.4.0}/config/tests/invalid/missing-server/servers/h1.yaml +0 -0
  67. {cloudfall-0.2.1 → cloudfall-0.4.0}/config/tests/invalid/schema/components/component-unknown-field.yaml +0 -0
  68. {cloudfall-0.2.1 → cloudfall-0.4.0}/config/tests/observed/compliant/h1.json +0 -0
  69. {cloudfall-0.2.1 → cloudfall-0.4.0}/config/tests/observed/compliant/h2.json +0 -0
  70. {cloudfall-0.2.1 → cloudfall-0.4.0}/docs/ansible-context.md +0 -0
  71. {cloudfall-0.2.1 → cloudfall-0.4.0}/docs/availability-design.md +0 -0
  72. {cloudfall-0.2.1 → cloudfall-0.4.0}/docs/brownfield-design.md +0 -0
  73. {cloudfall-0.2.1 → cloudfall-0.4.0}/docs/decisions/2026-09-14-no-ansible-replacement.md +0 -0
  74. {cloudfall-0.2.1 → cloudfall-0.4.0}/docs/decisions/2026-09-14-no-nixos-base.md +0 -0
  75. {cloudfall-0.2.1 → cloudfall-0.4.0}/docs/decisions/2026-09-14-no-rust-rewrite.md +0 -0
  76. {cloudfall-0.2.1 → cloudfall-0.4.0}/docs/fleet-goals.md +0 -0
  77. {cloudfall-0.2.1 → cloudfall-0.4.0}/docs/hybrid-storage-design.md +0 -0
  78. {cloudfall-0.2.1 → cloudfall-0.4.0}/docs/landscape.md +0 -0
  79. {cloudfall-0.2.1 → cloudfall-0.4.0}/docs/logging-service-guide.md +0 -0
  80. {cloudfall-0.2.1 → cloudfall-0.4.0}/docs/neon-migration-guide.md +0 -0
  81. {cloudfall-0.2.1 → cloudfall-0.4.0}/docs/new-server-storage-guide.md +0 -0
  82. {cloudfall-0.2.1 → cloudfall-0.4.0}/docs/operator-guide.md +0 -0
  83. {cloudfall-0.2.1 → cloudfall-0.4.0}/docs/proving-runs/2026-09-08-debian-container-m1.md +0 -0
  84. {cloudfall-0.2.1 → cloudfall-0.4.0}/docs/proving-runs/2026-09-08-dns-cutover.md +0 -0
  85. {cloudfall-0.2.1 → cloudfall-0.4.0}/docs/proving-runs/2026-09-08-guided-data-migration.md +0 -0
  86. {cloudfall-0.2.1 → cloudfall-0.4.0}/docs/proving-runs/2026-09-08-hetzner-m1.md +0 -0
  87. {cloudfall-0.2.1 → cloudfall-0.4.0}/docs/proving-runs/2026-09-08-hetzner-m2-m3.md +0 -0
  88. {cloudfall-0.2.1 → cloudfall-0.4.0}/docs/proving-runs/2026-09-08-hetzner-m4-m5.md +0 -0
  89. {cloudfall-0.2.1 → cloudfall-0.4.0}/docs/proving-runs/2026-09-08-render-cutover.md +0 -0
  90. {cloudfall-0.2.1 → cloudfall-0.4.0}/docs/proving-runs/2026-09-10-hetzner-m7.md +0 -0
  91. {cloudfall-0.2.1 → cloudfall-0.4.0}/docs/proving-runs/2026-09-10-hetzner-m8.md +0 -0
  92. {cloudfall-0.2.1 → cloudfall-0.4.0}/docs/proving-runs/2026-09-10-hetzner-m9.md +0 -0
  93. {cloudfall-0.2.1 → cloudfall-0.4.0}/docs/proving-runs/2026-09-11-hetzner-m10.md +0 -0
  94. {cloudfall-0.2.1 → cloudfall-0.4.0}/docs/secrets-guide.md +0 -0
  95. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/README.md +0 -0
  96. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/ansible.cfg +0 -0
  97. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/playbooks/backup.yml +0 -0
  98. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/playbooks/baseline.yml +0 -0
  99. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/playbooks/bootstrap.yml +0 -0
  100. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/playbooks/data.yml +0 -0
  101. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/playbooks/deploy.yml +0 -0
  102. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/playbooks/domains.yml +0 -0
  103. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/playbooks/health.yml +0 -0
  104. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/playbooks/inspect.yml +0 -0
  105. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/playbooks/logging.yml +0 -0
  106. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/playbooks/restart.yml +0 -0
  107. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/playbooks/rollback.yml +0 -0
  108. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/playbooks/services.yml +0 -0
  109. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/playbooks/time.yml +0 -0
  110. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_access/defaults/main.yml +0 -0
  111. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_access/handlers/main.yml +0 -0
  112. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_access/meta/argument_specs.yml +0 -0
  113. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_access/meta/main.yml +0 -0
  114. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_access/tasks/main.yml +0 -0
  115. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_bootstrap/defaults/main.yml +0 -0
  116. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_bootstrap/meta/argument_specs.yml +0 -0
  117. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_bootstrap/meta/main.yml +0 -0
  118. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_bootstrap/tasks/main.yml +0 -0
  119. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_data_migration/defaults/main.yml +0 -0
  120. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_data_migration/meta/argument_specs.yml +0 -0
  121. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_data_migration/meta/main.yml +0 -0
  122. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_data_migration/tasks/main.yml +0 -0
  123. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_deploy/defaults/main.yml +0 -0
  124. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_deploy/meta/argument_specs.yml +0 -0
  125. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_deploy/meta/main.yml +0 -0
  126. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_deploy/tasks/health.yml +0 -0
  127. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_deploy/tasks/health_gate.yml +0 -0
  128. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_deploy/tasks/main.yml +0 -0
  129. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_deploy/tasks/restart.yml +0 -0
  130. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_deploy/tasks/rollback.yml +0 -0
  131. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_deploy/templates/component.service.j2 +0 -0
  132. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_firewall/defaults/main.yml +0 -0
  133. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_firewall/handlers/main.yml +0 -0
  134. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_firewall/meta/argument_specs.yml +0 -0
  135. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_firewall/meta/main.yml +0 -0
  136. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_firewall/tasks/main.yml +0 -0
  137. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_firewall/templates/nftables.conf.j2 +0 -0
  138. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_inspect/defaults/main.yml +0 -0
  139. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_inspect/meta/argument_specs.yml +0 -0
  140. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_inspect/meta/main.yml +0 -0
  141. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_inspect/tasks/main.yml +0 -0
  142. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_logging_backend/defaults/main.yml +0 -0
  143. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_logging_backend/handlers/main.yml +0 -0
  144. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_logging_backend/meta/argument_specs.yml +0 -0
  145. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_logging_backend/meta/main.yml +0 -0
  146. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_logging_backend/tasks/main.yml +0 -0
  147. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_logging_backend/templates/alertmanager-defaults.j2 +0 -0
  148. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_logging_backend/templates/alertmanager.yaml.j2 +0 -0
  149. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_logging_backend/templates/grafana.ini.j2 +0 -0
  150. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_logging_backend/templates/loki-datasource.yml.j2 +0 -0
  151. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_logging_backend/templates/loki-systemd-override.conf.j2 +0 -0
  152. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_logging_backend/templates/loki.yaml.j2 +0 -0
  153. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_logging_backend/templates/nginx-loki-gateway.conf.j2 +0 -0
  154. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_logging_backend/templates/prometheus-datasource.yml.j2 +0 -0
  155. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_logging_backend/templates/prometheus-defaults.j2 +0 -0
  156. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_logging_backend/templates/prometheus-rules.yaml.j2 +0 -0
  157. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_logging_backend/templates/prometheus.yaml.j2 +0 -0
  158. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_logging_collector/defaults/main.yml +0 -0
  159. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_logging_collector/handlers/main.yml +0 -0
  160. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_logging_collector/meta/argument_specs.yml +0 -0
  161. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_logging_collector/meta/main.yml +0 -0
  162. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_logging_collector/tasks/main.yml +0 -0
  163. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_logging_collector/templates/alloy-systemd-override.conf.j2 +0 -0
  164. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_logging_collector/templates/config.alloy.j2 +0 -0
  165. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_nginx_site/defaults/main.yml +0 -0
  166. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_nginx_site/handlers/main.yml +0 -0
  167. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_nginx_site/meta/argument_specs.yml +0 -0
  168. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_nginx_site/meta/main.yml +0 -0
  169. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_nginx_site/tasks/main.yml +0 -0
  170. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_nginx_site/templates/domain-site.conf.j2 +0 -0
  171. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_postgresql/defaults/main.yml +0 -0
  172. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_postgresql/handlers/main.yml +0 -0
  173. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_postgresql/meta/argument_specs.yml +0 -0
  174. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_postgresql/meta/main.yml +0 -0
  175. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_postgresql/tasks/main.yml +0 -0
  176. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_postgresql/templates/backup.service.j2 +0 -0
  177. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_postgresql/templates/backup.sh.j2 +0 -0
  178. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_postgresql/templates/backup.timer.j2 +0 -0
  179. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_postgresql/templates/cloudfall.conf.j2 +0 -0
  180. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_postgresql/templates/restore-check.service.j2 +0 -0
  181. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_postgresql/templates/restore-check.sh.j2 +0 -0
  182. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_postgresql/templates/restore-check.timer.j2 +0 -0
  183. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_redis/defaults/main.yml +0 -0
  184. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_redis/handlers/main.yml +0 -0
  185. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_redis/meta/argument_specs.yml +0 -0
  186. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_redis/meta/main.yml +0 -0
  187. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_redis/tasks/main.yml +0 -0
  188. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_redis/templates/backup.service.j2 +0 -0
  189. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_redis/templates/backup.sh.j2 +0 -0
  190. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_redis/templates/backup.timer.j2 +0 -0
  191. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_redis/templates/cloudfall.conf.j2 +0 -0
  192. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_redis/templates/restore-check.service.j2 +0 -0
  193. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_redis/templates/restore-check.sh.j2 +0 -0
  194. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_redis/templates/restore-check.timer.j2 +0 -0
  195. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_time/defaults/main.yml +0 -0
  196. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_time/meta/argument_specs.yml +0 -0
  197. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_time/meta/main.yml +0 -0
  198. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_time/tasks/main.yml +0 -0
  199. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_unattended_upgrades/defaults/main.yml +0 -0
  200. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_unattended_upgrades/meta/argument_specs.yml +0 -0
  201. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_unattended_upgrades/meta/main.yml +0 -0
  202. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/ansible/roles/cloudfall_unattended_upgrades/tasks/main.yml +0 -0
  203. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/src/cloudfall_engine/__init__.py +0 -0
  204. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/src/cloudfall_engine/__main__.py +0 -0
  205. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/src/cloudfall_engine/ansible_inventory.py +0 -0
  206. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/src/cloudfall_engine/artifact.py +0 -0
  207. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/src/cloudfall_engine/cli.py +0 -0
  208. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/src/cloudfall_engine/playbook.py +0 -0
  209. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/src/cloudfall_engine/py.typed +0 -0
  210. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/tests/test_ansible_inventory.py +0 -0
  211. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/tests/test_artifact_builder.py +0 -0
  212. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/tests/test_deploy_templates.py +0 -0
  213. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/tests/test_firewall_templates.py +0 -0
  214. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/tests/test_logging_templates.py +0 -0
  215. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/tests/test_nginx_site_templates.py +0 -0
  216. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/tests/test_playbook.py +0 -0
  217. {cloudfall-0.2.1 → cloudfall-0.4.0}/engine/tests/test_postgresql_templates.py +0 -0
  218. {cloudfall-0.2.1 → cloudfall-0.4.0}/evaluations/cloudfall/README.md +0 -0
  219. {cloudfall-0.2.1 → cloudfall-0.4.0}/evaluations/cloudfall/environment.md +0 -0
  220. {cloudfall-0.2.1 → cloudfall-0.4.0}/evaluations/cloudfall/findings.md +0 -0
  221. {cloudfall-0.2.1 → cloudfall-0.4.0}/evaluations/cloudfall/issues.md +0 -0
  222. {cloudfall-0.2.1 → cloudfall-0.4.0}/evaluations/cloudfall/linkedin.md +0 -0
  223. {cloudfall-0.2.1 → cloudfall-0.4.0}/evaluations/cloudfall/readiness.md +0 -0
  224. {cloudfall-0.2.1 → cloudfall-0.4.0}/evaluations/cloudfall/report-agent-dev.md +0 -0
  225. {cloudfall-0.2.1 → cloudfall-0.4.0}/evaluations/cloudfall/report-dev.md +0 -0
  226. {cloudfall-0.2.1 → cloudfall-0.4.0}/evaluations/cloudfall/report-index.md +0 -0
  227. {cloudfall-0.2.1 → cloudfall-0.4.0}/evaluations/cloudfall/report-issues.md +0 -0
  228. {cloudfall-0.2.1 → cloudfall-0.4.0}/evaluations/cloudfall/report-runtime.md +0 -0
  229. {cloudfall-0.2.1 → cloudfall-0.4.0}/evaluations/cloudfall/trace.md +0 -0
  230. {cloudfall-0.2.1 → cloudfall-0.4.0}/evaluations/cloudfall/x.md +0 -0
  231. {cloudfall-0.2.1 → cloudfall-0.4.0}/examples/render-demo/README.md +0 -0
  232. {cloudfall-0.2.1 → cloudfall-0.4.0}/examples/render-demo/pyproject.toml +0 -0
  233. {cloudfall-0.2.1 → cloudfall-0.4.0}/examples/render-demo/render.yaml +0 -0
  234. {cloudfall-0.2.1 → cloudfall-0.4.0}/examples/render-demo/src/demo/__init__.py +0 -0
  235. {cloudfall-0.2.1 → cloudfall-0.4.0}/examples/render-demo/src/demo/asgi.py +0 -0
  236. {cloudfall-0.2.1 → cloudfall-0.4.0}/examples/render-demo/uv.lock +0 -0
  237. {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/README.md +0 -0
  238. {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/src/cloudfall/__init__.py +0 -0
  239. {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/src/cloudfall/__main__.py +0 -0
  240. {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/src/cloudfall/agent_tools.py +0 -0
  241. {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/src/cloudfall/arguments.py +0 -0
  242. {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/src/cloudfall/audit.py +0 -0
  243. {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/src/cloudfall/authoring.py +0 -0
  244. {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/src/cloudfall/cutover.py +0 -0
  245. {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/src/cloudfall/dashboard.py +0 -0
  246. {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/src/cloudfall/dashboard_server.py +0 -0
  247. {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/src/cloudfall/importer.py +0 -0
  248. {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/src/cloudfall/inventory.py +0 -0
  249. {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/src/cloudfall/lifecycle.py +0 -0
  250. {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/src/cloudfall/mcp_server.py +0 -0
  251. {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/src/cloudfall/migrate.py +0 -0
  252. {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/src/cloudfall/observation.py +0 -0
  253. {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/src/cloudfall/operations.py +0 -0
  254. {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/src/cloudfall/operator.py +0 -0
  255. {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/src/cloudfall/py.typed +0 -0
  256. {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/src/cloudfall/render_api.py +0 -0
  257. {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/src/cloudfall/resources.py +0 -0
  258. {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/src/cloudfall/secrets.py +0 -0
  259. {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/src/cloudfall/service_evidence.py +0 -0
  260. {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/tests/test_arguments.py +0 -0
  261. {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/tests/test_audit.py +0 -0
  262. {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/tests/test_commands.py +0 -0
  263. {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/tests/test_cutover.py +0 -0
  264. {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/tests/test_dashboard_server.py +0 -0
  265. {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/tests/test_data_migration.py +0 -0
  266. {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/tests/test_importer.py +0 -0
  267. {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/tests/test_inventory.py +0 -0
  268. {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/tests/test_lifecycle.py +0 -0
  269. {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/tests/test_migrate.py +0 -0
  270. {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/tests/test_operations_dashboard.py +0 -0
  271. {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/tests/test_operator.py +0 -0
  272. {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/tests/test_render_api.py +0 -0
  273. {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/tests/test_resources.py +0 -0
  274. {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/tests/test_secrets.py +0 -0
  275. {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/tests/test_service_evidence.py +0 -0
  276. {cloudfall-0.2.1 → cloudfall-0.4.0}/sdk/tests/test_validation.py +0 -0
  277. {cloudfall-0.2.1 → cloudfall-0.4.0}/site/public/apple-touch-icon.png +0 -0
  278. {cloudfall-0.2.1 → cloudfall-0.4.0}/site/public/favicon-32.png +0 -0
  279. {cloudfall-0.2.1 → cloudfall-0.4.0}/site/public/favicon.svg +0 -0
  280. {cloudfall-0.2.1 → cloudfall-0.4.0}/site/public/icon-192.png +0 -0
  281. {cloudfall-0.2.1 → cloudfall-0.4.0}/site/public/icon-512.png +0 -0
  282. {cloudfall-0.2.1 → cloudfall-0.4.0}/site/public/index.html +0 -0
  283. {cloudfall-0.2.1 → cloudfall-0.4.0}/site/public/manifesto.html +0 -0
  284. {cloudfall-0.2.1 → cloudfall-0.4.0}/site/public/og.png +0 -0
  285. {cloudfall-0.2.1 → cloudfall-0.4.0}/site/public/site.webmanifest +0 -0
  286. {cloudfall-0.2.1 → cloudfall-0.4.0}/site/src/worker.js +0 -0
  287. {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.2.1
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: the released version when that came
202
- from PyPI, the exact commit when it came from git or a clone. Without a
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 pin and run `uv sync` to move the project to a newer Cloudfall. A
234
- project initialized from a git or source install pins a commit instead, with
235
- a `[tool.uv.sources]` table naming the repository and revision; `--rev` asks
236
- for that explicitly.
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: the released version when that came
176
- from PyPI, the exact commit when it came from git or a clone. Without a
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 pin and run `uv sync` to move the project to a newer Cloudfall. A
208
- project initialized from a git or source install pins a commit instead, with
209
- a `[tool.uv.sources]` table naming the repository and revision; `--rev` asks
210
- for that explicitly.
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 --from git+https://github.com/romamo/cloudfall.git cloudfall init my-project`,
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.2.1"
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))