cloudfall 0.3.0__tar.gz → 0.4.0__tar.gz

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