test_template.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350
  1. """Tests for the 0.2.0 template.json runtime."""
  2. from __future__ import annotations
  3. import base64
  4. import json
  5. from pathlib import Path
  6. import pytest
  7. from cli.core.exceptions import TemplateLoadError
  8. from cli.core.library import Library
  9. from cli.core.template import Template, normalize_template_slug
  10. CHARACTER_SECRET_LENGTH = 40
  11. BASE64_SECRET_BYTES = 12
  12. def _write_template_json(template_dir: Path, manifest: dict, files: dict[str, str]) -> None:
  13. template_dir.mkdir(parents=True, exist_ok=True)
  14. (template_dir / "template.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8")
  15. files_dir = template_dir / "files"
  16. for relative_path, content in files.items():
  17. target = files_dir / relative_path
  18. target.parent.mkdir(parents=True, exist_ok=True)
  19. target.write_text(content, encoding="utf-8")
  20. def test_template_json_renders_all_files_and_normalizes_items(tmp_path: Path) -> None:
  21. """The new manifest shape should normalize variables[].items and render every file in files/."""
  22. template_dir = tmp_path / "compose" / "demo"
  23. _write_template_json(
  24. template_dir,
  25. {
  26. "slug": "demo",
  27. "kind": "compose",
  28. "metadata": {
  29. "name": "Demo",
  30. "description": "Demo template",
  31. "tags": ["test"],
  32. "version": {
  33. "name": "v1.0.0",
  34. "source_dep_name": "ghcr.io/example/demo",
  35. "source_dep_version": "1.0.0",
  36. "source_dep_digest": "sha256:deadbeef",
  37. "upstream_ref": "release-2026-04-22",
  38. "notes": "Pinned to the tested upstream image",
  39. },
  40. },
  41. "variables": [
  42. {
  43. "name": "general",
  44. "title": "General",
  45. "items": [
  46. {
  47. "name": "container_hostname",
  48. "type": "str",
  49. "title": "Container hostname",
  50. "default": "demo",
  51. },
  52. {
  53. "name": "database_password",
  54. "type": "secret",
  55. "title": "Database password",
  56. "config": {
  57. "autogenerated": {
  58. "kind": "characters",
  59. "length": CHARACTER_SECRET_LENGTH,
  60. "characters": ["A", "B", "1", "2"],
  61. },
  62. },
  63. },
  64. {
  65. "name": "tls_enabled",
  66. "type": "bool",
  67. "title": "TLS enabled",
  68. "default": True,
  69. },
  70. {
  71. "name": "service_count",
  72. "type": "int",
  73. "title": "Service count",
  74. "default": 3,
  75. "config": {
  76. "slider": True,
  77. "min": 1,
  78. "max": 9,
  79. "step": 2,
  80. "unit": "nodes",
  81. },
  82. },
  83. {
  84. "name": "notes",
  85. "type": "str",
  86. "title": "Notes",
  87. "config": {
  88. "textarea": True,
  89. "placeholder": "Optional notes",
  90. },
  91. },
  92. ],
  93. }
  94. ],
  95. },
  96. {
  97. "compose.yaml": (
  98. "services:\n"
  99. " app:\n"
  100. " hostname: << container_hostname >>\n"
  101. "<% if tls_enabled %>\n"
  102. " labels:\n"
  103. ' tls: "true"\n'
  104. "<% endif %>\n"
  105. ),
  106. "README.md": "hostname=<< container_hostname >>\n",
  107. },
  108. )
  109. template = Template(template_dir, library_name="default")
  110. rendered_files, variable_values = template.render(template.variables)
  111. assert set(rendered_files) == {"README.md", "compose.yaml"}
  112. assert "hostname: demo" in rendered_files["compose.yaml"]
  113. assert 'tls: "true"' in rendered_files["compose.yaml"]
  114. assert rendered_files["README.md"] == "hostname=demo\n"
  115. assert variable_values["container_hostname"] == "demo"
  116. assert len(variable_values["database_password"]) == CHARACTER_SECRET_LENGTH
  117. assert set(variable_values["database_password"]) <= {"A", "B", "1", "2"}
  118. assert template.variables._variable_map["database_password"].autogenerated is True
  119. assert template.variables._variable_map["database_password"].type == "secret"
  120. assert template.variables._variable_map["database_password"].config.autogenerated is not None
  121. assert template.variables._variable_map["service_count"].config.slider is True
  122. assert template.variables._variable_map["service_count"].config.unit == "nodes"
  123. assert template.variables._variable_map["notes"].config.textarea is True
  124. assert template.variables._variable_map["notes"].config.placeholder == "Optional notes"
  125. assert template.variables._variable_map["container_hostname"].description == "Container hostname"
  126. assert template.metadata.version.name == "v1.0.0"
  127. assert template.metadata.version.source_dep_name == "ghcr.io/example/demo"
  128. assert template.metadata.version.source_dep_version == "1.0.0"
  129. assert template.metadata.version.source_dep_digest == "sha256:deadbeef"
  130. assert template.metadata.version.upstream_ref == "release-2026-04-22"
  131. assert template.metadata.version.notes == "Pinned to the tested upstream image"
  132. def test_template_json_supports_value_field_and_base64_secret_generation(tmp_path: Path) -> None:
  133. """Manifest values should honor value fields and structured base64 secret generation."""
  134. template_dir = tmp_path / "compose" / "advanced"
  135. _write_template_json(
  136. template_dir,
  137. {
  138. "slug": "advanced",
  139. "kind": "compose",
  140. "metadata": {
  141. "name": "Advanced",
  142. "description": "Advanced template",
  143. "version": {
  144. "source_dep_name": "ghcr.io/example/advanced",
  145. "source_dep_version": "1.0.0",
  146. },
  147. },
  148. "variables": [
  149. {
  150. "name": "general",
  151. "title": "General",
  152. "items": [
  153. {
  154. "name": "environment",
  155. "type": "enum",
  156. "title": "Environment",
  157. "default": "prod",
  158. "value": "stage",
  159. "config": {"options": ["prod", "stage", "dev"]},
  160. },
  161. {
  162. "name": "api_token",
  163. "type": "secret",
  164. "title": "API token",
  165. "config": {
  166. "autogenerated": {
  167. "kind": "base64",
  168. "bytes": BASE64_SECRET_BYTES,
  169. }
  170. },
  171. },
  172. ],
  173. }
  174. ],
  175. },
  176. {
  177. "config.txt": "env=<< environment >>\n",
  178. },
  179. )
  180. template = Template(template_dir, library_name="default")
  181. rendered_files, variable_values = template.render(template.variables)
  182. assert rendered_files["config.txt"] == "env=stage\n"
  183. assert variable_values["environment"] == "stage"
  184. assert len(base64.b64decode(variable_values["api_token"])) == BASE64_SECRET_BYTES
  185. assert template.metadata.version.name == ""
  186. assert template.metadata.version.source_dep_name == "ghcr.io/example/advanced"
  187. assert template.metadata.version.source_dep_version == "1.0.0"
  188. assert not template.metadata.version
  189. def test_template_slug_is_manifest_driven_and_normalized(tmp_path: Path) -> None:
  190. """The manifest slug defines the template ID, not the directory name."""
  191. template_dir = tmp_path / "compose" / "portainer"
  192. _write_template_json(
  193. template_dir,
  194. {
  195. "slug": "portainer-compose",
  196. "kind": "compose",
  197. "metadata": {
  198. "name": "Portainer",
  199. "description": "Portainer template",
  200. },
  201. "variables": [],
  202. },
  203. {
  204. "compose.yaml": "services: {}\n",
  205. },
  206. )
  207. template = Template(template_dir, library_name="default")
  208. assert normalize_template_slug("portainer-compose", "compose") == "portainer"
  209. assert template.id == "portainer"
  210. assert template.original_id == "portainer"
  211. assert template.directory_id == "portainer"
  212. assert template.slug == "portainer"
  213. assert template.metadata.version.name == ""
  214. def test_legacy_template_yaml_is_rejected(tmp_path: Path) -> None:
  215. """Legacy template.yaml manifests should be marked incompatible."""
  216. template_dir = tmp_path / "compose" / "legacy"
  217. template_dir.mkdir(parents=True, exist_ok=True)
  218. (template_dir / "template.yaml").write_text("kind: compose\nmetadata:\n name: Legacy\n", encoding="utf-8")
  219. with pytest.raises(TemplateLoadError, match="Legacy template manifests are incompatible"):
  220. Template(template_dir, library_name="default")
  221. def test_raw_jinja_delimiters_are_allowed_in_rendered_files(tmp_path: Path) -> None:
  222. """Rendered files may contain raw Jinja syntax for downstream tools like Ansible."""
  223. template_dir = tmp_path / "ansible" / "raw-jinja"
  224. _write_template_json(
  225. template_dir,
  226. {
  227. "slug": "raw-jinja",
  228. "kind": "ansible",
  229. "metadata": {
  230. "name": "Raw Jinja",
  231. "description": "Ansible template",
  232. "version": {
  233. "name": "v1.0.0",
  234. },
  235. },
  236. "variables": [
  237. {
  238. "name": "general",
  239. "title": "General",
  240. "items": [
  241. {
  242. "name": "target_host",
  243. "type": "str",
  244. "title": "Target host",
  245. "default": "all",
  246. }
  247. ],
  248. }
  249. ],
  250. },
  251. {
  252. "playbook.yaml": (
  253. "- hosts: << target_host >>\n"
  254. " tasks:\n"
  255. " - debug:\n"
  256. ' msg: "{{ ansible_hostname }}"\n'
  257. " - name: Raw Ansible block delimiter remains literal\n"
  258. " debug:\n"
  259. ' msg: "{% raw %}{{ value }}{% endraw %}"\n'
  260. ),
  261. },
  262. )
  263. template = Template(template_dir, library_name="default")
  264. rendered_files, _ = template.render(template.variables)
  265. assert template.used_variables == {"target_host"}
  266. assert "{{ ansible_hostname }}" in rendered_files["playbook.yaml"]
  267. assert "{% raw %}{{ value }}{% endraw %}" in rendered_files["playbook.yaml"]
  268. assert "- hosts: all" in rendered_files["playbook.yaml"]
  269. def test_template_json_rejects_string_metadata_version(tmp_path: Path) -> None:
  270. """metadata.version must use the structured object shape."""
  271. template_dir = tmp_path / "compose" / "bad-version"
  272. _write_template_json(
  273. template_dir,
  274. {
  275. "slug": "bad-version",
  276. "kind": "compose",
  277. "metadata": {
  278. "name": "Bad Version",
  279. "description": "Bad version metadata",
  280. "version": "1.0.0",
  281. },
  282. "variables": [],
  283. },
  284. {
  285. "compose.yaml": "services: {}\n",
  286. },
  287. )
  288. with pytest.raises(TemplateLoadError, match=r"'metadata\.version' must be an object"):
  289. Template(template_dir, library_name="default")
  290. def test_library_discovery_ignores_legacy_template_manifests(tmp_path: Path) -> None:
  291. """Only template.json manifests should be treated as templates during discovery."""
  292. module_dir = tmp_path / "compose"
  293. legacy_dir = module_dir / "legacy"
  294. current_dir = module_dir / "current"
  295. legacy_dir.mkdir(parents=True, exist_ok=True)
  296. (legacy_dir / "template.yaml").write_text("kind: compose\nmetadata:\n name: Legacy\n", encoding="utf-8")
  297. _write_template_json(
  298. current_dir,
  299. {
  300. "slug": "current",
  301. "kind": "compose",
  302. "metadata": {
  303. "name": "Current",
  304. "description": "Current template",
  305. },
  306. "variables": [],
  307. },
  308. {
  309. "compose.yaml": "services: {}\n",
  310. },
  311. )
  312. library = Library(name="default", path=tmp_path)
  313. assert library.find("compose", sort_results=True) == [(current_dir, "default")]