| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338 |
- """Tests for the 0.2.0 template.json runtime."""
- from __future__ import annotations
- import base64
- import json
- from pathlib import Path
- import pytest
- from cli.core.exceptions import TemplateLoadError, TemplateValidationError
- from cli.core.library import Library
- from cli.core.template import Template, normalize_template_slug
- CHARACTER_SECRET_LENGTH = 40
- BASE64_SECRET_BYTES = 12
- def _write_template_json(template_dir: Path, manifest: dict, files: dict[str, str]) -> None:
- template_dir.mkdir(parents=True, exist_ok=True)
- (template_dir / "template.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8")
- files_dir = template_dir / "files"
- for relative_path, content in files.items():
- target = files_dir / relative_path
- target.parent.mkdir(parents=True, exist_ok=True)
- target.write_text(content, encoding="utf-8")
- def test_template_json_renders_all_files_and_normalizes_items(tmp_path: Path) -> None:
- """The new manifest shape should normalize variables[].items and render every file in files/."""
- template_dir = tmp_path / "compose" / "demo"
- _write_template_json(
- template_dir,
- {
- "slug": "demo",
- "kind": "compose",
- "metadata": {
- "name": "Demo",
- "description": "Demo template",
- "tags": ["test"],
- "version": {
- "name": "v1.0.0",
- "source_dep_name": "ghcr.io/example/demo",
- "source_dep_version": "1.0.0",
- "source_dep_digest": "sha256:deadbeef",
- "upstream_ref": "release-2026-04-22",
- "notes": "Pinned to the tested upstream image",
- },
- },
- "variables": [
- {
- "name": "general",
- "title": "General",
- "items": [
- {
- "name": "container_hostname",
- "type": "str",
- "title": "Container hostname",
- "default": "demo",
- },
- {
- "name": "database_password",
- "type": "secret",
- "title": "Database password",
- "config": {
- "autogenerated": {
- "kind": "characters",
- "length": CHARACTER_SECRET_LENGTH,
- "characters": ["A", "B", "1", "2"],
- },
- },
- },
- {
- "name": "tls_enabled",
- "type": "bool",
- "title": "TLS enabled",
- "default": True,
- },
- {
- "name": "service_count",
- "type": "int",
- "title": "Service count",
- "default": 3,
- "config": {
- "slider": True,
- "min": 1,
- "max": 9,
- "step": 2,
- "unit": "nodes",
- },
- },
- {
- "name": "notes",
- "type": "str",
- "title": "Notes",
- "config": {
- "textarea": True,
- "placeholder": "Optional notes",
- },
- },
- ],
- }
- ],
- },
- {
- "compose.yaml": (
- "services:\n"
- " app:\n"
- " hostname: << container_hostname >>\n"
- "<% if tls_enabled %>\n"
- " labels:\n"
- ' tls: "true"\n'
- "<% endif %>\n"
- ),
- "README.md": "hostname=<< container_hostname >>\n",
- },
- )
- template = Template(template_dir, library_name="default")
- rendered_files, variable_values = template.render(template.variables)
- assert set(rendered_files) == {"README.md", "compose.yaml"}
- assert "hostname: demo" in rendered_files["compose.yaml"]
- assert 'tls: "true"' in rendered_files["compose.yaml"]
- assert rendered_files["README.md"] == "hostname=demo\n"
- assert variable_values["container_hostname"] == "demo"
- assert len(variable_values["database_password"]) == CHARACTER_SECRET_LENGTH
- assert set(variable_values["database_password"]) <= {"A", "B", "1", "2"}
- assert template.variables._variable_map["database_password"].autogenerated is True
- assert template.variables._variable_map["database_password"].type == "secret"
- assert template.variables._variable_map["database_password"].config.autogenerated is not None
- assert template.variables._variable_map["service_count"].config.slider is True
- assert template.variables._variable_map["service_count"].config.unit == "nodes"
- assert template.variables._variable_map["notes"].config.textarea is True
- assert template.variables._variable_map["notes"].config.placeholder == "Optional notes"
- assert template.variables._variable_map["container_hostname"].description == "Container hostname"
- assert template.metadata.version.name == "v1.0.0"
- assert template.metadata.version.source_dep_name == "ghcr.io/example/demo"
- assert template.metadata.version.source_dep_version == "1.0.0"
- assert template.metadata.version.source_dep_digest == "sha256:deadbeef"
- assert template.metadata.version.upstream_ref == "release-2026-04-22"
- assert template.metadata.version.notes == "Pinned to the tested upstream image"
- def test_template_json_supports_value_field_and_base64_secret_generation(tmp_path: Path) -> None:
- """Manifest values should honor value fields and structured base64 secret generation."""
- template_dir = tmp_path / "compose" / "advanced"
- _write_template_json(
- template_dir,
- {
- "slug": "advanced",
- "kind": "compose",
- "metadata": {
- "name": "Advanced",
- "description": "Advanced template",
- "version": {
- "source_dep_name": "ghcr.io/example/advanced",
- "source_dep_version": "1.0.0",
- },
- },
- "variables": [
- {
- "name": "general",
- "title": "General",
- "items": [
- {
- "name": "environment",
- "type": "enum",
- "title": "Environment",
- "default": "prod",
- "value": "stage",
- "config": {"options": ["prod", "stage", "dev"]},
- },
- {
- "name": "api_token",
- "type": "secret",
- "title": "API token",
- "config": {
- "autogenerated": {
- "kind": "base64",
- "bytes": BASE64_SECRET_BYTES,
- }
- },
- },
- ],
- }
- ],
- },
- {
- "config.txt": "env=<< environment >>\n",
- },
- )
- template = Template(template_dir, library_name="default")
- rendered_files, variable_values = template.render(template.variables)
- assert rendered_files["config.txt"] == "env=stage\n"
- assert variable_values["environment"] == "stage"
- assert len(base64.b64decode(variable_values["api_token"])) == BASE64_SECRET_BYTES
- assert template.metadata.version.name == ""
- assert template.metadata.version.source_dep_name == "ghcr.io/example/advanced"
- assert template.metadata.version.source_dep_version == "1.0.0"
- assert not template.metadata.version
- def test_template_slug_is_manifest_driven_and_normalized(tmp_path: Path) -> None:
- """The manifest slug defines the template ID, not the directory name."""
- template_dir = tmp_path / "compose" / "portainer"
- _write_template_json(
- template_dir,
- {
- "slug": "portainer-compose",
- "kind": "compose",
- "metadata": {
- "name": "Portainer",
- "description": "Portainer template",
- },
- "variables": [],
- },
- {
- "compose.yaml": "services: {}\n",
- },
- )
- template = Template(template_dir, library_name="default")
- assert normalize_template_slug("portainer-compose", "compose") == "portainer"
- assert template.id == "portainer"
- assert template.original_id == "portainer"
- assert template.directory_id == "portainer"
- assert template.slug == "portainer"
- assert template.metadata.version.name == ""
- def test_legacy_template_yaml_is_rejected(tmp_path: Path) -> None:
- """Legacy template.yaml manifests should be marked incompatible."""
- template_dir = tmp_path / "compose" / "legacy"
- template_dir.mkdir(parents=True, exist_ok=True)
- (template_dir / "template.yaml").write_text("kind: compose\nmetadata:\n name: Legacy\n", encoding="utf-8")
- with pytest.raises(TemplateLoadError, match="Legacy template manifests are incompatible"):
- Template(template_dir, library_name="default")
- def test_legacy_jinja_delimiters_are_rejected(tmp_path: Path) -> None:
- """files/ content must use the new custom delimiters."""
- template_dir = tmp_path / "compose" / "legacy-delimiters"
- _write_template_json(
- template_dir,
- {
- "slug": "legacy-delimiters",
- "kind": "compose",
- "metadata": {
- "name": "Legacy delimiters",
- "description": "Bad template",
- "version": {
- "name": "v1.0.0",
- },
- },
- "variables": [
- {
- "name": "general",
- "title": "General",
- "items": [
- {
- "name": "container_hostname",
- "type": "str",
- "title": "Container hostname",
- }
- ],
- }
- ],
- },
- {
- "compose.yaml": "hostname: {{ container_hostname }}\n",
- },
- )
- template = Template(template_dir, library_name="default")
- with pytest.raises(TemplateValidationError, match="Legacy Jinja delimiter"):
- _ = template.used_variables
- def test_template_json_rejects_string_metadata_version(tmp_path: Path) -> None:
- """metadata.version must use the structured object shape."""
- template_dir = tmp_path / "compose" / "bad-version"
- _write_template_json(
- template_dir,
- {
- "slug": "bad-version",
- "kind": "compose",
- "metadata": {
- "name": "Bad Version",
- "description": "Bad version metadata",
- "version": "1.0.0",
- },
- "variables": [],
- },
- {
- "compose.yaml": "services: {}\n",
- },
- )
- with pytest.raises(TemplateLoadError, match=r"'metadata\.version' must be an object"):
- Template(template_dir, library_name="default")
- def test_library_discovery_ignores_legacy_template_manifests(tmp_path: Path) -> None:
- """Only template.json manifests should be treated as templates during discovery."""
- module_dir = tmp_path / "compose"
- legacy_dir = module_dir / "legacy"
- current_dir = module_dir / "current"
- legacy_dir.mkdir(parents=True, exist_ok=True)
- (legacy_dir / "template.yaml").write_text("kind: compose\nmetadata:\n name: Legacy\n", encoding="utf-8")
- _write_template_json(
- current_dir,
- {
- "slug": "current",
- "kind": "compose",
- "metadata": {
- "name": "Current",
- "description": "Current template",
- },
- "variables": [],
- },
- {
- "compose.yaml": "services: {}\n",
- },
- )
- library = Library(name="default", path=tmp_path)
- assert library.find("compose", sort_results=True) == [(current_dir, "default")]
|