mirror of
https://github.com/trailofbits/dropkit
synced 2026-06-21 14:11:54 +00:00
111a6cd995
* 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>
126 lines
4.4 KiB
Python
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
|