From f5565cc2ac22971f9ab774b844a2bb1fc377525a Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 5 Feb 2023 21:48:27 -0800 Subject: [PATCH] Allow reading/writing to a manifest file (#21) Allow reading/writing to a manifest file. Writing a manifest file can be useful to checkpoint all the expected primary artifacts of the cluster and used for driving tests, where just discovering artifacts form the cluster may result in unexpectedly missing some (e.g. if a kustomization is misconfigured) --- flux_local/manifest.py | 68 +++++++++++++++++++++++++++++++++++++----- setup.cfg | 2 +- tests/test_manifest.py | 49 +++++++++++++++++++++++++++++- 3 files changed, 110 insertions(+), 9 deletions(-) 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": [], + }, + ] + }