Files
recipe-maintainer/lib/recipe.py
autonomic-bot f283a371bb recipe-maintainer: public snapshot (secrets + deployment plans removed, single commit)
Sanitized single-commit public mirror of recipe-maintainer.
- Removed test-ssh/.testenv (live creds); added test-ssh/.testenv.example placeholders.
- Removed plans/ and planned-updates/ (deployment-planning docs) so no client/
  deployment domains appear in the public repo.
- All other secret stores were already gitignored.
- docs.coopcloud.tech retained as a submodule (public upstream).
2026-06-16 20:18:24 +00:00

187 lines
5.9 KiB
Python

"""Recipe version parsing, compose.yml reading, and metadata.
Handles the Co-op Cloud version format: <recipe-semver>+v<upstream-version>
Example: 0.2.6+v4.5.0
"""
import os
import re
import subprocess
from dataclasses import dataclass
from pathlib import Path
from lib.config import paths
# ---------------------------------------------------------------------------
# Version parsing
# ---------------------------------------------------------------------------
@dataclass
class RecipeVersion:
"""Parses and manipulates Co-op Cloud version strings.
Format: <major>.<minor>.<patch>+v<upstream_version>
Example: 0.2.6+v4.5.0
"""
recipe_major: int
recipe_minor: int
recipe_patch: int
upstream_version: str
@classmethod
def parse(cls, version_string: str) -> "RecipeVersion":
"""Parse a version string like '0.2.6+v4.5.0'."""
# Split on '+v' to separate recipe semver from upstream
if "+v" in version_string:
recipe_part, upstream = version_string.split("+v", 1)
elif "+" in version_string:
recipe_part, upstream = version_string.split("+", 1)
if upstream.startswith("v"):
upstream = upstream[1:]
else:
recipe_part = version_string
upstream = ""
parts = recipe_part.split(".")
if len(parts) != 3:
raise ValueError(
f"Invalid recipe version format: {version_string}"
)
return cls(
recipe_major=int(parts[0]),
recipe_minor=int(parts[1]),
recipe_patch=int(parts[2]),
upstream_version=upstream,
)
def bump(self, level: str) -> "RecipeVersion":
"""Return a new version with the specified level bumped.
level: "patch", "minor", or "major"
"""
if level == "patch":
return RecipeVersion(
self.recipe_major, self.recipe_minor,
self.recipe_patch + 1, self.upstream_version,
)
elif level == "minor":
return RecipeVersion(
self.recipe_major, self.recipe_minor + 1,
0, self.upstream_version,
)
elif level == "major":
return RecipeVersion(
self.recipe_major + 1, 0, 0,
self.upstream_version,
)
else:
raise ValueError(f"Invalid bump level: {level}")
def with_upstream(self, upstream: str) -> "RecipeVersion":
"""Return a new version with a different upstream version."""
return RecipeVersion(
self.recipe_major, self.recipe_minor,
self.recipe_patch, upstream,
)
def __str__(self) -> str:
base = f"{self.recipe_major}.{self.recipe_minor}.{self.recipe_patch}"
if self.upstream_version:
return f"{base}+v{self.upstream_version}"
return base
# ---------------------------------------------------------------------------
# Recipe directory operations
# ---------------------------------------------------------------------------
def recipe_dir(recipe: str) -> Path:
"""Return the abra recipe checkout directory."""
return paths.ABRA_RECIPES_DIR / recipe
def compose_path(recipe: str) -> Path:
"""Return the path to a recipe's compose.yml."""
return recipe_dir(recipe) / "compose.yml"
def has_local_changes(recipe: str) -> bool:
"""Check if a recipe checkout has uncommitted changes."""
d = recipe_dir(recipe)
if not d.exists():
return False
result = subprocess.run(
["git", "-C", str(d), "status", "--short"],
capture_output=True, text=True, timeout=10,
)
return bool(result.stdout.strip())
def read_compose_version(recipe: str) -> RecipeVersion | None:
"""Read the version label from a recipe's compose.yml.
Looks for: coop-cloud.${STACK_NAME}.version=X.Y.Z+vU.P.S
"""
p = compose_path(recipe)
if not p.exists():
return None
with open(p) as f:
content = f.read()
# Match the version label pattern
m = re.search(r'coop-cloud\.\$\{STACK_NAME\}\.version=([^\s"]+)', content)
if m:
return RecipeVersion.parse(m.group(1))
return None
def write_compose_version(recipe: str, version: RecipeVersion) -> None:
"""Update the version label in a recipe's compose.yml."""
p = compose_path(recipe)
with open(p) as f:
content = f.read()
content = re.sub(
r'(coop-cloud\.\$\{STACK_NAME\}\.version=)[^\s"]+',
rf'\g<1>{version}',
content,
)
with open(p, "w") as f:
f.write(content)
def read_compose_images(recipe: str) -> dict[str, str]:
"""Read service→image:tag mapping from compose.yml.
Returns a dict like {"app": "cryptpad/cryptpad:version-2025.9.0"}.
Simple regex-based parsing (not full YAML).
"""
p = compose_path(recipe)
if not p.exists():
return {}
images = {}
current_service = None
with open(p) as f:
in_services = False
indent_level = 0
for line in f:
stripped = line.strip()
# Detect services: block
if stripped == "services:":
in_services = True
continue
if in_services:
# Service name: indented once, ends with ":"
if line.startswith(" ") and not line.startswith(" ") and stripped.endswith(":"):
current_service = stripped.rstrip(":")
# Image line: indented under a service
elif current_service and "image:" in stripped:
image = stripped.split("image:", 1)[1].strip()
images[current_service] = image
# New top-level block ends services
elif not line.startswith(" ") and stripped and stripped != "---":
in_services = False
current_service = None
return images