datahenge-cairn 0.1.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 (213) hide show
  1. datahenge_cairn-0.1.0/.gitignore +34 -0
  2. datahenge_cairn-0.1.0/.ventwig.lock +4 -0
  3. datahenge_cairn-0.1.0/ABOUT_GHCR.md +383 -0
  4. datahenge_cairn-0.1.0/ABOUT_REGISTRIES.md +176 -0
  5. datahenge_cairn-0.1.0/CLAUDE.md +82 -0
  6. datahenge_cairn-0.1.0/High Level Motivations and Workflows.md +225 -0
  7. datahenge_cairn-0.1.0/LICENSE +21 -0
  8. datahenge_cairn-0.1.0/PKG-INFO +301 -0
  9. datahenge_cairn-0.1.0/README.md +266 -0
  10. datahenge_cairn-0.1.0/docs/00-project-scope.md +73 -0
  11. datahenge_cairn-0.1.0/docs/01-decisions-closed.md +1200 -0
  12. datahenge_cairn-0.1.0/docs/02-decisions-open.md +119 -0
  13. datahenge_cairn-0.1.0/docs/03-discussion-log.md +554 -0
  14. datahenge_cairn-0.1.0/docs/04-lessons-learned.md +394 -0
  15. datahenge_cairn-0.1.0/docs/CHANGELOG.md +838 -0
  16. datahenge_cairn-0.1.0/docs/plans/next-steps.md +229 -0
  17. datahenge_cairn-0.1.0/docs/plans/phase-1-build.md +219 -0
  18. datahenge_cairn-0.1.0/docs/requirements/00-overview.md +49 -0
  19. datahenge_cairn-0.1.0/docs/requirements/01-vendoring.md +58 -0
  20. datahenge_cairn-0.1.0/docs/requirements/02-build.md +166 -0
  21. datahenge_cairn-0.1.0/docs/requirements/03-deploy.md +198 -0
  22. datahenge_cairn-0.1.0/docs/requirements/04-data.md +54 -0
  23. datahenge_cairn-0.1.0/docs/requirements/05-config.md +128 -0
  24. datahenge_cairn-0.1.0/docs/requirements/06-cli.md +215 -0
  25. datahenge_cairn-0.1.0/pyproject.toml +92 -0
  26. datahenge_cairn-0.1.0/src/cairn/__init__.py +18 -0
  27. datahenge_cairn-0.1.0/src/cairn/__main__.py +8 -0
  28. datahenge_cairn-0.1.0/src/cairn/adopt.py +459 -0
  29. datahenge_cairn-0.1.0/src/cairn/appsjson.py +73 -0
  30. datahenge_cairn-0.1.0/src/cairn/build.py +407 -0
  31. datahenge_cairn-0.1.0/src/cairn/cli.py +938 -0
  32. datahenge_cairn-0.1.0/src/cairn/config.py +486 -0
  33. datahenge_cairn-0.1.0/src/cairn/descriptor.py +219 -0
  34. datahenge_cairn-0.1.0/src/cairn/doctor.py +340 -0
  35. datahenge_cairn-0.1.0/src/cairn/engine.py +169 -0
  36. datahenge_cairn-0.1.0/src/cairn/environments.py +263 -0
  37. datahenge_cairn-0.1.0/src/cairn/errors.py +96 -0
  38. datahenge_cairn-0.1.0/src/cairn/images.py +533 -0
  39. datahenge_cairn-0.1.0/src/cairn/project.py +63 -0
  40. datahenge_cairn-0.1.0/src/cairn/provision.py +849 -0
  41. datahenge_cairn-0.1.0/src/cairn/prune.py +149 -0
  42. datahenge_cairn-0.1.0/src/cairn/push.py +73 -0
  43. datahenge_cairn-0.1.0/src/cairn/py.typed +0 -0
  44. datahenge_cairn-0.1.0/src/cairn/reconcile.py +458 -0
  45. datahenge_cairn-0.1.0/src/cairn/registry.py +515 -0
  46. datahenge_cairn-0.1.0/src/cairn/resolve.py +229 -0
  47. datahenge_cairn-0.1.0/src/cairn/systemd.py +151 -0
  48. datahenge_cairn-0.1.0/src/cairn/tagging.py +135 -0
  49. datahenge_cairn-0.1.0/src/cairn/timing.py +93 -0
  50. datahenge_cairn-0.1.0/src/cairn/transcript.py +230 -0
  51. datahenge_cairn-0.1.0/src/cairn/vendor.py +297 -0
  52. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/.dockerignore +4 -0
  53. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/.editorconfig +19 -0
  54. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/.github/ISSUE_TEMPLATE/bug_report.md +34 -0
  55. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/.github/ISSUE_TEMPLATE/feature_request.md +23 -0
  56. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/.github/ISSUE_TEMPLATE/question-about-using-frappe_docker.md +12 -0
  57. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/.github/PULL_REQUEST_TEMPLATE.md +7 -0
  58. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/.github/dependabot.yml +26 -0
  59. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/.github/scripts/get_latest_tags.py +88 -0
  60. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/.github/scripts/update_example_env.py +28 -0
  61. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/.github/scripts/update_pwd.py +30 -0
  62. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/.github/workflows/app-build-image.yml +189 -0
  63. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/.github/workflows/build_develop.yml +12 -0
  64. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/.github/workflows/build_stable.yml +12 -0
  65. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/.github/workflows/core-build-bench.yml +59 -0
  66. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/.github/workflows/core-build-develop.yml +51 -0
  67. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/.github/workflows/core-build-stable.yml +148 -0
  68. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/.github/workflows/core-build-test-images.yml +108 -0
  69. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/.github/workflows/core-publish-images.yml +92 -0
  70. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/.github/workflows/docs-publish-site.yml +66 -0
  71. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/.github/workflows/lint.yml +35 -0
  72. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/.github/workflows/pre-commit-autoupdate.yml +26 -0
  73. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/.github/workflows/stale.yml +19 -0
  74. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/.gitignore +34 -0
  75. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/.pre-commit-config.yaml +57 -0
  76. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/.shellcheckrc +1 -0
  77. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/.vscode/extensions.json +9 -0
  78. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/CODE_OF_CONDUCT.md +76 -0
  79. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/CONTRIBUTING.md +146 -0
  80. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/LICENSE +21 -0
  81. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/MAINTAINERS.md +25 -0
  82. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/README.md +117 -0
  83. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/compose.yaml +100 -0
  84. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/devcontainer-example/devcontainer.json +53 -0
  85. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/devcontainer-example/docker-compose.yml +88 -0
  86. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/development/apps-example.json +6 -0
  87. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/development/installer.py +245 -0
  88. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/development/vscode-example/launch.json +77 -0
  89. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/development/vscode-example/settings.json +20 -0
  90. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/development/vscode-example/tasks.json +22 -0
  91. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/docker-bake.hcl +117 -0
  92. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/docs/.vitepress/config.mts +29 -0
  93. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/docs/01-getting-started/00-introduction.md +92 -0
  94. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/docs/01-getting-started/01-choosing-a-deployment-method.md +126 -0
  95. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/docs/01-getting-started/02-docker-immutability.md +52 -0
  96. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/docs/01-getting-started/03-arm64.md +235 -0
  97. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/docs/01-getting-started/04-single-compose-setup.md +42 -0
  98. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/docs/01-getting-started/index.md +3 -0
  99. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/docs/02-setup/01-overview.md +53 -0
  100. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/docs/02-setup/02-build-setup.md +153 -0
  101. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/docs/02-setup/03-start-setup.md +66 -0
  102. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/docs/02-setup/04-env-variables.md +160 -0
  103. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/docs/02-setup/05-overrides.md +35 -0
  104. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/docs/02-setup/06-setup-examples.md +142 -0
  105. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/docs/02-setup/07-single-server-example.md +298 -0
  106. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/docs/02-setup/08-single-server-nginxproxy-example.md +173 -0
  107. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/docs/02-setup/index.md +3 -0
  108. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/docs/03-production/01-tls-ssl-setup.md +57 -0
  109. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/docs/03-production/02-backup-strategy.md +62 -0
  110. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/docs/03-production/03-multi-tenancy.md +73 -0
  111. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/docs/03-production/04-nginx-proxy-acme-companion.md +86 -0
  112. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/docs/03-production/05-caddy-https.md +48 -0
  113. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/docs/03-production/06-automated-builds-and-deployment.md +147 -0
  114. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/docs/03-production/index.md +3 -0
  115. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/docs/04-operations/01-site-operations.md +85 -0
  116. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/docs/04-operations/index.md +3 -0
  117. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/docs/05-development/01-development.md +446 -0
  118. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/docs/05-development/02-debugging.md +20 -0
  119. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/docs/05-development/03-local-services-connection.md +17 -0
  120. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/docs/05-development/04-alternate-setup.md +254 -0
  121. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/docs/05-development/index.md +3 -0
  122. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/docs/06-migration/01-migrate-from-multi-image-setup.md +127 -0
  123. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/docs/06-migration/02-traefik-v3-migration.md +83 -0
  124. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/docs/06-migration/03-postgres-major-version-upgrade.md +49 -0
  125. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/docs/06-migration/index.md +3 -0
  126. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/docs/07-troubleshooting/01-troubleshoot.md +84 -0
  127. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/docs/07-troubleshooting/02-windows-nginx-entrypoint-error.md +16 -0
  128. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/docs/07-troubleshooting/03-arm64-apple-silicon.md +10 -0
  129. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/docs/07-troubleshooting/index.md +3 -0
  130. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/docs/08-reference/01-build-version-10-images.md +20 -0
  131. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/docs/08-reference/02-configuring-vitepress.md +45 -0
  132. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/docs/08-reference/03-fork-management.md +161 -0
  133. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/docs/08-reference/04-framework-comparisons.md +163 -0
  134. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/docs/08-reference/05-external-links.md +18 -0
  135. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/docs/08-reference/06-github-actions-image-workflows.md +312 -0
  136. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/docs/08-reference/07-how-assets-are-handled.md +62 -0
  137. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/docs/08-reference/index.md +3 -0
  138. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/docs/09-concepts/01-custom-app.md +64 -0
  139. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/docs/09-concepts/02-docker-bind-mounts.md +62 -0
  140. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/docs/09-concepts/index.md +3 -0
  141. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/docs/getting-started.md +978 -0
  142. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/docs/images/Docker Desktop Screenshot - Resources section.png +0 -0
  143. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/docs/images/Docker Manual Screenshot - Resources section.png +0 -0
  144. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/docs/index.md +26 -0
  145. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/docs/package.json +19 -0
  146. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/docs/pnpm-lock.yaml +1759 -0
  147. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/docs/public/favicon.png +0 -0
  148. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/docs/public/frappe-docker.png +0 -0
  149. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/example.env +76 -0
  150. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/images/bench/Dockerfile +170 -0
  151. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/images/custom/Containerfile +174 -0
  152. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/images/layered/Containerfile +58 -0
  153. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/images/production/Containerfile +165 -0
  154. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/install_x11_deps.sh +104 -0
  155. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/overrides/compose.backup-cron.yaml +15 -0
  156. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/overrides/compose.custom-domain-ssl.yaml +5 -0
  157. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/overrides/compose.custom-domain.yaml +29 -0
  158. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/overrides/compose.https.yaml +32 -0
  159. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/overrides/compose.mariadb-secrets.yaml +11 -0
  160. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/overrides/compose.mariadb-shared.yaml +30 -0
  161. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/overrides/compose.mariadb.yaml +30 -0
  162. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/overrides/compose.migrator.yaml +44 -0
  163. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/overrides/compose.multi-bench-ssl.yaml +14 -0
  164. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/overrides/compose.multi-bench.yaml +52 -0
  165. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/overrides/compose.nginxproxy-ssl.yaml +28 -0
  166. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/overrides/compose.nginxproxy.yaml +21 -0
  167. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/overrides/compose.noproxy.yaml +4 -0
  168. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/overrides/compose.postgres.yaml +23 -0
  169. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/overrides/compose.proxy.yaml +20 -0
  170. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/overrides/compose.redis.yaml +21 -0
  171. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/overrides/compose.traefik-ssl.yaml +48 -0
  172. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/overrides/compose.traefik.yaml +45 -0
  173. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/pwd.yml +224 -0
  174. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/requirements-test.txt +1 -0
  175. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/resources/core/main-entrypoint.sh +12 -0
  176. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/resources/core/nginx/nginx-entrypoint.sh +52 -0
  177. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/resources/core/nginx/nginx-template.conf +113 -0
  178. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/resources/core/nginx/security_headers.conf +5 -0
  179. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/resources/core/start.sh +20 -0
  180. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/setup.cfg +12 -0
  181. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/tests/__init__.py +0 -0
  182. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/tests/_check_connections.py +52 -0
  183. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/tests/_check_website_theme.py +17 -0
  184. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/tests/_create_bucket.py +19 -0
  185. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/tests/_ping_frappe_connections.py +26 -0
  186. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/tests/compose.ci.yaml +21 -0
  187. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/tests/conftest.py +168 -0
  188. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/tests/test_frappe_docker.py +175 -0
  189. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker/tests/utils.py +99 -0
  190. datahenge_cairn-0.1.0/src/cairn/vendored/frappe_docker.pin.toml +5 -0
  191. datahenge_cairn-0.1.0/tests/test_adopt.py +398 -0
  192. datahenge_cairn-0.1.0/tests/test_appsjson.py +93 -0
  193. datahenge_cairn-0.1.0/tests/test_build.py +520 -0
  194. datahenge_cairn-0.1.0/tests/test_cli.py +1181 -0
  195. datahenge_cairn-0.1.0/tests/test_config.py +474 -0
  196. datahenge_cairn-0.1.0/tests/test_conventions.py +91 -0
  197. datahenge_cairn-0.1.0/tests/test_descriptor.py +164 -0
  198. datahenge_cairn-0.1.0/tests/test_doctor.py +512 -0
  199. datahenge_cairn-0.1.0/tests/test_engine.py +165 -0
  200. datahenge_cairn-0.1.0/tests/test_environments.py +304 -0
  201. datahenge_cairn-0.1.0/tests/test_images.py +394 -0
  202. datahenge_cairn-0.1.0/tests/test_project.py +45 -0
  203. datahenge_cairn-0.1.0/tests/test_provision.py +671 -0
  204. datahenge_cairn-0.1.0/tests/test_prune.py +210 -0
  205. datahenge_cairn-0.1.0/tests/test_push.py +95 -0
  206. datahenge_cairn-0.1.0/tests/test_reconcile.py +383 -0
  207. datahenge_cairn-0.1.0/tests/test_registry.py +479 -0
  208. datahenge_cairn-0.1.0/tests/test_resolve.py +226 -0
  209. datahenge_cairn-0.1.0/tests/test_systemd.py +120 -0
  210. datahenge_cairn-0.1.0/tests/test_tagging.py +212 -0
  211. datahenge_cairn-0.1.0/tests/test_timing.py +96 -0
  212. datahenge_cairn-0.1.0/tests/test_transcript.py +178 -0
  213. datahenge_cairn-0.1.0/tests/test_vendor.py +230 -0
@@ -0,0 +1,34 @@
1
+ # NOTE: src/cairn/vendored/frappe_docker/ is intentionally NOT ignored — it is the
2
+ # ventwig-managed, pinned copy of upstream, committed as plain files (D-007).
3
+
4
+ # Local scratch deployments used to exercise the CLI end-to-end (manifest discovery,
5
+ # build config layering). Real deployments live outside this repo (ADR-029).
6
+ /deployments/
7
+
8
+ # Build/deploy artifacts
9
+ apps.json
10
+ compose.custom.yaml
11
+ *.env
12
+ !example.env
13
+
14
+ # Python
15
+ __pycache__/
16
+ *.py[cod]
17
+ .venv/
18
+ venv/
19
+ *.egg-info/
20
+ dist/
21
+ build/
22
+ .mypy_cache/
23
+ .pytest_cache/
24
+ .ruff_cache/
25
+ .coverage
26
+ .coverage.*
27
+ htmlcov/
28
+
29
+ # OS / editor
30
+ .DS_Store
31
+ *.swp
32
+
33
+ # Claude Code local session/plan state
34
+ .claude/
@@ -0,0 +1,4 @@
1
+ [frappe_docker]
2
+ synced_commit = "d4a310089f5d6fc38ed1317b898d75b9c74901db"
3
+ synced_tree = "682796f14b7b307d1908919e6ff836ae16c7615a"
4
+ synced_at = "2026-07-21T21:18:39Z"
@@ -0,0 +1,383 @@
1
+ # About GHCR — GitHub Container Registry
2
+
3
+ Written for someone who has used GitHub for years but has never pushed a container image to
4
+ it. This explains what you are actually signing into, who ends up owning what, and the handful
5
+ of sharp edges that are genuinely surprising.
6
+
7
+ > ### Read [ABOUT_REGISTRIES.md](ABOUT_REGISTRIES.md) first
8
+ >
9
+ > **GHCR is not cairn's recommended default.** It is one option, and it is the weakest of them
10
+ > on cost: GitHub Packages prices multi-gigabyte artifacts poorly, and an ERPNext image is
11
+ > roughly 2.75 GB with no cheap incremental layer (see §6).
12
+ >
13
+ > This document is most useful for **your own projects**, or for a client already committed to
14
+ > GitHub. For client work generally, the ownership and least-privilege rules in
15
+ > `ABOUT_REGISTRIES.md` come first, and a client-owned cloud registry usually wins.
16
+
17
+ cairn is **registry-agnostic** — nothing here is required. Set `[cairn.registry]` in the
18
+ deployment's `cairn.toml` and the rest of cairn behaves identically against any registry.
19
+
20
+ > **Verify the pricing and token details against GitHub's own documentation before
21
+ > committing money or credentials.** The mechanics below are stable; the numbers and the
22
+ > fine-grained-token story are the parts GitHub changes.
23
+
24
+ ---
25
+
26
+ ## 1. What GHCR actually is
27
+
28
+ `ghcr.io` is a container registry — a web service that stores container images and hands them
29
+ out. It speaks the same standard protocol as Docker Hub, so `podman`, `docker`, and cairn all
30
+ talk to it the same way.
31
+
32
+ It is one component of a larger feature called **GitHub Packages**, which also stores npm,
33
+ NuGet, Maven, and RubyGems artifacts. Those share your account's storage quota with GHCR but
34
+ are otherwise unrelated. When you read GitHub's docs, "Packages" is the umbrella and
35
+ "Container registry" is the part you want.
36
+
37
+ ### "Package" — GitHub's word for a Docker concept
38
+
39
+ GitHub's docs and UI say **package** where you would say *repository*, and **version** where you
40
+ would say *image*. The vocabulary is generic because the same permissions, UI, and API cover npm
41
+ tarballs and Maven jars too. Translated:
42
+
43
+ | Docker / OCI | GHCR calls it | Example |
44
+ | --- | --- | --- |
45
+ | repository | a **package** (of type `container`) | `ghcr.io/acme-corp/erpnext-acme` |
46
+ | image / manifest — one digest | a **package version** | `sha256:1b019793…` |
47
+ | tag | a **tag** on a version | `:production`, `:v16-1b019793dc20` |
48
+
49
+ So a "package" is not vague: for containers it is **exactly one image repository** — the name,
50
+ plus every version and tag beneath it. Two things follow that matter later in this document:
51
+
52
+ - **Permissions are per package, meaning per image repository.** "Write access to the
53
+ `erpnext-acme` package" grants exactly that one repository and nothing else in the account
54
+ (§3).
55
+ - **Deletion operates on a version, not a tag.** That is *why* deleting takes every tag on that
56
+ image with it: `production`, `v16`, and `v16-1b019793dc20` are three tags on one version (§8).
57
+
58
+ One side effect of the shared umbrella: the storage quota covers **all** package types in the
59
+ account, so a client's npm packages and your container images draw from the same allowance.
60
+
61
+ An image lives at a path with three parts:
62
+
63
+ ```
64
+ ghcr.io/datahenge/erpnext-btu-v16:production
65
+ └─┬───┘ └───┬────┘ └──────┬──────┘ └───┬────┘
66
+ registry owner image name tag
67
+ ```
68
+
69
+ - **owner** — a GitHub user or organization. Yours.
70
+ - **image name** — whatever you choose. cairn takes it from `image_name` in `cairn.toml`.
71
+ - **tag** — a movable label. cairn writes several per image; see §7.
72
+
73
+ **Both the owner and the image name must be lowercase.** This is a registry rule, not a
74
+ GitHub one, and it is the first thing that bites people: the organization displayed as
75
+ `Datahenge` is addressed as `datahenge`.
76
+
77
+ ## 2. What you are logging into
78
+
79
+ ```
80
+ podman login ghcr.io
81
+ ```
82
+
83
+ It prompts for a username and a password. What it wants:
84
+
85
+ - **Username** — your GitHub username. Not the organization name, even when you are pushing
86
+ to the organization. You authenticate *as yourself*; authorization is separate.
87
+ - **Password** — **not your GitHub password.** GHCR will not accept it. It wants a
88
+ **Personal Access Token**, which is a long generated string you create in GitHub's
89
+ settings and treat as a password.
90
+
91
+ Use a **classic** personal access token. GitHub has two kinds — "classic" and "fine-grained"
92
+ — and the container registry's support for fine-grained tokens has historically lagged.
93
+ Classic is the documented, reliable path. If you prefer fine-grained, check GitHub's current
94
+ docs first rather than assuming.
95
+
96
+ Create one at **Settings → Developer settings → Personal access tokens → Tokens (classic)**.
97
+ The scopes (checkboxes) that matter:
98
+
99
+ | Scope | Grants | Who needs it |
100
+ | --- | --- | --- |
101
+ | `read:packages` | Pull images | **The VPS** |
102
+ | `write:packages` | Push images (includes read) | **Your build machine** |
103
+ | `delete:packages` | Delete image versions | Nobody, ideally — see §8 |
104
+
105
+ If a package is linked to a **private** repository, a classic token may also need the `repo`
106
+ scope to read it. If a pull fails with a permission error despite `read:packages`, that is the
107
+ first thing to try.
108
+
109
+ **Make two separate tokens.** The build machine gets `write:packages`; the VPS gets
110
+ `read:packages` and nothing else. This is not ceremony — it is the entire mechanism by which
111
+ a compromised VPS cannot overwrite your production image. cairn's design leans on it: the
112
+ roles are separated by *credentials*, not by shipping different code to each machine.
113
+
114
+ Give the tokens an expiry you will actually notice, and put them somewhere you can find them
115
+ again. GitHub shows a token exactly once.
116
+
117
+ ## 3. How this relates to your GitHub account and repos
118
+
119
+ This is the part that surprises people, so plainly:
120
+
121
+ **A package is owned by an account, not by a repository.** When you push
122
+ `ghcr.io/datahenge/erpnext-btu-v16`, GHCR creates a *package* belonging to the `datahenge`
123
+ organization. It exists whether or not any repository is involved. It did not come from a
124
+ repo and it is not inside one.
125
+
126
+ You can optionally **link** a package to a repository afterwards. Linking:
127
+
128
+ - makes the package appear in that repo's sidebar, so people find it;
129
+ - lets GitHub Actions *in that repo* push to it using the automatic `GITHUB_TOKEN`, with no
130
+ personal token at all;
131
+ - can make the package inherit the repo's access permissions, so your collaborators get
132
+ access without being granted it package-by-package.
133
+
134
+ Two ways to link. Manually, in the package's settings page. Or automatically, by stamping the
135
+ image with a label naming the repository:
136
+
137
+ ```
138
+ org.opencontainers.image.source = https://github.com/Datahenge/cairn
139
+ ```
140
+
141
+ GHCR reads that label on push and links the package for you. **cairn does not currently stamp
142
+ this label** — it stamps creation time, title, version, and revision, but not source. That is
143
+ a deliberate gap rather than an oversight: adding it means deciding *which* repository an
144
+ image points at (the deployment's, or the tool's), which is a design question and not a
145
+ detail. Until it is decided, link manually if you want the linkage.
146
+
147
+ **Repos you merely participate in are irrelevant here.** Being a contributor to someone
148
+ else's project grants you nothing on their packages, and your packages are invisible to them
149
+ unless you make them public or grant access. Package permissions are their own system.
150
+
151
+ ### How narrow can access be? Narrower than it looks
152
+
153
+ The question that matters if you are pushing into a *client's* organization: does write access
154
+ mean you can overwrite all hundred of their packages? **No.**
155
+
156
+ - `write:packages` on a token is a **ceiling on what the token may attempt**, not a grant of
157
+ what you may touch. Authorization is resolved per package, every time.
158
+ - Each package has its own access list with **Read / Write / Admin** roles, granted to a user
159
+ or a team, **per package**.
160
+ - A package **linked to a repository inherits that repository's permissions**. So the per-repo
161
+ model you are used to applies to images: link the ERPNext image to the repo you already have
162
+ write on, and your image access is exactly your repo access — nothing more.
163
+ - **Plain org membership grants nothing** on existing private packages. They are invisible.
164
+ - **A typo cannot clobber anything.** Pushing to a misspelled name either creates a *new*
165
+ package, or is denied if that name exists and you lack Write on it. There is no path where a
166
+ mistyped push overwrites a package you were never granted.
167
+
168
+ The configuration that *would* be dangerous is being made an **organization owner**, or being
169
+ put in a team with admin over all packages. Do not ask for that, and decline it if offered —
170
+ see rule 2 in `ABOUT_REGISTRIES.md`. Ask for write on one package, or on the one repository it
171
+ is linked to.
172
+
173
+ ## 4. Who owns the images after deployment
174
+
175
+ The GitHub account that pushed them — in your case the `datahenge` organization. Pulling an
176
+ image to a VPS does not transfer anything; the VPS holds a *copy*, and the registry keeps the
177
+ original.
178
+
179
+ The practical consequences are worth stating because they are the kind of thing discovered at
180
+ a bad moment:
181
+
182
+ - **If the package is deleted, targets can no longer pull it.** Already-running containers
183
+ keep running — they use the local copy — but a fresh host, or one whose local image has been
184
+ pruned, cannot deploy. cairn's rollback model depends on old images still being in the
185
+ registry.
186
+ - **If the organization is deleted or renamed, every image path changes.** The registry path
187
+ contains the owner name.
188
+ - **If you pushed to your own namespace, your client does not own their image.** An image
189
+ sitting in *your* organization is a dependency they have on you: end the relationship badly
190
+ and they cannot deploy or roll back software they own. This is a rule, not a caveat —
191
+ `ABOUT_REGISTRIES.md` rule 1, and a requirement in `docs/requirements/05-config.md`. Push to
192
+ an account **they** own: set `[cairn.registry]` in their deployment's `cairn.toml` and
193
+ nothing else about cairn changes.
194
+ - **Anyone with `read:packages` on a private package can pull the whole image**, which
195
+ contains your application code. Treat pull tokens as code access, because that is what they
196
+ are.
197
+
198
+ ## 5. Visibility: private by default
199
+
200
+ A newly pushed package is **private**. Nobody but you (and accounts you grant) can pull it.
201
+
202
+ You can change a package to **public** in its settings, and then *anyone on the internet* can
203
+ pull it anonymously, with no token. For a proprietary ERPNext build that is almost certainly
204
+ wrong — the image contains your custom app source.
205
+
206
+ Two facts about public that are easy to miss:
207
+
208
+ - Public packages are **free**, in both storage and bandwidth. Private ones are not. See §6.
209
+ - A public image version that has been downloaded more than a few thousand times **cannot be
210
+ deleted at all.** See §8.
211
+
212
+ ## 6. What it costs — read this before you push several images
213
+
214
+ GHCR is free for public packages. For **private** packages, storage and outbound data
215
+ transfer count against your plan's GitHub Packages allowance, and you are billed per gigabyte
216
+ beyond it.
217
+
218
+ **This matters more for ERPNext than for most projects, because the images are large.** A
219
+ custom Frappe + ERPNext image is roughly **2.75 GB**. The allowances included with the
220
+ personal and small-team plans are on the order of a couple of gigabytes of storage and ten
221
+ gigabytes of monthly transfer — meaning **one single image can exceed your entire included
222
+ storage**, and one deployment pull can consume a meaningful share of the monthly transfer.
223
+
224
+ Do the arithmetic for your own situation, with current prices from GitHub's billing docs, on
225
+ these three numbers:
226
+
227
+ 1. **Image size** × **how many versions you keep** = storage. Rollback headroom is not free;
228
+ keeping five versions of a 2.75 GB image is about 14 GB.
229
+ 2. **Image size** × **deploys per month** = outbound transfer. Every `cairn reconcile` that
230
+ actually converges pulls an image.
231
+ 3. Transfer *into* GitHub Actions is free. Transfer to your VPS is not.
232
+
233
+ Two things that make this better or worse than it looks:
234
+
235
+ - **Better:** layers are shared. Two images differing only in an upper layer store the common
236
+ layers once, and a pull only fetches what the host lacks.
237
+ - **Worse, and specific to this stack:** the upstream build recipe installs Frappe and every
238
+ app in a *single* step, so changing one line in one custom app rebuilds that entire step.
239
+ The result is a new multi-gigabyte layer rather than a small one — so in practice a
240
+ custom-app change costs close to a full image in both storage and transfer. This is a known
241
+ characteristic of the upstream recipe, recorded in cairn's design notes as one of the
242
+ standing arguments for eventually maintaining our own build recipe. It now has a cost in
243
+ money as well as in build minutes.
244
+
245
+ If the bill turns out to be the deciding factor, the alternatives are a registry with cheaper
246
+ egress, or one you host yourself. cairn does not care which you choose.
247
+
248
+ ## 7. What cairn puts in the registry
249
+
250
+ Per build, cairn writes **two** tags pointing at the same image:
251
+
252
+ - a **deterministic** tag like `v16-1b019793dc20`, where the trailing hash is derived from
253
+ every resolved input — the Frappe commit, each app's commit, and the build settings. The
254
+ same inputs always produce the same name.
255
+ - a **moving** tag like `v16`, which is repointed to the newest build.
256
+
257
+ Then, per environment, you point an **environment tag** at a chosen image:
258
+
259
+ ```
260
+ cairn new-tag production --latest
261
+ ```
262
+
263
+ `production` is now a third name for the same underlying image, and it is the name your VPS
264
+ watches. Moving that tag is what deploying, promoting, and rolling back all are — no rebuild,
265
+ no upload, just a new name written server-side.
266
+
267
+ So a single image commonly carries three or more tags at once, and `cairn images` folds them
268
+ together and reports them as one image, because that is what they are.
269
+
270
+ One thing to be clear about, because the word invites the wrong inference: cairn's
271
+ deterministic tag is **deterministic, not immutable.** Same inputs → same name. The name is
272
+ still a pointer, and a registry will happily move it. The thing that never changes is the
273
+ **digest** — the `sha256:…` value. That is the real identity of an image, and it is what
274
+ cairn's deploy machinery actually compares.
275
+
276
+ ## 8. Deleting images — the genuinely sharp edge
277
+
278
+ GHCR's deletion model does not work the way you would guess, and cairn is shaped around it.
279
+
280
+ **There is no way to delete a single tag.** Deletion operates on a *version* — one underlying
281
+ image — and deleting a version removes **every tag pointing at it, and the image itself.**
282
+
283
+ Read that against §7. If `v16-1b019793dc20`, `v16`, and `production` all point at one image,
284
+ then "deleting the `v16` tag" is not an operation GHCR offers. Attempting to clean up a tag
285
+ name destroys the image your production environment is running.
286
+
287
+ Also: **a public version with more than a few thousand downloads cannot be deleted at all**,
288
+ by anyone, including you.
289
+
290
+ This is why `cairn retire <env>` deletes nothing. It tells you what to remove from your
291
+ manifest, and warns you that the registry tag will still exist and still resolve. And it is
292
+ why `cairn prune` operates only on your **build machine's** local images and never on the
293
+ registry. Registry-side cleanup is deliberately left as a manual, deliberate act.
294
+
295
+ If you do need to reclaim registry space, do it by hand in the package's **Manage versions**
296
+ page, deliberately, having first checked with `cairn images` that no environment tag points at
297
+ the version you are about to destroy.
298
+
299
+ ## 9. First-time setup, start to finish
300
+
301
+ **On your build machine, once:**
302
+
303
+ 1. Create a classic token with `write:packages`.
304
+ 2. `podman login ghcr.io` — your GitHub username, then the token as the password.
305
+ 3. Verify with a read: `cairn images`. It will report an empty repository rather than a
306
+ permission error.
307
+
308
+ The credential is stored by podman, not by cairn — normally in
309
+ `${XDG_RUNTIME_DIR}/containers/auth.json`. cairn only ever *reads* it. Note that
310
+ `XDG_RUNTIME_DIR` is on a tmpfs that is cleared at reboot, so a login there does not survive
311
+ one. If you want it to persist, `podman login` writes to `~/.config/containers/auth.json`
312
+ when the runtime directory is unavailable, or you can point it there explicitly.
313
+
314
+ **Then build and publish:**
315
+
316
+ ```
317
+ cairn build --push
318
+ cairn images # confirm it arrived
319
+ ```
320
+
321
+ **On the VPS, once:**
322
+
323
+ 1. Create a *second* classic token with only `read:packages`.
324
+ 2. `docker login ghcr.io` with that one.
325
+ 3. Nothing else. cairn on the target reads that credential through Docker and stores nothing
326
+ of its own.
327
+
328
+ **Then set visibility.** Go to the package's settings and confirm it is private. It will be,
329
+ but confirm it, because the consequence of being wrong is publishing your client's source
330
+ code.
331
+
332
+ ## 10. Errors you will actually hit
333
+
334
+ **`the registry would not issue a read token (requested access to the resource is denied)`**
335
+
336
+ GHCR answers "you are not logged in", "this repository does not exist", and "this is private
337
+ and you cannot read it" with the *same* response, deliberately, so that guessing cannot reveal
338
+ which repositories exist. cairn's message lists all three causes because it genuinely cannot
339
+ tell them apart. Check them in that order — and check the spelling of the namespace, since a
340
+ typo is indistinguishable from a permissions problem.
341
+
342
+ **`unauthorized` / `denied` on push**
343
+
344
+ Your token has `read:packages` but not `write:packages`, or you are logged in as a different
345
+ account than you think. `podman login ghcr.io` again.
346
+
347
+ **`name unknown` or a 404 on a path you are sure exists**
348
+
349
+ Almost always capitalization. `ghcr.io/Datahenge/...` is not `ghcr.io/datahenge/...`.
350
+
351
+ **A pull fails on the VPS but works on your laptop**
352
+
353
+ The VPS's token is missing `read:packages`, or the package is linked to a private repository
354
+ and the token also needs `repo`, or the login was done as `root` and the pull is running as a
355
+ different user — Docker credentials are per-user.
356
+
357
+ ## 11. What cairn does and does not do with your credentials
358
+
359
+ Stated plainly, because it is a reasonable thing to want to know about a tool you point at a
360
+ production registry:
361
+
362
+ - cairn **never** asks you for a password, stores one, writes one to disk, or puts one in a
363
+ log or a build transcript.
364
+ - cairn **reads** the credential file your `podman login` or `docker login` already created,
365
+ uses it for the duration of one command, and forgets it.
366
+ - cairn tries **unauthenticated** access first. For a public repository it never opens your
367
+ credential file at all.
368
+ - The build config that names your registry and namespace holds **no secrets** — just the
369
+ hostname and the owner — which is why it is safe to keep beside a deployment.
370
+ - On a target, the environment descriptor also holds no secrets. It names the *mechanism*
371
+ holding them and nothing more.
372
+
373
+ ---
374
+
375
+ ## Further reading
376
+
377
+ - GitHub's own docs: *Working with the Container registry*, and *About billing for GitHub
378
+ Packages* — the authoritative source for tokens and pricing, both of which change.
379
+ - [`docs/requirements/03-deploy.md`](docs/requirements/03-deploy.md) — how cairn uses the
380
+ registry as the desired-state pointer, and the deletion constraints above stated as
381
+ requirements.
382
+ - [`docs/requirements/05-config.md`](docs/requirements/05-config.md) — where registry settings
383
+ live, and why credentials are the container engine's job and never cairn's.
@@ -0,0 +1,176 @@
1
+ # Choosing a container registry
2
+
3
+ cairn builds an image and puts it somewhere; your servers pull it from there. That "somewhere"
4
+ is a **container registry**, and cairn works with any of them — it stores no credentials, hardwires
5
+ no hostname, and has no preference.
6
+
7
+ This document exists because *which* registry you pick is not a neutral technical choice when you
8
+ build software for clients. Two of the three rules below are about liability, not engineering.
9
+
10
+ ---
11
+
12
+ ## The three rules
13
+
14
+ ### 1. The image belongs in the account that owns the source
15
+
16
+ You already do this with code: the client's GitHub holds their app, and you have been granted
17
+ access to read it. **The image is a build artifact of that code and inherits the same ownership.**
18
+
19
+ The failure this prevents is specific. If you are the sole owner of a client's built image, then
20
+ the day the relationship ends badly, that client cannot deploy or roll back software **they own**.
21
+ Their operations depend on your goodwill. That is not a position to be in, in either direction —
22
+ it is bad for them, and it is an obligation you did not agree to carry.
23
+
24
+ So: the registry account is theirs. You are granted access to it. When the engagement ends, they
25
+ revoke your access and lose nothing.
26
+
27
+ Your own projects are the obvious exception. Your registry, your account, your bill.
28
+
29
+ ### 2. Your credential should reach the engagement's images and nothing else
30
+
31
+ Ask for write access to **the ERPNext image, or the repository holding it** — not to the client's
32
+ registry as a whole.
33
+
34
+ The reason is not that you might act badly. It is that **you will eventually make a mistake**, and
35
+ the size of the mistake should be bounded by the credential. A token that can write exactly one
36
+ repository cannot destroy a client's other images no matter what you type. This protects you more
37
+ than it protects them: it means a bad afternoon cannot become a liability event.
38
+
39
+ A corollary worth stating: **do not accept broad access when it is offered.** A client who makes
40
+ you an administrator of their whole registry to "save time" has handed you a risk, not a
41
+ convenience. Ask to be narrowed.
42
+
43
+ ### 3. Nobody's credentials live in cairn
44
+
45
+ cairn never asks for, stores, writes, or logs a password or token. You run `podman login` or
46
+ `docker login`; the container engine keeps the credential; cairn reads it when it needs to and
47
+ forgets it. What cairn stores is a hostname and an account name, neither of which is a secret.
48
+
49
+ ---
50
+
51
+ ## What cairn needs from you
52
+
53
+ Two values, in the deployment's `cairn.toml`, committed:
54
+
55
+ ```toml
56
+ [cairn.registry]
57
+ host = "ghcr.io"
58
+ namespace = "acme-corp" # the client's account — not yours
59
+ ```
60
+
61
+ These are committed deliberately, so the client can take the deployment over and keep publishing
62
+ to their own registry without needing anything from your laptop. Machine-specific settings —
63
+ which container engine you build with, where transcripts go — stay in your own config and are
64
+ never committed.
65
+
66
+ Absent a `[cairn.registry]`, images stay local and cairn never guesses a registry.
67
+
68
+ To publish somewhere else temporarily without editing a client's file, a `cairn.local.toml` beside
69
+ the manifest overrides it.
70
+
71
+ ---
72
+
73
+ ## The options
74
+
75
+ Ordered by how well they satisfy rules 1 and 2. **Verify current pricing before committing** — it
76
+ changes, and the numbers below are indicative.
77
+
78
+ ### Client-owned cloud registry — the default recommendation
79
+
80
+ AWS **ECR**, Google **Artifact Registry**, Azure **ACR**.
81
+
82
+ - **Ownership** — unambiguous. It is inside the client's cloud account, alongside everything else
83
+ of theirs.
84
+ - **Least privilege** — the strongest available. ECR scopes IAM policies to an individual
85
+ repository ARN; Artifact Registry grants a writer role on a single repository; ACR offers
86
+ repository-scoped tokens. You can hold a credential that can write `erpnext-acme` and literally
87
+ nothing else.
88
+ - **Cost** — the best fit by a wide margin at ERPNext image sizes. Storage is flat per GB with no
89
+ small included cap to blow through, and **egress is often free or trivial when the VPS is in the
90
+ same cloud and region** — which is the single biggest cost lever available, since every deploy
91
+ pulls a multi-gigabyte image.
92
+ - **Credential handling** — one login per registry hostname. Because the hostname *is* the
93
+ identity, there is no ambiguity about which client you are pushing to and no browser session
94
+ involved.
95
+ - **Cost to you** — the most setup. Each client needs a cloud account and someone to create the
96
+ repository and the scoped credential.
97
+
98
+ **Pick this when** the client already has a cloud account, or their VPS is already in one.
99
+
100
+ ### Client-owned GitHub organization
101
+
102
+ - **Ownership** — good. The package belongs to their org.
103
+ - **Least privilege** — good, and better than it first appears: packages carry their own
104
+ Read/Write/Admin access list, and a package **linked to a repository inherits that repository's
105
+ permissions** — so the familiar per-repo model applies to images too. Link the image to the
106
+ ERPNext repo you already have write on, and your image access is exactly your repo access.
107
+ Plain org membership grants nothing on existing private packages, and a mistyped push either
108
+ creates a new package or is denied; it cannot overwrite one you were never granted. **Do not
109
+ accept org ownership** — that is the configuration that would make access effectively
110
+ boundless.
111
+ - **Credential handling** — the simplest of any option. **One** GitHub account and one token, for
112
+ every client. Authorization resolves server-side per organization. No second login, ever.
113
+ - **Cost** — **the weak point, and the reason this is not the default.** GitHub Packages prices
114
+ multi-gigabyte artifacts poorly: a small included allowance, then per-GB storage at roughly
115
+ 2.5× a purpose-built registry, plus per-GB egress on every pull to a VPS. It is made worse by
116
+ the upstream build recipe having no per-app layer seam, so **every build is a fresh full-size
117
+ layer** rather than a cheap delta — layer sharing saves you almost nothing, and each retained
118
+ rollback version costs close to a whole image.
119
+ - See **[ABOUT_GHCR.md](ABOUT_GHCR.md)** for the mechanics: tokens, scopes, visibility, and the
120
+ deletion rule that is genuinely surprising.
121
+
122
+ **Pick this when** the client is already on GitHub, images are small or few, or the convenience of
123
+ one credential outweighs the bill.
124
+
125
+ ### Registry on the client's own VPS
126
+
127
+ Run a registry container on the deploy target itself.
128
+
129
+ - **Ownership** — their hardware, their disk. Unambiguous.
130
+ - **Least privilege** — you have access to their server anyway, so this adds no new exposure.
131
+ - **Cost** — no registry bill at all, and pulls never leave the machine, so they are instant and
132
+ free. Attractive given cairn targets a single VPS.
133
+ - **The real cost** — your **rollback history shares a failure domain with the thing you would
134
+ roll back.** Lose the host and you lose every stored image with it. It also puts disk pressure
135
+ on the server that is running production.
136
+
137
+ **Pick this when** cost dominates and off-host rollback history is genuinely not needed — or
138
+ alongside another registry, not instead of one.
139
+
140
+ ### Your own namespace
141
+
142
+ - Correct for **your own projects** and nothing else.
143
+ - For client work it fails rule 1 outright.
144
+ - It is defensible in exactly one business model, which is not cairn's assumption: if you were a
145
+ SaaS host and clients logged into *your* hosted ERPNext, the images would legitimately be yours
146
+ — while their source stayed in their own accounts with you granted read.
147
+
148
+ ---
149
+
150
+ ## What to ask a client for
151
+
152
+ Concretely, so the request is small and easy to grant:
153
+
154
+ > I need somewhere to publish the ERPNext container image I build for you. It should live in an
155
+ > account **you** own, so you are never dependent on me to deploy or roll back your own software.
156
+ >
157
+ > Please create one container repository named `erpnext-<yourname>` and grant me **write access to
158
+ > that repository only** — not to your registry as a whole. If anything goes wrong on my end, I
159
+ > want the damage to be limited to this one repository.
160
+ >
161
+ > Your servers will also need a **read-only** credential to pull it.
162
+
163
+ That last line matters: the deploy target gets a **pull-only** credential. It is what makes a
164
+ compromised server unable to overwrite the image it runs, and it is the same separation on every
165
+ registry.
166
+
167
+ ---
168
+
169
+ ## Further reading
170
+
171
+ - **[ABOUT_GHCR.md](ABOUT_GHCR.md)** — GitHub's registry in detail, for your own projects or a
172
+ client already on GitHub.
173
+ - [`docs/requirements/05-config.md`](docs/requirements/05-config.md) — the rules above stated as
174
+ requirements, including why the registry coordinates are committed with the deployment.
175
+ - [`docs/requirements/03-deploy.md`](docs/requirements/03-deploy.md) — how cairn uses a registry
176
+ tag as the desired-state pointer your servers converge to.