faas-gitops 0.2.1__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. {faas_gitops-0.2.1/src/faas_gitops.egg-info → faas_gitops-0.3.0}/PKG-INFO +1 -1
  2. {faas_gitops-0.2.1 → faas_gitops-0.3.0}/pyproject.toml +1 -1
  3. {faas_gitops-0.2.1 → faas_gitops-0.3.0}/src/faas_gitops/adapters/bizy_function_provider.py +7 -0
  4. {faas_gitops-0.2.1 → faas_gitops-0.3.0}/src/faas_gitops/cli/app.py +10 -3
  5. {faas_gitops-0.2.1 → faas_gitops-0.3.0}/src/faas_gitops/cli/presenters.py +12 -1
  6. {faas_gitops-0.2.1 → faas_gitops-0.3.0}/src/faas_gitops/layout.py +1 -0
  7. {faas_gitops-0.2.1 → faas_gitops-0.3.0}/src/faas_gitops/models.py +10 -0
  8. {faas_gitops-0.2.1 → faas_gitops-0.3.0}/src/faas_gitops/protocols/faas_function.py +13 -0
  9. {faas_gitops-0.2.1 → faas_gitops-0.3.0}/src/faas_gitops/usecases/apply_function_changes.py +3 -1
  10. faas_gitops-0.3.0/src/faas_gitops/usecases/deploy_functions.py +58 -0
  11. {faas_gitops-0.2.1 → faas_gitops-0.3.0}/src/faas_gitops/usecases/detect_drift.py +35 -10
  12. {faas_gitops-0.2.1 → faas_gitops-0.3.0}/src/faas_gitops/usecases/detect_function_drift.py +2 -2
  13. {faas_gitops-0.2.1 → faas_gitops-0.3.0/src/faas_gitops.egg-info}/PKG-INFO +1 -1
  14. {faas_gitops-0.2.1 → faas_gitops-0.3.0}/src/faas_gitops.egg-info/SOURCES.txt +2 -0
  15. {faas_gitops-0.2.1 → faas_gitops-0.3.0}/tests/test_apply_function_changes.py +8 -4
  16. faas_gitops-0.3.0/tests/test_deploy_functions.py +114 -0
  17. {faas_gitops-0.2.1 → faas_gitops-0.3.0}/tests/test_detect_function_drift.py +59 -0
  18. {faas_gitops-0.2.1 → faas_gitops-0.3.0}/MANIFEST.in +0 -0
  19. {faas_gitops-0.2.1 → faas_gitops-0.3.0}/setup.cfg +0 -0
  20. {faas_gitops-0.2.1 → faas_gitops-0.3.0}/src/faas_gitops/__init__.py +0 -0
  21. {faas_gitops-0.2.1 → faas_gitops-0.3.0}/src/faas_gitops/adapters/__init__.py +0 -0
  22. {faas_gitops-0.2.1 → faas_gitops-0.3.0}/src/faas_gitops/adapters/bizy_template_provider.py +0 -0
  23. {faas_gitops-0.2.1 → faas_gitops-0.3.0}/src/faas_gitops/adapters/git_cli_gateway.py +0 -0
  24. {faas_gitops-0.2.1 → faas_gitops-0.3.0}/src/faas_gitops/cli/__init__.py +0 -0
  25. {faas_gitops-0.2.1 → faas_gitops-0.3.0}/src/faas_gitops/cli/parser.py +0 -0
  26. {faas_gitops-0.2.1 → faas_gitops-0.3.0}/src/faas_gitops/config.py +0 -0
  27. {faas_gitops-0.2.1 → faas_gitops-0.3.0}/src/faas_gitops/filtering.py +0 -0
  28. {faas_gitops-0.2.1 → faas_gitops-0.3.0}/src/faas_gitops/infra/__init__.py +0 -0
  29. {faas_gitops-0.2.1 → faas_gitops-0.3.0}/src/faas_gitops/infra/yaml_utils.py +0 -0
  30. {faas_gitops-0.2.1 → faas_gitops-0.3.0}/src/faas_gitops/protocols/__init__.py +0 -0
  31. {faas_gitops-0.2.1 → faas_gitops-0.3.0}/src/faas_gitops/protocols/faas_template.py +0 -0
  32. {faas_gitops-0.2.1 → faas_gitops-0.3.0}/src/faas_gitops/protocols/git_gateway.py +0 -0
  33. {faas_gitops-0.2.1 → faas_gitops-0.3.0}/src/faas_gitops/usecases/__init__.py +0 -0
  34. {faas_gitops-0.2.1 → faas_gitops-0.3.0}/src/faas_gitops/usecases/apply_template_changes.py +0 -0
  35. {faas_gitops-0.2.1 → faas_gitops-0.3.0}/src/faas_gitops/usecases/detect_template_drift.py +0 -0
  36. {faas_gitops-0.2.1 → faas_gitops-0.3.0}/src/faas_gitops/usecases/render_definitions.py +0 -0
  37. {faas_gitops-0.2.1 → faas_gitops-0.3.0}/src/faas_gitops/usecases/sync_functions.py +0 -0
  38. {faas_gitops-0.2.1 → faas_gitops-0.3.0}/src/faas_gitops/usecases/sync_templates.py +0 -0
  39. {faas_gitops-0.2.1 → faas_gitops-0.3.0}/src/faas_gitops.egg-info/dependency_links.txt +0 -0
  40. {faas_gitops-0.2.1 → faas_gitops-0.3.0}/src/faas_gitops.egg-info/entry_points.txt +0 -0
  41. {faas_gitops-0.2.1 → faas_gitops-0.3.0}/src/faas_gitops.egg-info/requires.txt +0 -0
  42. {faas_gitops-0.2.1 → faas_gitops-0.3.0}/src/faas_gitops.egg-info/top_level.txt +0 -0
  43. {faas_gitops-0.2.1 → faas_gitops-0.3.0}/tests/test_apply_template_changes.py +0 -0
  44. {faas_gitops-0.2.1 → faas_gitops-0.3.0}/tests/test_detect_drift.py +0 -0
  45. {faas_gitops-0.2.1 → faas_gitops-0.3.0}/tests/test_git_cli_gateway.py +0 -0
  46. {faas_gitops-0.2.1 → faas_gitops-0.3.0}/tests/test_render_definitions.py +0 -0
  47. {faas_gitops-0.2.1 → faas_gitops-0.3.0}/tests/test_sync_functions.py +0 -0
  48. {faas_gitops-0.2.1 → faas_gitops-0.3.0}/tests/test_sync_templates.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: faas-gitops
3
- Version: 0.2.1
3
+ Version: 0.3.0
4
4
  Summary: Declarative GitOps for FaaS
5
5
  Requires-Python: >=3.12
6
6
  Requires-Dist: aiofiles>=23.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "faas-gitops"
7
- version = "0.2.1"
7
+ version = "0.3.0"
8
8
  description = "Declarative GitOps for FaaS"
9
9
  requires-python = ">=3.12"
10
10
  dependencies = [
@@ -29,3 +29,10 @@ class BizyFunctionProvider:
29
29
  name=meta.get("functionName"),
30
30
  tags=meta.get("tags"),
31
31
  )
32
+
33
+ async def list_deployments(self, func_id: str) -> list[dict]:
34
+ result = await self._p.list_deployments(function_id=func_id)
35
+ return [dict(d) for d in (result.data.data or []) if d is not None]
36
+
37
+ async def deploy_version(self, func_id: str, deployment_id: str, version: str) -> None:
38
+ await self._p.deploy(version=version, function_id=func_id, deployment_id=deployment_id)
@@ -12,11 +12,12 @@ from faas_gitops.adapters.bizy_function_provider import BizyFunctionProvider
12
12
  from faas_gitops.adapters.bizy_template_provider import BizyTemplateProvider
13
13
  from faas_gitops.adapters.git_cli_gateway import GitCliGateway
14
14
  from faas_gitops.cli.parser import build_parser
15
- from faas_gitops.cli.presenters import print_apply_report, print_drift_report
15
+ from faas_gitops.cli.presenters import print_apply_report, print_deploy_report, print_drift_report
16
16
  from faas_gitops.config import load_settings
17
17
  from faas_gitops.models import ApplyResult
18
18
  from faas_gitops.usecases.apply_function_changes import ApplyFunctionChanges
19
19
  from faas_gitops.usecases.apply_template_changes import ApplyTemplateChanges
20
+ from faas_gitops.usecases.deploy_functions import DeployFunctions
20
21
  from faas_gitops.usecases.detect_function_drift import DetectFunctionDrift
21
22
  from faas_gitops.usecases.detect_template_drift import DetectTemplateDrift
22
23
  from faas_gitops.usecases.render_definitions import render_definitions
@@ -95,10 +96,16 @@ async def async_main() -> int:
95
96
  faas = await _make_faas(stack)
96
97
  git = _make_git()
97
98
  repo_path = await git.clone_repo(repo_slug)
99
+ dry_run = getattr(args, "dry_run", False)
98
100
  result = await ApplyFunctionChanges(BizyFunctionProvider(faas), settings).run(
99
- repo_path, dry_run=getattr(args, "dry_run", False), repo_slug=repo_slug,
101
+ repo_path, dry_run=dry_run, repo_slug=repo_slug,
100
102
  )
101
- return print_apply_report(result, getattr(args, "dry_run", False))
103
+ if not dry_run and result.applied_versions:
104
+ deploy_result = await DeployFunctions(BizyFunctionProvider(faas), settings).run(
105
+ result.applied_versions, repo_path
106
+ )
107
+ print_deploy_report(deploy_result)
108
+ return print_apply_report(result, dry_run)
102
109
 
103
110
  logger.warning(f"unknown command: {args.command}")
104
111
  return 1
@@ -2,7 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  from loguru import logger
4
4
 
5
- from faas_gitops.models import ApplyResult, DriftResult
5
+ from faas_gitops.models import ApplyResult, DeployResult, DriftResult
6
6
 
7
7
 
8
8
  def print_drift_report(result: DriftResult) -> int:
@@ -28,3 +28,14 @@ def print_apply_report(result: ApplyResult, dry_run: bool = False) -> int:
28
28
  for name, err in result.failed:
29
29
  logger.warning(f" ! {name}: {err}")
30
30
  return 1 if result.failed else 0
31
+
32
+
33
+ def print_deploy_report(result: DeployResult) -> int:
34
+ logger.info(f"deployed={len(result.deployed)} skipped={len(result.skipped)} failed={len(result.failed)}")
35
+ for n in result.deployed:
36
+ logger.info(f" → {n}")
37
+ for n in result.skipped:
38
+ logger.info(f" = {n}")
39
+ for name, err in result.failed:
40
+ logger.warning(f" ! {name}: {err}")
41
+ return 1 if result.failed else 0
@@ -10,3 +10,4 @@ FUNCTION_BASE = "base.yaml"
10
10
  FUNCTION_DEFINITION = "definition.yaml"
11
11
  FUNCTION_FILES = (FUNCTION_META, FUNCTION_BASE, FUNCTION_DEFINITION)
12
12
  FUNCTION_RENDERED = "rendered.yaml"
13
+ AUTO_DEPLOY_TAGS = ("bizytrd_auto_deploy",)
@@ -82,6 +82,16 @@ class ApplyResult:
82
82
  """Container for apply operation results."""
83
83
 
84
84
  applied: list[str] = field(default_factory=list)
85
+ applied_versions: dict[str, str] = field(default_factory=dict)
85
86
  unchanged: list[str] = field(default_factory=list)
86
87
  skipped: list[str] = field(default_factory=list)
87
88
  failed: list[tuple[str, str]] = field(default_factory=list)
89
+
90
+
91
+ @dataclass
92
+ class DeployResult:
93
+ """Container for deploy operation results."""
94
+
95
+ deployed: list[str] = field(default_factory=list)
96
+ skipped: list[str] = field(default_factory=list)
97
+ failed: list[tuple[str, str]] = field(default_factory=list)
@@ -51,3 +51,16 @@ class FaaSFunctionProvider(Protocol):
51
51
  meta: Dict with 'functionName' and/or 'tags' keys.
52
52
  """
53
53
  ...
54
+
55
+ async def list_deployments(self, func_id: str) -> list[dict]:
56
+ """List all deployments for a function.
57
+
58
+ Returns:
59
+ List of deployment dicts with 'id', 'status', 'definition_version'.
60
+ """
61
+ ...
62
+
63
+ async def deploy_version(self, func_id: str, deployment_id: str, version: str) -> None:
64
+ """Deploy a specific definition version to a deployment (fire-and-forget).
65
+ """
66
+ ...
@@ -51,7 +51,9 @@ class ApplyFunctionChanges:
51
51
  if parts.definition is not None:
52
52
  def_payload = parts.to_faas_definition(comment=comment)
53
53
  if def_payload is not None:
54
- await self._provider.push_definition(fid, def_payload)
54
+ version = await self._provider.push_definition(fid, def_payload)
55
+ if version is not None:
56
+ result.applied_versions[fid] = str(version)
55
57
  await self._provider.update_function_meta(fid, parts.to_faas_meta())
56
58
  result.applied.append(fid)
57
59
  return result
@@ -0,0 +1,58 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from loguru import logger
6
+
7
+ from faas_gitops.config import Settings
8
+ from faas_gitops.layout import AUTO_DEPLOY_TAGS, FUNCTION_DIR, FUNCTION_META
9
+ from faas_gitops.models import DeployResult
10
+ from faas_gitops.protocols.faas_function import FaaSFunctionProvider
11
+ from faas_gitops.usecases.sync_functions import read_function_from_dir
12
+
13
+
14
+ class DeployFunctions:
15
+ """Auto-deploy drifted functions tagged with AUTO_DEPLOY_TAG.
16
+
17
+ For each applied function whose meta.yaml tags include AUTO_DEPLOY_TAG,
18
+ deploys the pushed version to every non-terminated deployment.
19
+ Skips deployments already at the target version (idempotency guard).
20
+ Fire-and-forget: triggers deploy without waiting for readiness.
21
+ """
22
+
23
+ def __init__(self, provider: FaaSFunctionProvider, settings: Settings) -> None:
24
+ self._provider = provider
25
+ self._settings = settings
26
+
27
+ async def run(self, applied_versions: dict[str, str], repo_path: Path) -> DeployResult:
28
+ result = DeployResult()
29
+ for fid, version in applied_versions.items():
30
+ try:
31
+ parts = await read_function_from_dir(repo_path / FUNCTION_DIR / fid)
32
+ tags = parts.meta.get("tags") or []
33
+ if not set(AUTO_DEPLOY_TAGS) & set(tags):
34
+ continue
35
+ deployments = await self._provider.list_deployments(fid)
36
+ active = [d for d in deployments if d.get("status") != "terminated"]
37
+ if not active:
38
+ logger.warning(f" ! {fid}: no active deployments")
39
+ result.skipped.append(fid)
40
+ continue
41
+ for dep in active:
42
+ deploy_id = dep["id"]
43
+ current = str(dep.get("definition_version", ""))
44
+ if current == str(version):
45
+ logger.info(f" = {fid}/{deploy_id}: already at version {version}")
46
+ result.skipped.append(f"{fid}/{deploy_id}")
47
+ continue
48
+ try:
49
+ await self._provider.deploy_version(fid, deploy_id, version)
50
+ logger.info(f" → {fid}/{deploy_id}: {current} → {version}")
51
+ result.deployed.append(f"{fid}/{deploy_id}")
52
+ except Exception as e:
53
+ logger.error(f" ✗ {fid}/{deploy_id}: {e}")
54
+ result.failed.append((f"{fid}/{deploy_id}", str(e)))
55
+ except Exception as e:
56
+ logger.error(f" ✗ {fid}: {e}")
57
+ result.failed.append((fid, str(e)))
58
+ return result
@@ -27,35 +27,57 @@ async def run_git_diff(repo_path: Path, base: str, head: str, prefix: str) -> st
27
27
  return stdout.decode()
28
28
 
29
29
 
30
- def parse_git_diff(text: str, prefix: str, depth: int, *, exclude_suffixes: tuple[str, ...] = ()) -> DriftResult:
31
- result = DriftResult(diff_text=text)
30
+ def parse_git_diff(
31
+ text: str,
32
+ prefix: str,
33
+ depth: int,
34
+ *,
35
+ exclude_suffixes: tuple[str, ...] = (),
36
+ include_suffixes: tuple[str, ...] = (),
37
+ ) -> DriftResult:
32
38
  if not text:
33
- return result
39
+ return DriftResult()
34
40
 
35
41
  seen: dict[str, str] = {}
42
+ sections: list[tuple[str, str]] = []
36
43
  current_path: str | None = None
37
44
  current_status: str | None = None
45
+ current_lines: list[str] = []
46
+
38
47
  for line in text.splitlines():
39
48
  m = _DIFF_HEADER.match(line)
40
49
  if m:
41
- if current_path and current_status:
42
- seen[current_path] = current_status
50
+ if current_path:
51
+ if current_status:
52
+ seen[current_path] = current_status
53
+ sections.append((current_path, "\n".join(current_lines)))
43
54
  current_path = m.group(1)
44
55
  current_status = "modified"
56
+ current_lines = [line]
45
57
  continue
46
58
  if current_path:
59
+ current_lines.append(line)
47
60
  if _NEW_FILE.match(line):
48
61
  current_status = "missing_in_faas"
49
62
  elif _DELETED_FILE.match(line):
50
63
  current_status = "missing_in_git"
51
- if current_path and current_status:
52
- seen[current_path] = current_status
64
+ if current_path:
65
+ if current_status:
66
+ seen[current_path] = current_status
67
+ sections.append((current_path, "\n".join(current_lines)))
53
68
 
54
- items: dict[str, str] = {}
55
- for path, status in seen.items():
69
+ def _path_kept(path: str) -> bool:
56
70
  if not path.startswith(prefix + "/"):
57
- continue
71
+ return False
72
+ if include_suffixes and not any(path.endswith(s) for s in include_suffixes):
73
+ return False
58
74
  if exclude_suffixes and any(path.endswith(s) for s in exclude_suffixes):
75
+ return False
76
+ return True
77
+
78
+ items: dict[str, str] = {}
79
+ for path, status in seen.items():
80
+ if not _path_kept(path):
59
81
  continue
60
82
  rel = path[len(prefix) + 1:]
61
83
  parts = rel.split("/")
@@ -66,6 +88,9 @@ def parse_git_diff(text: str, prefix: str, depth: int, *, exclude_suffixes: tupl
66
88
  continue
67
89
  items[item_id] = status
68
90
 
91
+ filtered_text = "\n".join(section for path, section in sections if _path_kept(path))
92
+
93
+ result = DriftResult(diff_text=filtered_text)
69
94
  for item_id, status in items.items():
70
95
  if status == "missing_in_faas":
71
96
  result.missing_in_faas.append(item_id)
@@ -8,7 +8,7 @@ import yaml
8
8
 
9
9
  from faas_gitops.config import Settings
10
10
  from faas_gitops.filtering import match_scope
11
- from faas_gitops.layout import FUNCTION_DIR, FUNCTION_META
11
+ from faas_gitops.layout import FUNCTION_DIR, FUNCTION_META, FUNCTION_RENDERED
12
12
  from faas_gitops.models import DriftResult
13
13
  from faas_gitops.usecases.detect_drift import parse_git_diff, run_git_diff
14
14
 
@@ -20,7 +20,7 @@ class DetectFunctionDrift:
20
20
 
21
21
  async def run(self, repo_path: Path, base: str = "main", sync_branch: str = "sync/faas-functions") -> DriftResult:
22
22
  text = await run_git_diff(repo_path, base, sync_branch, FUNCTION_DIR)
23
- result = parse_git_diff(text, FUNCTION_DIR, depth=1, exclude_suffixes=())
23
+ result = parse_git_diff(text, FUNCTION_DIR, depth=1, include_suffixes=(FUNCTION_RENDERED,))
24
24
  if not any((result.drifted, result.missing_in_git, result.missing_in_faas)):
25
25
  return result
26
26
  await self._apply_scope(result, repo_path)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: faas-gitops
3
- Version: 0.2.1
3
+ Version: 0.3.0
4
4
  Summary: Declarative GitOps for FaaS
5
5
  Requires-Python: >=3.12
6
6
  Requires-Dist: aiofiles>=23.0
@@ -28,6 +28,7 @@ src/faas_gitops/protocols/git_gateway.py
28
28
  src/faas_gitops/usecases/__init__.py
29
29
  src/faas_gitops/usecases/apply_function_changes.py
30
30
  src/faas_gitops/usecases/apply_template_changes.py
31
+ src/faas_gitops/usecases/deploy_functions.py
31
32
  src/faas_gitops/usecases/detect_drift.py
32
33
  src/faas_gitops/usecases/detect_function_drift.py
33
34
  src/faas_gitops/usecases/detect_template_drift.py
@@ -36,6 +37,7 @@ src/faas_gitops/usecases/sync_functions.py
36
37
  src/faas_gitops/usecases/sync_templates.py
37
38
  tests/test_apply_function_changes.py
38
39
  tests/test_apply_template_changes.py
40
+ tests/test_deploy_functions.py
39
41
  tests/test_detect_drift.py
40
42
  tests/test_detect_function_drift.py
41
43
  tests/test_git_cli_gateway.py
@@ -9,6 +9,7 @@ import pytest
9
9
  import yaml
10
10
 
11
11
  from faas_gitops.config import Settings
12
+ from faas_gitops.layout import FUNCTION_RENDERED
12
13
  from faas_gitops.models import FunctionParts
13
14
  from faas_gitops.usecases.apply_function_changes import ApplyFunctionChanges
14
15
  from faas_gitops.usecases.sync_functions import read_function_from_dir
@@ -29,14 +30,15 @@ def _make_repo(tmp_path: Path) -> Path:
29
30
  (func_dir / "meta.yaml").write_text("functionName: my-func\ntags:\n- gitops\n")
30
31
  (func_dir / "base.yaml").write_text("templateName: some-template\n")
31
32
  (func_dir / "definition.yaml").write_text("repo: siliconflow/test\n")
33
+ (func_dir / FUNCTION_RENDERED).write_text("old: rendered\n")
32
34
  (r / "README").write_text("hi")
33
35
  _git("add", ".", cwd=r)
34
36
  _git("commit", "-m", "init", cwd=r)
35
37
  _git("checkout", "-b", "sync/faas-functions", cwd=r)
36
38
  _git("checkout", "main", cwd=r)
37
- (func_dir / "meta.yaml").write_text("functionName: my-func-updated\ntags:\n- gitops\n")
39
+ (func_dir / FUNCTION_RENDERED).write_text("new: rendered\n")
38
40
  _git("add", ".", cwd=r)
39
- _git("commit", "-m", "update fn1 on main", cwd=r)
41
+ _git("commit", "-m", "update fn1 rendered.yaml on main", cwd=r)
40
42
  _git("remote", "add", "origin", str(r), cwd=r)
41
43
  _git("fetch", "origin", cwd=r)
42
44
  return r
@@ -93,10 +95,11 @@ class TestApplyFunctionChanges:
93
95
  repo = _make_repo(tmp_path)
94
96
  provider = MagicMock()
95
97
  provider.get_latest_definition = AsyncMock(return_value={"metadata": {"templateName": "tpl"}, "spec": {"data": {"repo": "old"}}})
96
- provider.push_definition = AsyncMock()
98
+ provider.push_definition = AsyncMock(return_value="5")
97
99
  provider.update_function_meta = AsyncMock()
98
100
  result = await ApplyFunctionChanges(provider, Settings()).run(repo, repo_slug="org/repo")
99
101
  assert result.applied == ["fn1"]
102
+ assert result.applied_versions == {"fn1": "5"}
100
103
  assert not result.failed
101
104
  provider.push_definition.assert_called_once()
102
105
  provider.update_function_meta.assert_called_once()
@@ -110,6 +113,7 @@ class TestApplyFunctionChanges:
110
113
  result = await ApplyFunctionChanges(provider, Settings()).run(repo, dry_run=True, repo_slug="org/repo")
111
114
  assert result.skipped == ["fn1"]
112
115
  assert not result.applied
116
+ assert result.applied_versions == {}
113
117
  provider.push_definition.assert_not_called()
114
118
  provider.update_function_meta.assert_not_called()
115
119
 
@@ -146,7 +150,7 @@ class TestApplyFunctionChanges:
146
150
  _git("checkout", "-b", "sync/faas-functions", cwd=r)
147
151
  func_dir = r / "functions" / "fn1"
148
152
  func_dir.mkdir(parents=True)
149
- (func_dir / "meta.yaml").write_text("functionName: my-func\ntags:\n- gitops\n")
153
+ (func_dir / FUNCTION_RENDERED).write_text("old: rendered\n")
150
154
  _git("add", ".", cwd=r)
151
155
  _git("commit", "-m", "add fn1 on sync", cwd=r)
152
156
  _git("checkout", "main", cwd=r)
@@ -0,0 +1,114 @@
1
+ from __future__ import annotations
2
+
3
+ import tempfile
4
+ from pathlib import Path
5
+ from unittest.mock import AsyncMock, MagicMock
6
+
7
+ import pytest
8
+
9
+ from faas_gitops.config import Settings
10
+ from faas_gitops.layout import FUNCTION_DIR, FUNCTION_META
11
+ from faas_gitops.usecases.deploy_functions import DeployFunctions
12
+
13
+
14
+ def _make_repo(*funcs: tuple[str, str]) -> Path:
15
+ repo = Path(tempfile.mkdtemp(prefix="faas-test-"))
16
+ for fid, meta_content in funcs:
17
+ d = repo / FUNCTION_DIR / fid
18
+ d.mkdir(parents=True)
19
+ (d / FUNCTION_META).write_text(meta_content)
20
+ return repo
21
+
22
+
23
+ _META_WITH_TAG = "functionName: f1\ntags:\n- bizytrd_auto_deploy\n"
24
+ _META_WITHOUT_TAG = "functionName: f2\ntags:\n- other\n"
25
+
26
+
27
+ class TestDeployFunctions:
28
+ @pytest.mark.asyncio
29
+ async def test_no_tag_skipped(self) -> None:
30
+ repo = _make_repo(("fn1", _META_WITHOUT_TAG))
31
+ provider = MagicMock()
32
+ provider.list_deployments = AsyncMock()
33
+ provider.deploy_version = AsyncMock()
34
+ result = await DeployFunctions(provider, Settings()).run({"fn1": "5"}, repo)
35
+ assert result.deployed == []
36
+ assert result.skipped == []
37
+ provider.list_deployments.assert_not_called()
38
+
39
+ @pytest.mark.asyncio
40
+ async def test_tag_with_active_dep_gets_deployed(self) -> None:
41
+ repo = _make_repo(("fn1", _META_WITH_TAG))
42
+ provider = MagicMock()
43
+ provider.list_deployments = AsyncMock(return_value=[
44
+ {"id": "dep1", "status": "ready", "definition_version": 3},
45
+ ])
46
+ provider.deploy_version = AsyncMock()
47
+ result = await DeployFunctions(provider, Settings()).run({"fn1": "5"}, repo)
48
+ assert "fn1/dep1" in result.deployed
49
+ provider.deploy_version.assert_called_once_with("fn1", "dep1", "5")
50
+
51
+ @pytest.mark.asyncio
52
+ async def test_idempotent_skip_when_same_version(self) -> None:
53
+ repo = _make_repo(("fn1", _META_WITH_TAG))
54
+ provider = MagicMock()
55
+ provider.list_deployments = AsyncMock(return_value=[
56
+ {"id": "dep1", "status": "ready", "definition_version": 5},
57
+ ])
58
+ provider.deploy_version = AsyncMock()
59
+ result = await DeployFunctions(provider, Settings()).run({"fn1": "5"}, repo)
60
+ assert result.deployed == []
61
+ assert "fn1/dep1" in result.skipped
62
+ provider.deploy_version.assert_not_called()
63
+
64
+ @pytest.mark.asyncio
65
+ async def test_terminated_deployments_excluded(self) -> None:
66
+ repo = _make_repo(("fn1", _META_WITH_TAG))
67
+ provider = MagicMock()
68
+ provider.list_deployments = AsyncMock(return_value=[
69
+ {"id": "dep1", "status": "terminated", "definition_version": 1},
70
+ {"id": "dep2", "status": "ready", "definition_version": 1},
71
+ ])
72
+ provider.deploy_version = AsyncMock()
73
+ result = await DeployFunctions(provider, Settings()).run({"fn1": "5"}, repo)
74
+ assert "fn1/dep2" in result.deployed
75
+ assert all("dep1" not in d for d in result.deployed)
76
+
77
+ @pytest.mark.asyncio
78
+ async def test_no_active_deployments_warns(self) -> None:
79
+ repo = _make_repo(("fn1", _META_WITH_TAG))
80
+ provider = MagicMock()
81
+ provider.list_deployments = AsyncMock(return_value=[
82
+ {"id": "dep1", "status": "terminated", "definition_version": 1},
83
+ ])
84
+ provider.deploy_version = AsyncMock()
85
+ result = await DeployFunctions(provider, Settings()).run({"fn1": "5"}, repo)
86
+ assert result.deployed == []
87
+ assert "fn1" in result.skipped
88
+
89
+ @pytest.mark.asyncio
90
+ async def test_deploy_error_continues_to_next_function(self) -> None:
91
+ repo = _make_repo(("fn1", _META_WITH_TAG), ("fn2", _META_WITH_TAG))
92
+ provider = MagicMock()
93
+ provider.list_deployments = AsyncMock(return_value=[
94
+ {"id": "dep1", "status": "ready", "definition_version": 1},
95
+ ])
96
+ provider.deploy_version = AsyncMock(side_effect=[RuntimeError("boom"), None])
97
+ result = await DeployFunctions(provider, Settings()).run({"fn1": "5", "fn2": "6"}, repo)
98
+ assert "fn2/dep1" in result.deployed
99
+ assert len(result.failed) == 1
100
+ assert result.failed[0][0] == "fn1/dep1"
101
+
102
+ @pytest.mark.asyncio
103
+ async def test_multiple_active_deployments_all_deployed(self) -> None:
104
+ repo = _make_repo(("fn1", _META_WITH_TAG))
105
+ provider = MagicMock()
106
+ provider.list_deployments = AsyncMock(return_value=[
107
+ {"id": "dep1", "status": "ready", "definition_version": 1},
108
+ {"id": "dep2", "status": "pending", "definition_version": 2},
109
+ ])
110
+ provider.deploy_version = AsyncMock()
111
+ result = await DeployFunctions(provider, Settings()).run({"fn1": "5"}, repo)
112
+ assert "fn1/dep1" in result.deployed
113
+ assert "fn1/dep2" in result.deployed
114
+ assert provider.deploy_version.call_count == 2
@@ -8,6 +8,7 @@ import pytest
8
8
 
9
9
  from faas_gitops.config import Settings
10
10
  from faas_gitops.layout import FUNCTION_DIR, FUNCTION_META
11
+ from faas_gitops.usecases import detect_function_drift as _module
11
12
  from faas_gitops.usecases.detect_function_drift import DetectFunctionDrift
12
13
 
13
14
 
@@ -51,3 +52,61 @@ class TestDetectFunctionDriftScope:
51
52
  async def test_none_tags_treated_as_empty(self) -> None:
52
53
  repo = _make_repo_with_functions(("fn3", _META_NO_TAGS))
53
54
  assert await DetectFunctionDrift(Settings(scoped_function_tags={"gitops"}))._in_scope("fn3", repo) is False
55
+
56
+
57
+ _DIFF_RENDERED_ONLY = """\
58
+ diff --git a/functions/fn1/rendered.yaml b/functions/fn1/rendered.yaml
59
+ index abc..def 100644
60
+ --- a/functions/fn1/rendered.yaml
61
+ +++ b/functions/fn1/rendered.yaml
62
+ @@ -1 +1 @@
63
+ -old: rendered
64
+ +new: rendered
65
+ """
66
+
67
+ _DIFF_META_ONLY = """\
68
+ diff --git a/functions/fn1/meta.yaml b/functions/fn1/meta.yaml
69
+ index abc..def 100644
70
+ --- a/functions/fn1/meta.yaml
71
+ +++ b/functions/fn1/meta.yaml
72
+ @@ -1,2 +1,2 @@
73
+ -functionName: old
74
+ +functionName: new
75
+ tags:
76
+ """
77
+
78
+ _DIFF_DEFINITION_ONLY = """\
79
+ diff --git a/functions/fn1/definition.yaml b/functions/fn1/definition.yaml
80
+ index abc..def 100644
81
+ --- a/functions/fn1/definition.yaml
82
+ +++ b/functions/fn1/definition.yaml
83
+ @@ -1 +1 @@
84
+ -repo: old
85
+ +repo: new
86
+ """
87
+
88
+
89
+ def _patch_run_git_diff(monkeypatch, diff_text: str) -> None:
90
+ async def _fake_run_git_diff(repo_path, base, head, prefix):
91
+ return diff_text
92
+ monkeypatch.setattr(_module, "run_git_diff", _fake_run_git_diff)
93
+
94
+
95
+ class TestDetectFunctionDriftRunRenderedOnly:
96
+ @pytest.mark.asyncio
97
+ async def test_rendered_yaml_change_triggers_drift(self, monkeypatch) -> None:
98
+ _patch_run_git_diff(monkeypatch, _DIFF_RENDERED_ONLY)
99
+ result = await DetectFunctionDrift(Settings()).run(Path("/tmp/unused"))
100
+ assert result.drifted == ["fn1"]
101
+
102
+ @pytest.mark.asyncio
103
+ async def test_meta_only_change_does_not_trigger_drift(self, monkeypatch) -> None:
104
+ _patch_run_git_diff(monkeypatch, _DIFF_META_ONLY)
105
+ result = await DetectFunctionDrift(Settings()).run(Path("/tmp/unused"))
106
+ assert result.drifted == []
107
+
108
+ @pytest.mark.asyncio
109
+ async def test_definition_only_change_does_not_trigger_drift(self, monkeypatch) -> None:
110
+ _patch_run_git_diff(monkeypatch, _DIFF_DEFINITION_ONLY)
111
+ result = await DetectFunctionDrift(Settings()).run(Path("/tmp/unused"))
112
+ assert result.drifted == []
File without changes
File without changes