splent-cli 1.28.1__tar.gz → 1.29.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 (201) hide show
  1. {splent_cli-1.28.1/src/splent_cli.egg-info → splent_cli-1.29.0}/PKG-INFO +1 -1
  2. {splent_cli-1.28.1 → splent_cli-1.29.0}/pyproject.toml +1 -1
  3. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/product/product_build.py +94 -9
  4. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/product/product_create.py +12 -1
  5. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/product/product_deploy.py +238 -53
  6. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/product/product_derive.py +10 -2
  7. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/product/product_env.py +26 -5
  8. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/product/product_release.py +83 -47
  9. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/product/product_sync_template.py +11 -1
  10. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/release/release_resume.py +16 -9
  11. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/tokens_setup.py +4 -1
  12. splent_cli-1.29.0/src/splent_cli/services/image_registry.py +173 -0
  13. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/services/registry.py +100 -0
  14. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/services/release.py +10 -6
  15. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/services/release_gate.py +107 -18
  16. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/utils/git_url.py +21 -3
  17. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/utils/template_drift.py +25 -1
  18. {splent_cli-1.28.1 → splent_cli-1.29.0/src/splent_cli.egg-info}/PKG-INFO +1 -1
  19. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli.egg-info/SOURCES.txt +1 -0
  20. {splent_cli-1.28.1 → splent_cli-1.29.0}/LICENSE +0 -0
  21. {splent_cli-1.28.1 → splent_cli-1.29.0}/README.md +0 -0
  22. {splent_cli-1.28.1 → splent_cli-1.29.0}/setup.cfg +0 -0
  23. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/__init__.py +0 -0
  24. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/__main__.py +0 -0
  25. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/cli.py +0 -0
  26. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/__init__.py +0 -0
  27. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/cache/__init__.py +0 -0
  28. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/cache/cache_clear.py +0 -0
  29. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/cache/cache_orphans.py +0 -0
  30. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/cache/cache_outdated.py +0 -0
  31. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/cache/cache_prune.py +0 -0
  32. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/cache/cache_size.py +0 -0
  33. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/cache/cache_status.py +0 -0
  34. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/cache/cache_usage.py +0 -0
  35. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/cache/cache_versions.py +0 -0
  36. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/check/__init__.py +0 -0
  37. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/check/check_contracts.py +0 -0
  38. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/check/check_deps.py +0 -0
  39. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/check/check_docker.py +0 -0
  40. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/check/check_env.py +0 -0
  41. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/check/check_features.py +0 -0
  42. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/check/check_github.py +0 -0
  43. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/check/check_infra.py +0 -0
  44. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/check/check_product.py +0 -0
  45. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/check/check_pypi.py +0 -0
  46. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/check/check_pyproject.py +0 -0
  47. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/check/check_releases.py +0 -0
  48. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/clear/__init__.py +0 -0
  49. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/clear/clear_build.py +0 -0
  50. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/clear/clear_log.py +0 -0
  51. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/clear/clear_uploads.py +0 -0
  52. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/command/__init__.py +0 -0
  53. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/command/command_create.py +0 -0
  54. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/coverage.py +0 -0
  55. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/database/db_console.py +0 -0
  56. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/database/db_dump.py +0 -0
  57. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/database/db_migrate.py +0 -0
  58. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/database/db_reset.py +0 -0
  59. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/database/db_restore.py +0 -0
  60. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/database/db_rollback.py +0 -0
  61. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/database/db_seed.py +0 -0
  62. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/database/db_status.py +0 -0
  63. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/database/db_upgrade.py +0 -0
  64. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/doctor.py +0 -0
  65. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/env/env_list.py +0 -0
  66. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/env/env_set.py +0 -0
  67. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/env/env_show.py +0 -0
  68. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/export/__init__.py +0 -0
  69. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/export/export_puml.py +0 -0
  70. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/feature/feature_add.py +0 -0
  71. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/feature/feature_attach.py +0 -0
  72. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/feature/feature_clean.py +0 -0
  73. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/feature/feature_clone.py +0 -0
  74. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/feature/feature_compat.py +0 -0
  75. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/feature/feature_compile.py +0 -0
  76. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/feature/feature_contract.py +0 -0
  77. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/feature/feature_create.py +0 -0
  78. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/feature/feature_delete.py +0 -0
  79. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/feature/feature_detach.py +0 -0
  80. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/feature/feature_discard.py +0 -0
  81. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/feature/feature_drift.py +0 -0
  82. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/feature/feature_env.py +0 -0
  83. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/feature/feature_fork.py +0 -0
  84. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/feature/feature_git.py +0 -0
  85. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/feature/feature_hook_add.py +0 -0
  86. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/feature/feature_hook_remove.py +0 -0
  87. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/feature/feature_hooks.py +0 -0
  88. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/feature/feature_impact.py +0 -0
  89. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/feature/feature_info.py +0 -0
  90. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/feature/feature_inject_config.py +0 -0
  91. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/feature/feature_install.py +0 -0
  92. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/feature/feature_list.py +0 -0
  93. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/feature/feature_order.py +0 -0
  94. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/feature/feature_outdated.py +0 -0
  95. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/feature/feature_pin.py +0 -0
  96. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/feature/feature_pip_install.py +0 -0
  97. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/feature/feature_pull.py +0 -0
  98. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/feature/feature_refine.py +0 -0
  99. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/feature/feature_release.py +0 -0
  100. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/feature/feature_remove.py +0 -0
  101. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/feature/feature_rename.py +0 -0
  102. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/feature/feature_search.py +0 -0
  103. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/feature/feature_status.py +0 -0
  104. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/feature/feature_sync_template.py +0 -0
  105. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/feature/feature_test.py +0 -0
  106. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/feature/feature_translate.py +0 -0
  107. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/feature/feature_unlock.py +0 -0
  108. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/feature/feature_upgrade.py +0 -0
  109. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/feature/feature_versions.py +0 -0
  110. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/feature/feature_xray.py +0 -0
  111. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/lint.py +0 -0
  112. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/locust.py +0 -0
  113. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/marketplace/login.py +0 -0
  114. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/marketplace/logout.py +0 -0
  115. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/marketplace/marketplace_auth_utils.py +0 -0
  116. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/marketplace/marketplace_index.py +0 -0
  117. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/marketplace/whoami.py +0 -0
  118. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/product/__init__.py +0 -0
  119. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/product/product_auto_require.py +0 -0
  120. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/product/product_clean.py +0 -0
  121. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/product/product_commands.py +0 -0
  122. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/product/product_config.py +0 -0
  123. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/product/product_configure.py +0 -0
  124. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/product/product_console.py +0 -0
  125. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/product/product_containers.py +0 -0
  126. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/product/product_deselect.py +0 -0
  127. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/product/product_down.py +0 -0
  128. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/product/product_drift.py +0 -0
  129. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/product/product_list.py +0 -0
  130. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/product/product_logs.py +0 -0
  131. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/product/product_missing.py +0 -0
  132. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/product/product_port.py +0 -0
  133. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/product/product_resolve.py +0 -0
  134. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/product/product_restart.py +0 -0
  135. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/product/product_routes.py +0 -0
  136. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/product/product_run.py +0 -0
  137. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/product/product_select.py +0 -0
  138. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/product/product_shell.py +0 -0
  139. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/product/product_signals.py +0 -0
  140. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/product/product_test.py +0 -0
  141. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/product/product_up.py +0 -0
  142. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/product/product_validate.py +0 -0
  143. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/release/__init__.py +0 -0
  144. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/release/release_core.py +0 -0
  145. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/selenium.py +0 -0
  146. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/spl/__init__.py +0 -0
  147. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/spl/spl_add_constraints.py +0 -0
  148. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/spl/spl_add_feature.py +0 -0
  149. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/spl/spl_compare.py +0 -0
  150. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/spl/spl_configurations.py +0 -0
  151. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/spl/spl_create.py +0 -0
  152. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/spl/spl_deps.py +0 -0
  153. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/spl/spl_features.py +0 -0
  154. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/spl/spl_fetch.py +0 -0
  155. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/spl/spl_info.py +0 -0
  156. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/spl/spl_list.py +0 -0
  157. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/spl/spl_outdated.py +0 -0
  158. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/spl/spl_pin.py +0 -0
  159. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/spl/spl_publish.py +0 -0
  160. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/spl/spl_remove_feature.py +0 -0
  161. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/spl/spl_utils.py +0 -0
  162. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/uvl/uvl_utils.py +0 -0
  163. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/commands/version.py +0 -0
  164. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/services/__init__.py +0 -0
  165. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/services/compose.py +0 -0
  166. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/services/context.py +0 -0
  167. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/services/credentials.py +0 -0
  168. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/services/index_refresh.py +0 -0
  169. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/services/marketplace.py +0 -0
  170. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/services/marketplace_api.py +0 -0
  171. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/services/marketplace_url.py +0 -0
  172. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/services/preflight.py +0 -0
  173. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/services/pypi_quota.py +0 -0
  174. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/services/release_dry_run.py +0 -0
  175. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/services/spl_store.py +0 -0
  176. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/utils/__init__.py +0 -0
  177. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/utils/archetype.py +0 -0
  178. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/utils/cache_utils.py +0 -0
  179. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/utils/command_loader.py +0 -0
  180. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/utils/contract_freshness.py +0 -0
  181. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/utils/db_client.py +0 -0
  182. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/utils/db_utils.py +0 -0
  183. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/utils/decorators.py +0 -0
  184. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/utils/dynamic_imports.py +0 -0
  185. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/utils/feature_installer.py +0 -0
  186. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/utils/feature_utils.py +0 -0
  187. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/utils/forge.py +0 -0
  188. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/utils/integrity.py +0 -0
  189. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/utils/io_utils.py +0 -0
  190. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/utils/lifecycle.py +0 -0
  191. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/utils/manifest.py +0 -0
  192. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/utils/path_utils.py +0 -0
  193. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/utils/proc.py +0 -0
  194. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/utils/redact.py +0 -0
  195. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/utils/refinement_facts.py +0 -0
  196. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/utils/toml_edit.py +0 -0
  197. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli/utils/upgrade_plan.py +0 -0
  198. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli.egg-info/dependency_links.txt +0 -0
  199. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli.egg-info/entry_points.txt +0 -0
  200. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli.egg-info/requires.txt +0 -0
  201. {splent_cli-1.28.1 → splent_cli-1.29.0}/src/splent_cli.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: splent_cli
3
- Version: 1.28.1
3
+ Version: 1.29.0
4
4
  Summary: SPLENT-CLI is a CLI to be able to work on your development more easily.
5
5
  Author-email: DiversoLab <diversolab@us.es>
6
6
  Project-URL: Homepage, https://github.com/diverso-lab/splent_cli
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "splent_cli"
7
- version = "1.28.1"
7
+ version = "1.29.0"
8
8
  description = "SPLENT-CLI is a CLI to be able to work on your development more easily."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.13"
@@ -88,6 +88,16 @@ OPTIONAL_DEPLOY_SETTINGS: tuple[tuple[str, str, tuple[str, ...]], ...] = (
88
88
  "trusted proxy. true forces it on every cookie, false never sets it.",
89
89
  ),
90
90
  ),
91
+ (
92
+ "SPLENT_IMAGE",
93
+ "git.splent.io/<org>/<product>:stable",
94
+ (
95
+ "Image the web service runs. Unset means the image built on this",
96
+ "host. Set it to the image a release publishes, tagged stable, to",
97
+ "run releases without building here. Every deploy pulls it, and the",
98
+ "autodeploy feature follows it.",
99
+ ),
100
+ ),
91
101
  (
92
102
  "MAX_CONTENT_LENGTH",
93
103
  "64M",
@@ -551,9 +561,34 @@ def _apply_restart_policy(svc_def: dict) -> None:
551
561
  svc_def["restart"] = RESTART_POLICY
552
562
 
553
563
 
564
+ def resolve_product_placeholders(value: str, product: str, *, prod: bool) -> str:
565
+ """Resolve ``__PRODUCT__`` in a feature's value for this product.
566
+
567
+ ``__PRODUCT___web`` is the product's web container, which is
568
+ ``<product>_web`` in development and ``<product>_web_deploy`` in
569
+ production (the prod compose template names it). Every other
570
+ ``__PRODUCT__`` is the product name. product:build and
571
+ product:env --merge --prod both write .env.deploy, so both go through
572
+ this: the merge used to replace the plain name only and point a feature
573
+ at the development container's name in production.
574
+ """
575
+ if "__PRODUCT__" not in value:
576
+ return value
577
+ if prod:
578
+ value = value.replace("__PRODUCT___web", f"{product}_web_deploy")
579
+ return value.replace("__PRODUCT__", product)
580
+
581
+
582
+ def local_or_registry_image(product: str) -> str:
583
+ """The web image of a deploy: ``SPLENT_IMAGE`` when set, else the local build."""
584
+ return "${SPLENT_IMAGE:-" + product + ":latest}"
585
+
586
+
554
587
  def apply_production_policies(compose_data: dict, product: str) -> dict:
555
588
  """Give every deployed service a restart policy, and the web and database
556
- services a healthcheck, unless the compose file already set one.
589
+ services a healthcheck, unless the compose file already set one. The web
590
+ image becomes ``${SPLENT_IMAGE:-<product>:latest}``, so a host can run the
591
+ image a release published instead of building its own.
557
592
 
558
593
  Applied by product:build to the merged deploy compose, so every product
559
594
  gets it on its next build, whatever its docker-compose.prod.yml says and
@@ -570,6 +605,11 @@ def apply_production_policies(compose_data: dict, product: str) -> dict:
570
605
  )
571
606
  if web_name and isinstance(services[web_name], dict):
572
607
  services[web_name].setdefault("healthcheck", dict(WEB_HEALTHCHECK))
608
+ # The local build, unless .env.deploy names a registry image. Only the
609
+ # plain local name is rewritten: an image the product chose on purpose
610
+ # is left alone.
611
+ if services[web_name].get("image") == f"{product}:latest":
612
+ services[web_name]["image"] = local_or_registry_image(product)
573
613
 
574
614
  db_name = next(
575
615
  (n for n in (f"{product}_db_deploy", f"{product}_db") if n in services),
@@ -937,6 +977,37 @@ def _collect_feature_assets(workspace, declared_features, docker_path, env="prod
937
977
  return copied
938
978
 
939
979
 
980
+ def unresolved_pins(
981
+ workspace: str, product: str, features: Sequence[str]
982
+ ) -> list[tuple[str, str]]:
983
+ """Pinned features this workspace has not resolved, each with the reason.
984
+
985
+ product:build reads every feature's env file and [tool.splent.config]
986
+ from the release the product pins, and copies the product's features/
987
+ links into the image. Both are product:resolve's work. Without it the
988
+ build used to carry on with whatever copy of the feature sat at the
989
+ workspace root, usually an old one: the .env.deploy.example lost every
990
+ setting the old copy did not declare, product:deploy then dropped them
991
+ from .env.deploy, and the image shipped links naming the old versions.
992
+ Editable features (no version) are not pinned and are not checked.
993
+ """
994
+ problems = []
995
+ for feat in features:
996
+ clean = compose.normalize_feature_ref(feat)
997
+ org_safe, _, name_ver = clean.partition("/")
998
+ if "@" not in name_ver:
999
+ continue
1000
+ cached = os.path.join(
1001
+ workspace, ".splent_cache", "features", org_safe, name_ver
1002
+ )
1003
+ linked = os.path.join(workspace, product, "features", org_safe, name_ver)
1004
+ if not os.path.isdir(cached):
1005
+ problems.append((name_ver, "not in .splent_cache"))
1006
+ elif not os.path.isdir(linked):
1007
+ problems.append((name_ver, f"not linked in {product}/features/"))
1008
+ return problems
1009
+
1010
+
940
1011
  @click.command(
941
1012
  "product:build",
942
1013
  short_help="Build deployment artifacts: env, compose, and Docker image.",
@@ -961,6 +1032,27 @@ def product_build(no_image, skip_preflight):
961
1032
 
962
1033
  click.echo(f"🏗️ Building deployment artifacts for product: {product}\n")
963
1034
 
1035
+ # Read declared features from pyproject.toml (not glob)
1036
+ pydata = load_toml(pyproject_path, what="pyproject.toml")
1037
+ declared_features = read_features_from_data(pydata, "prod")
1038
+
1039
+ # ---------------------------------------------------------
1040
+ # The releases the product pins, on disk
1041
+ # ---------------------------------------------------------
1042
+ unresolved = unresolved_pins(workspace, product, declared_features)
1043
+ if unresolved:
1044
+ click.secho(
1045
+ " The build reads the releases pyproject.toml pins, and these are "
1046
+ "not resolved in this workspace.",
1047
+ fg="red",
1048
+ )
1049
+ width = max(len(name) for name, _ in unresolved)
1050
+ for name, why in unresolved:
1051
+ click.echo(f" {name.ljust(width)} {why}")
1052
+ click.echo()
1053
+ click.echo(" Run splent product:resolve and build again.")
1054
+ raise SystemExit(1)
1055
+
964
1056
  # ---------------------------------------------------------
965
1057
  # Pre-flight checks (UVL + feature contracts)
966
1058
  # ---------------------------------------------------------
@@ -987,10 +1079,6 @@ def product_build(no_image, skip_preflight):
987
1079
  # unnamed courses with no categories in production.
988
1080
  product_env, tunable_keys = load_env_file_with_markers(product_env_file)
989
1081
 
990
- # Read declared features from pyproject.toml (not glob)
991
- pydata = load_toml(pyproject_path, what="pyproject.toml")
992
- declared_features = read_features_from_data(pydata, "prod")
993
-
994
1082
  # Unversioned features, and what actually happens to them.
995
1083
  #
996
1084
  # This warning used to say the build would fail. It does not, and that is
@@ -1061,10 +1149,7 @@ def product_build(no_image, skip_preflight):
1061
1149
  # share the product network, so the prod service cannot simply take the
1062
1150
  # dev name as an alias. The CLI owns both names, so it owns the mapping.
1063
1151
  for k, v in env_result.items():
1064
- if "__PRODUCT__" in v:
1065
- env_result[k] = v.replace(
1066
- "__PRODUCT___web", f"{product}_web_deploy"
1067
- ).replace("__PRODUCT__", product)
1152
+ env_result[k] = resolve_product_placeholders(v, product, prod=True)
1068
1153
 
1069
1154
  # Remove __FEATURE_HOST_DIR__ variables — not needed in prod
1070
1155
  # (templates are baked into Docker images, no bind mounts)
@@ -13,7 +13,12 @@ from jinja2 import Environment, FileSystemLoader, select_autoescape
13
13
 
14
14
  from splent_cli.utils.path_utils import PathUtils
15
15
  from splent_cli.services import context, spl_store
16
- from splent_cli.utils.template_drift import is_reserved_repo_name, not_a_product_reason
16
+ from splent_cli.utils.template_drift import (
17
+ DEFAULT_UPGRADE_SCHEDULE,
18
+ is_reserved_repo_name,
19
+ not_a_product_reason,
20
+ )
21
+ from splent_cli.utils.template_drift import _forge_host as _template_forge_host
17
22
 
18
23
  try:
19
24
  from importlib.metadata import version as _pkg_version
@@ -218,6 +223,10 @@ def make_product(name, spl_name, spl_doi, features_file, force):
218
223
  # Dropping it here made every product created this way fall back to
219
224
  # "<name>.uvl" and 404, with nothing local left that knew the real name.
220
225
  "spl_file": pin.file or "",
226
+ # The forge workflows. The same values product:sync-template --ci
227
+ # renders, so a new product does not start out drifted.
228
+ "forge_host": _template_forge_host(),
229
+ "upgrade_schedule": DEFAULT_UPGRADE_SCHEDULE,
221
230
  # Production persistence. These switch on the named-volume mounts in
222
231
  # the prod compose template so uploads survive redeploys. Products
223
232
  # scaffolded before these variables existed render without them and
@@ -304,6 +313,8 @@ def _write_product(env, name, base_path, template_ctx):
304
313
  "scripts/05_0_start_app_dev.sh": "product/product_05_0_start_app_dev.sh.j2",
305
314
  "scripts/05_1_start_app_prod.sh": "product/product_05_1_start_app_prod.sh.j2",
306
315
  ".gitignore": "product/product_.gitignore.j2",
316
+ ".forgejo/workflows/splent-validate.yml": "product/product_ci_validate.yml.j2",
317
+ ".forgejo/workflows/splent-upgrade.yml": "product/product_ci_upgrade.yml.j2",
307
318
  "LICENSE": "product/product_LICENSE.j2",
308
319
  "package.json": "product/product_package.json.j2",
309
320
  "pyproject.toml": "product/product_pyproject.toml.j2",
@@ -1,15 +1,17 @@
1
1
  import os
2
2
  import re
3
3
  import subprocess
4
+ from dataclasses import dataclass, field
4
5
 
5
6
  import click
6
7
 
7
- from splent_cli.utils.io_utils import atomic_write
8
+ from splent_cli.utils.io_utils import atomic_write, env_line
8
9
  import yaml
9
10
  from splent_cli.services import compose, context
10
11
  from splent_cli.commands.product.product_build import (
11
12
  OPTIONAL_DEPLOY_KEYS,
12
13
  host_docker_dir_env,
14
+ is_user_tunable_marker,
13
15
  load_env_file_with_markers,
14
16
  render_deploy_env,
15
17
  )
@@ -45,60 +47,205 @@ def suggest_value(key: str, product: str) -> str | None:
45
47
  ENV_DEPLOY_MODE = 0o600
46
48
 
47
49
 
50
+ #: The template product:deploy merged into .env.deploy last time, kept next to
51
+ #: it. Comparing against it is how a deploy tells a value the operator edited
52
+ #: from one the template changed: without it, both look like "the file differs
53
+ #: from the template". Ignored by git and by the image like every .env.*.
54
+ ENV_DEPLOY_BASE_SUFFIX = ".base"
55
+
56
+ #: Keys the CLI derives for the product and the operator has no reason to edit.
57
+ #: Without a recorded base they still follow the template, so an upgrade that
58
+ #: renames one (the database host, the network) reaches deployments made
59
+ #: before the base existed.
60
+ _DERIVED_KEYS = frozenset({"SPLENT_ENV", "SPLENT_NETWORK"})
61
+
62
+
63
+ @dataclass
64
+ class TemplateSync:
65
+ """What merging the template into an operator's env file did."""
66
+
67
+ env: dict
68
+ #: Keys the template changed and the operator had left alone.
69
+ updated: list = field(default_factory=list)
70
+ #: Keys the operator set that the template now sets differently.
71
+ kept: list = field(default_factory=list)
72
+ #: Keys the operator file had and the template no longer names.
73
+ dropped: list = field(default_factory=list)
74
+ #: User-tunable keys the operator deleted, which stay deleted.
75
+ deleted_tunables: set = field(default_factory=set)
76
+
77
+
78
+ def derived_keys(product: str) -> frozenset:
79
+ """The keys the CLI writes into every product's production template.
80
+
81
+ Read from the CLI's own template rather than listed here, so a key the
82
+ template gains is covered too. Secrets (<SET>) and user-tunable keys are
83
+ the operator's and are left out.
84
+ """
85
+ from splent_cli.utils.template_drift import product_ctx, render_template
86
+
87
+ keys = set(_DERIVED_KEYS)
88
+ text = render_template("product/product_.env.prod.example.j2", product_ctx(product))
89
+ tunable_next = False
90
+ for line in text.splitlines():
91
+ stripped = line.strip()
92
+ if is_user_tunable_marker(stripped):
93
+ tunable_next = True
94
+ continue
95
+ if not stripped or stripped.startswith("#") or "=" not in stripped:
96
+ continue
97
+ key, _, value = stripped.partition("=")
98
+ if not tunable_next and value.strip() != "<SET>":
99
+ keys.add(key.strip())
100
+ tunable_next = False
101
+ return frozenset(keys)
102
+
103
+
104
+ def base_path(env_path: str) -> str:
105
+ """Where the template behind ``env_path`` is recorded."""
106
+ return env_path + ENV_DEPLOY_BASE_SUFFIX
107
+
108
+
109
+ def read_base(env_path: str) -> dict | None:
110
+ """The template last merged into ``env_path``, or None if never recorded."""
111
+ path = base_path(env_path)
112
+ if not os.path.isfile(path):
113
+ return None
114
+ values, _ = load_env_file_with_markers(path)
115
+ return values
116
+
117
+
118
+ def write_base(env_path: str, template_vars: dict) -> None:
119
+ """Record the template just merged into ``env_path`` for the next merge."""
120
+ atomic_write(
121
+ base_path(env_path),
122
+ "".join(env_line(k, v) for k, v in template_vars.items()),
123
+ dir_owner=True,
124
+ mode=ENV_DEPLOY_MODE,
125
+ force_mode=True,
126
+ )
127
+
128
+
48
129
  def sync_with_template(
49
- example_vars: dict, tunable_keys: set, existing_vars: dict
50
- ) -> tuple[dict, list, set]:
130
+ example_vars: dict,
131
+ tunable_keys: set,
132
+ existing_vars: dict,
133
+ base_vars: dict | None = None,
134
+ derived: frozenset = frozenset(),
135
+ ) -> TemplateSync:
51
136
  """Bring an operator's env file up to date with its template.
52
137
 
53
- The template is the source of truth for every infrastructure variable.
54
- Two kinds of value belong to the operator instead and are never
55
- overwritten:
56
- * secrets and other keys the template ships as <SET>, once filled in;
57
- * keys the template marks with a "# user-tunable" comment, such as the
58
- interface a port binds to. A marked key the operator deleted stays
59
- deleted, so removing it to fall back to the compose default works.
60
-
61
- Returns ``(env_vars, updated_keys, deleted_tunables)``. product:deploy and
62
- product:env --merge --prod both write .env.deploy through this, so one
63
- cannot undo what the other kept.
138
+ A three-way merge. ``base_vars`` is the template as it was when the file
139
+ was last merged, so for every key that differs from the template now:
140
+
141
+ * the operator's value equals the old template: the operator never
142
+ touched it, the template moved, and the new template value is taken;
143
+ * otherwise the operator changed it and their value is kept.
144
+
145
+ Before this the template won every disagreement except secrets and
146
+ user-tunable keys, so a setting the operator turned on (publishing in the
147
+ marketplace, an API key the template ships empty) was silently undone on
148
+ every deploy.
149
+
150
+ With no recorded base (a file merged by an older CLI) nothing tells the
151
+ two apart, so the operator's value is kept and reported, except for the
152
+ ``derived`` keys the CLI computes for the product, which follow the
153
+ template as they always did.
154
+
155
+ Secrets the template ships as <SET> and user-tunable keys are the
156
+ operator's in every case, and a tunable the operator deleted stays
157
+ deleted. Keys the template no longer names are dropped and reported.
158
+
159
+ product:deploy and product:env --merge --prod both write .env.deploy
160
+ through this, so one cannot undo what the other kept.
64
161
  """
162
+ result = TemplateSync(env={})
65
163
  # Keys the operator removed on purpose. Only meaningful for tunables: a
66
- # template-owned key that went missing is still restored below.
67
- deleted_tunables = {
164
+ # template-owned key that went missing is restored below.
165
+ result.deleted_tunables = {
68
166
  k for k in tunable_keys if k in example_vars and k not in existing_vars
69
167
  }
70
168
 
71
- env_vars = {}
72
- updated = []
73
169
  for k, v in example_vars.items():
74
- if k in deleted_tunables:
170
+ if k in result.deleted_tunables:
75
171
  # The operator deleted this tunable. Leave it out and let the
76
172
  # compose default apply.
77
173
  continue
78
- elif k in tunable_keys and k in existing_vars:
79
- # Operator-owned value — keep it exactly as they wrote it.
80
- env_vars[k] = existing_vars[k]
81
- elif (
82
- v.strip() == "<SET>"
83
- and k in existing_vars
84
- and existing_vars[k].strip() != "<SET>"
85
- ):
86
- # User already configured this — keep their value
87
- env_vars[k] = existing_vars[k]
88
- elif k in existing_vars and existing_vars[k] != v:
89
- # Infrastructure value changed in template — update it
90
- env_vars[k] = v
91
- updated.append(k)
174
+ if k not in existing_vars or existing_vars[k] == v:
175
+ result.env[k] = v
176
+ continue
177
+ mine = existing_vars[k]
178
+ if k in tunable_keys:
179
+ # Operator-owned value, kept exactly as they wrote it.
180
+ result.env[k] = mine
181
+ elif v.strip() == "<SET>":
182
+ # A secret, filled in unless the file still says <SET>.
183
+ result.env[k] = v if mine.strip() == "<SET>" else mine
184
+ elif base_vars is not None and k in base_vars:
185
+ if mine == base_vars[k]:
186
+ result.env[k] = v
187
+ result.updated.append(k)
188
+ else:
189
+ result.env[k] = mine
190
+ if base_vars[k] != v:
191
+ result.kept.append(k)
192
+ elif k in derived:
193
+ result.env[k] = v
194
+ result.updated.append(k)
92
195
  else:
93
- env_vars[k] = v
196
+ result.env[k] = mine
197
+ result.kept.append(k)
198
+
94
199
  # Optional operator settings (GUNICORN_WORKERS and the like) are not
95
200
  # live lines in any template, so the loop above never sees them. One the
96
201
  # operator set is theirs and stays, or uncommenting it in .env.deploy
97
202
  # would last until the next deploy.
98
203
  for k in sorted(OPTIONAL_DEPLOY_KEYS):
99
- if k in existing_vars and k not in env_vars and k not in deleted_tunables:
100
- env_vars[k] = existing_vars[k]
101
- return env_vars, updated, deleted_tunables
204
+ if (
205
+ k in existing_vars
206
+ and k not in result.env
207
+ and k not in result.deleted_tunables
208
+ ):
209
+ result.env[k] = existing_vars[k]
210
+
211
+ result.dropped = [
212
+ k
213
+ for k in existing_vars
214
+ if k not in result.env and k not in result.deleted_tunables
215
+ ]
216
+ return result
217
+
218
+
219
+ def report_sync(sync: TemplateSync, env_file: str) -> None:
220
+ """Say what merging the template changed, by key name only.
221
+
222
+ Values stay out of the output: half of them are passwords and keys.
223
+ """
224
+
225
+ def say(text: str) -> None:
226
+ click.echo(click.style(" env ", dim=True) + text)
227
+
228
+ if sync.updated:
229
+ say(
230
+ f"updated {len(sync.updated)} variable(s) from the template: "
231
+ + ", ".join(sync.updated)
232
+ )
233
+ if sync.kept:
234
+ say(
235
+ f"kept your value for {len(sync.kept)} variable(s) the template sets "
236
+ f"differently: " + ", ".join(sync.kept)
237
+ )
238
+ say(f" delete a line from {env_file} to take the template's value")
239
+ if sync.dropped:
240
+ say(
241
+ f"dropped {len(sync.dropped)} variable(s) the template no longer "
242
+ f"has: " + ", ".join(sync.dropped)
243
+ )
244
+ if sync.deleted_tunables:
245
+ say(
246
+ f"keeping {len(sync.deleted_tunables)} removed variable(s) out: "
247
+ + ", ".join(sorted(sync.deleted_tunables))
248
+ )
102
249
 
103
250
 
104
251
  # Env vars whose value is a directory the running app writes to. Their contents
@@ -266,22 +413,15 @@ def product_deploy(down, ci):
266
413
  # ---------------------------------------------------------
267
414
  existing_vars, _ = load_env_file_with_markers(env_path)
268
415
  example_vars, tunable_keys = load_env_file_with_markers(env_example_path)
269
- env_vars, updated, deleted_tunables = sync_with_template(
270
- example_vars, tunable_keys, existing_vars
416
+ sync = sync_with_template(
417
+ example_vars,
418
+ tunable_keys,
419
+ existing_vars,
420
+ base_vars=read_base(env_path),
421
+ derived=derived_keys(product),
271
422
  )
272
-
273
- if updated:
274
- click.echo(
275
- click.style(" env ", dim=True)
276
- + f"updated {len(updated)} variable(s) from template"
277
- )
278
-
279
- if deleted_tunables:
280
- click.echo(
281
- click.style(" env ", dim=True)
282
- + f"keeping {len(deleted_tunables)} removed variable(s) out: "
283
- + ", ".join(sorted(deleted_tunables))
284
- )
423
+ env_vars = sync.env
424
+ report_sync(sync, "docker/.env.deploy")
285
425
 
286
426
  # ---------------------------------------------------------
287
427
  # Ask interactively for missing (<SET>) values
@@ -301,9 +441,18 @@ def product_deploy(down, ci):
301
441
  )
302
442
  continue
303
443
  if ci:
304
- env_value = os.getenv(key)
444
+ # The environment first. Without it, whatever the interactive
445
+ # deploy would offer as a default is taken (a random string
446
+ # for a secret, the conventional database names), so a feature
447
+ # that ships a <SET> secret does not stop an unattended deploy.
448
+ # What only the operator knows still has to come from them.
449
+ env_value = os.getenv(key) or suggest_value(key, product)
305
450
  if env_value:
306
451
  env_vars[key] = env_value
452
+ if not os.getenv(key):
453
+ click.echo(
454
+ click.style(" secret ", dim=True) + f"{key} generated"
455
+ )
307
456
  else:
308
457
  missing_vars.append(key)
309
458
  else:
@@ -345,6 +494,7 @@ def product_deploy(down, ci):
345
494
  mode=ENV_DEPLOY_MODE,
346
495
  force_mode=True,
347
496
  )
497
+ write_base(env_path, example_vars)
348
498
 
349
499
  # ---------------------------------------------------------
350
500
  # Say when the app will not believe its proxy
@@ -483,6 +633,41 @@ def product_deploy(down, ci):
483
633
  # container starts.
484
634
  compose.ensure_network(compose.network_name(product))
485
635
 
636
+ # A registry image (SPLENT_IMAGE) is pulled first: Compose only pulls an
637
+ # image it does not have, so a host that already ran an older build of
638
+ # the same tag would otherwise redeploy that one.
639
+ image = (env_vars.get("SPLENT_IMAGE") or "").strip()
640
+ if "/" in image:
641
+ click.echo(click.style(" image ", dim=True) + f"pulling {image}")
642
+ pulled = subprocess.run(
643
+ [
644
+ "docker",
645
+ "compose",
646
+ "-p",
647
+ compose.deploy_project_name(product),
648
+ "-f",
649
+ compose_path,
650
+ "--env-file",
651
+ env_path,
652
+ "pull",
653
+ f"{product}_web_deploy",
654
+ ],
655
+ capture_output=True,
656
+ text=True,
657
+ cwd=docker_dir,
658
+ env={**os.environ, **host_docker_dir_env(workspace, product)},
659
+ )
660
+ if pulled.returncode != 0:
661
+ click.secho(f" Could not pull {image}.", fg="red")
662
+ detail = (pulled.stderr or pulled.stdout or "").strip().splitlines()
663
+ for line in detail[-5:]:
664
+ click.echo(f" {line}")
665
+ click.echo(
666
+ " Check SPLENT_IMAGE in docker/.env.deploy, and docker login to "
667
+ "that registry when the image is private."
668
+ )
669
+ raise SystemExit(1)
670
+
486
671
  click.echo(click.style(" deploying ", dim=True) + f"{product} (prod)...")
487
672
 
488
673
  try:
@@ -183,8 +183,9 @@ def product_derive(mode):
183
183
 
184
184
  \b
185
185
  --prod runs (after pre-flight):
186
- 1. product:build merge env + compose + build Docker image
187
- 2. product:deploy start production containers
186
+ 1. product:resolve clone the pinned releases and link them
187
+ 2. product:build merge env + compose + build Docker image
188
+ 3. product:deploy start production containers
188
189
 
189
190
  \b
190
191
  The pipeline stops at the first step that fails.
@@ -354,6 +355,13 @@ def product_derive(mode):
354
355
  step(product_port, env_flag="dev")
355
356
 
356
357
  elif mode == "prod":
358
+ # The build reads the pinned releases from the cache and refuses
359
+ # without them, so resolve them first, as the dev path does.
360
+ click.echo(
361
+ click.style(" sync ", dim=True) + "resolving the pinned features..."
362
+ )
363
+ step(product_sync, force=False, mode="prod", strict=True)
364
+
357
365
  click.echo(" build merging env + compose + Docker image")
358
366
  step(product_build, no_image=False, skip_preflight=True)
359
367
 
@@ -368,26 +368,46 @@ def product_env(generate, merge, env_name, process_all):
368
368
  # second product of the same line came up.
369
369
  merged["SPLENT_NETWORK"] = compose.network_name(product)
370
370
 
371
- # Replace __PRODUCT__ placeholder with the actual product name
371
+ # Replace __PRODUCT__ placeholder with the actual product name.
372
+ # product_build imports this module, so the helper is imported here.
373
+ from splent_cli.commands.product.product_build import (
374
+ resolve_product_placeholders,
375
+ )
376
+
372
377
  product_replaced = []
373
378
  for k, v in merged.items():
374
379
  if "__PRODUCT__" in v:
375
- merged[k] = v.replace("__PRODUCT__", product)
380
+ merged[k] = resolve_product_placeholders(
381
+ v, product, prod=env_name == "prod"
382
+ )
376
383
  product_replaced.append(k)
377
384
 
378
385
  if env_name == "prod":
379
386
  # .env.deploy is the operator's file: it holds the passwords and
380
387
  # the SECRET_KEY the running database was created with. The same
381
- # rule product:deploy uses keeps every <SET> the operator filled
382
- # in and every user-tunable value; the rest follows the template.
388
+ # three-way merge product:deploy uses keeps every value the
389
+ # operator set and takes every one the template changed.
383
390
  from splent_cli.commands.product.product_deploy import (
384
391
  ENV_DEPLOY_MODE,
392
+ derived_keys,
393
+ read_base,
394
+ report_sync,
385
395
  sync_with_template,
396
+ write_base,
386
397
  )
387
398
 
399
+ template = dict(merged)
388
400
  if os.path.isfile(target_env):
389
401
  existing, _ = load_env_file_with_markers(target_env)
390
- merged, _, _ = sync_with_template(merged, tunable_keys, existing)
402
+ sync = sync_with_template(
403
+ template,
404
+ tunable_keys,
405
+ existing,
406
+ base_vars=read_base(target_env),
407
+ derived=derived_keys(product),
408
+ )
409
+ merged = sync.env
410
+ report_sync(sync, os.path.relpath(target_env, product_path))
391
411
  backup_file(target_env, mode=ENV_DEPLOY_MODE)
392
412
  atomic_write(
393
413
  target_env,
@@ -396,6 +416,7 @@ def product_env(generate, merge, env_name, process_all):
396
416
  mode=ENV_DEPLOY_MODE,
397
417
  force_mode=True,
398
418
  )
419
+ write_base(target_env, template)
399
420
  else:
400
421
  atomic_write(
401
422
  target_env,