Files
Riccardo Schirone 111a6cd995 Validate rendered cloud-init templates against official schema (#63)
* Validate rendered cloud-init templates against official schema

Add jsonschema dev dependency and two new tests that render the Jinja2
cloud-init template (with and without Tailscale) then validate the
output against canonical/cloud-init's JSON schema. Tests skip gracefully
when the network is unavailable.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Pin cloud-init schema to 22.4.2 and load from local test data

Replace the network-fetched schema (from cloud-init main) with a local
copy pinned to version 22.4.2 — the oldest cloud-init shipped by any
currently supported DigitalOcean image (Debian 12 bookworm). This
addresses review feedback to avoid validating against a schema newer
than what target distros actually support, and removes the network
dependency so tests work offline.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 17:03:16 +02:00

126 lines
4.4 KiB
Python

"""Tests for cloud-init template parsing and rendering."""
import json
from pathlib import Path
import pytest
import yaml
from jinja2 import Template, TemplateSyntaxError
from jsonschema import Draft4Validator
from dropkit.config import Config
# Pinned to cloud-init 22.4.2 — the oldest version shipped by a
# currently supported DigitalOcean image (Debian 12 bookworm).
_SCHEMA_PATH = Path(__file__).parent / "data" / "schema-cloud-config-v1.json"
@pytest.fixture(scope="module")
def cloud_config_schema():
"""Load the cloud-init JSON schema from local test data."""
return json.loads(_SCHEMA_PATH.read_text())
def _load_default_template() -> str:
"""Load the default cloud-init template content."""
path = Config.get_default_template_path()
return path.read_text()
def test_default_template_parses():
"""Verify the default template is valid Jinja2 syntax."""
content = _load_default_template()
try:
Template(content)
except TemplateSyntaxError as exc:
raise AssertionError(f"Template has Jinja2 syntax error: {exc}") from exc
def test_default_template_renders():
"""Verify the template renders with sample variables."""
content = _load_default_template()
template = Template(content)
rendered = template.render(
username="testuser",
full_name="Test User",
email="test@example.com",
ssh_keys=["ssh-ed25519 AAAAC3... test@host"],
tailscale_enabled=True,
)
assert "testuser" in rendered
assert "ssh-ed25519 AAAAC3... test@host" in rendered
assert "git config --global user.name 'Test User'" in rendered
assert "git config --global user.email 'test@example.com'" in rendered
assert "tailscale" in rendered
def test_default_template_renders_without_tailscale():
"""Verify the Tailscale section is absent when disabled."""
content = _load_default_template()
template = Template(content)
rendered = template.render(
username="testuser",
full_name="Test User",
email="test@example.com",
ssh_keys=["ssh-ed25519 AAAAC3... test@host"],
tailscale_enabled=False,
)
assert "testuser" in rendered
assert "tailscale.com/install.sh" not in rendered
def _render_template(tailscale_enabled: bool = True) -> str:
"""Render the default template with sample variables."""
content = _load_default_template()
template = Template(content)
return template.render(
username="testuser",
full_name="Test User",
email="test@example.com",
ssh_keys=["ssh-ed25519 AAAAC3NzaC1lZDI1NTE5 test@host"],
tailscale_enabled=tailscale_enabled,
)
def test_rendered_template_valid_cloud_config_schema(cloud_config_schema):
"""Verify the rendered template passes cloud-init schema validation."""
rendered = _render_template(tailscale_enabled=True)
doc = yaml.safe_load(rendered)
validator = Draft4Validator(cloud_config_schema)
errors = list(validator.iter_errors(doc))
messages = [f" - {e.message}" for e in errors]
assert not errors, "Cloud-init schema errors:\n" + "\n".join(messages)
def test_rendered_template_no_tailscale_valid_schema(cloud_config_schema):
"""Verify the rendered template without Tailscale also passes schema validation."""
rendered = _render_template(tailscale_enabled=False)
doc = yaml.safe_load(rendered)
validator = Draft4Validator(cloud_config_schema)
errors = list(validator.iter_errors(doc))
messages = [f" - {e.message}" for e in errors]
assert not errors, "Cloud-init schema errors:\n" + "\n".join(messages)
def test_docker_install_uses_distro_detection():
"""Verify Docker setup detects distro dynamically instead of hardcoding Ubuntu."""
content = _load_default_template()
template = Template(content)
rendered = template.render(
username="testuser",
full_name="Test User",
email="test@example.com",
ssh_keys=["ssh-ed25519 AAAAC3... test@host"],
tailscale_enabled=True,
)
# Must not hardcode Ubuntu — should use /etc/os-release for distro detection
assert "download.docker.com/linux/ubuntu" not in rendered
assert "/etc/os-release" in rendered
assert "download.docker.com/linux/$ID" in rendered
# Docker packages installed via runcmd, not apt sources
assert "apt-get install -y docker-ce" in rendered
# Architecture detected dynamically, not hardcoded amd64
assert "dpkg --print-architecture" in rendered