diff --git a/flux_local/manifest.py b/flux_local/manifest.py index 218b8a19..2ba15def 100644 --- a/flux_local/manifest.py +++ b/flux_local/manifest.py @@ -5,23 +5,29 @@ e.g. such as writing management plan for resources. """ +import dataclasses import datetime -from dataclasses import dataclass -from typing import Any +from pathlib import Path +from typing import Any, cast +import aiofiles +import yaml from pydantic import BaseModel __all__ = [ + "read_manifest", + "write_manifest", "Manifest", "Cluster", "Kustomization", "HelmRepository", "HelmRelease", "HelmChart", + "ManifestException", ] -@dataclass +@dataclasses.dataclass class HelmChart: """A representation of an instantiation of a chart for a HelmRelease.""" @@ -62,7 +68,7 @@ def chart_name(self) -> str: return f"{self.repo_namespace}-{self.repo_name}/{self.name}" -@dataclass +@dataclasses.dataclass class HelmRelease: """A representation of a Flux HelmRelease.""" @@ -93,7 +99,7 @@ def release_name(self) -> str: return f"{self.namespace}-{self.name}" -@dataclass +@dataclasses.dataclass class HelmRepository: """A representation of a flux HelmRepository.""" @@ -127,7 +133,7 @@ def repo_name(self) -> str: return f"{self.namespace}-{self.name}" -@dataclass +@dataclasses.dataclass class Kustomization: """A Kustomization is a set of declared cluster artifacts. @@ -154,7 +160,7 @@ def id_name(self) -> str: return f"{self.path}" -@dataclass +@dataclasses.dataclass class Cluster: """A set of nodes that run containerized applications. @@ -202,3 +208,51 @@ class Manifest(BaseModel): clusters: list[Cluster] """A list of Clusters represented in the repo.""" + + @staticmethod + def parse_yaml(content: str) -> "Manifest": + """Parse a serialized manifest.""" + doc = next(yaml.load_all(content, Loader=yaml.Loader), None) + if not doc or "spec" not in doc: + raise ManifestException("Manifest file malformed, missing 'spec'") + return Manifest(clusters=doc["spec"]) + + def yaml(self) -> str: + """Serialize the manifest as a yaml file.""" + return cast( + str, + yaml.dump( + {"spec": [dataclasses.asdict(cluster) for cluster in self.clusters]}, + sort_keys=False, + explicit_start=True, + ), + ) + + +class ManifestException(Exception): + """Error raised while working with the Manifest.""" + + +async def read_manifest(manifest_path: Path) -> Manifest: + """Return the contents of a serialized manifest file.""" + async with aiofiles.open(str(manifest_path)) as manifest_file: + content = await manifest_file.read() + return Manifest.parse_yaml(content) + + +async def write_manifest(manifest_path: Path, manifest: Manifest) -> None: + """Write the specified manifest content to disk.""" + content = manifest.yaml() + async with aiofiles.open(str(manifest_path), mode="w") as manifest_file: + await manifest_file.write(content) + + +async def update_manifest(manifest_path: Path, manifest: Manifest) -> None: + """Write the specified manifest only if changed.""" + async with aiofiles.open(str(manifest_path)) as manifest_file: + content = await manifest_file.read() + new_content = manifest.yaml() + if content == new_content: + return + async with aiofiles.open(str(manifest_path), mode="w") as manifest_file: + await manifest_file.write(new_content) diff --git a/setup.cfg b/setup.cfg index fb50fca0..f859c2fd 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = flux-local -version = 0.0.2 +version = 0.0.3 description = flux-local is a python library and set of tools for managing a flux gitops repository, with validation steps to help improve quality of commits, PRs, and general local testing. long_description = file: README.md long_description_content_type = text/markdown diff --git a/tests/test_manifest.py b/tests/test_manifest.py index 0a387796..9f78a840 100644 --- a/tests/test_manifest.py +++ b/tests/test_manifest.py @@ -1,10 +1,20 @@ """Tests for manifest library.""" +import json from pathlib import Path +import pytest import yaml -from flux_local.manifest import HelmRelease, HelmRepository +from flux_local.manifest import ( + Cluster, + HelmRelease, + HelmRepository, + Manifest, + ManifestException, + read_manifest, + write_manifest, +) TESTDATA_DIR = Path("tests/testdata/helm-repo") @@ -34,3 +44,40 @@ def test_parse_helm_repository() -> None: assert repo.name == "bitnami" assert repo.namespace == "flux-system" assert repo.url == "https://charts.bitnami.com/bitnami" + + +async def test_read_manifest_invalid_file() -> None: + """Test reading an invalid manifest file.""" + with pytest.raises(ManifestException, match="Manifest file malformed"): + await read_manifest(Path("/dev/null")) + + +async def test_write_manifest_file() -> None: + """Test reading an invalid manifest file.""" + await write_manifest(Path("/dev/null"), Manifest(clusters=[])) + + +async def test_read_write_empty_manifest(tmp_path: Path) -> None: + """Test serializing and reading back a manifest.""" + manifest = Manifest(clusters=[]) + await write_manifest(tmp_path / "file.yaml", manifest) + new_manifest = await read_manifest(tmp_path / "file.yaml") + assert not new_manifest.clusters + + +async def test_read_write_manifest(tmp_path: Path) -> None: + """Test serializing and reading back a manifest.""" + manifest = Manifest( + clusters=[Cluster(name="cluster", path="./example", kustomizations=[])] + ) + await write_manifest(tmp_path / "file.yaml", manifest) + new_manifest = await read_manifest(tmp_path / "file.yaml") + assert json.loads(new_manifest.json()) == { + "clusters": [ + { + "name": "cluster", + "path": "./example", + "kustomizations": [], + }, + ] + }