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.
- {faas_gitops-0.2.1/src/faas_gitops.egg-info → faas_gitops-0.3.0}/PKG-INFO +1 -1
- {faas_gitops-0.2.1 → faas_gitops-0.3.0}/pyproject.toml +1 -1
- {faas_gitops-0.2.1 → faas_gitops-0.3.0}/src/faas_gitops/adapters/bizy_function_provider.py +7 -0
- {faas_gitops-0.2.1 → faas_gitops-0.3.0}/src/faas_gitops/cli/app.py +10 -3
- {faas_gitops-0.2.1 → faas_gitops-0.3.0}/src/faas_gitops/cli/presenters.py +12 -1
- {faas_gitops-0.2.1 → faas_gitops-0.3.0}/src/faas_gitops/layout.py +1 -0
- {faas_gitops-0.2.1 → faas_gitops-0.3.0}/src/faas_gitops/models.py +10 -0
- {faas_gitops-0.2.1 → faas_gitops-0.3.0}/src/faas_gitops/protocols/faas_function.py +13 -0
- {faas_gitops-0.2.1 → faas_gitops-0.3.0}/src/faas_gitops/usecases/apply_function_changes.py +3 -1
- faas_gitops-0.3.0/src/faas_gitops/usecases/deploy_functions.py +58 -0
- {faas_gitops-0.2.1 → faas_gitops-0.3.0}/src/faas_gitops/usecases/detect_drift.py +35 -10
- {faas_gitops-0.2.1 → faas_gitops-0.3.0}/src/faas_gitops/usecases/detect_function_drift.py +2 -2
- {faas_gitops-0.2.1 → faas_gitops-0.3.0/src/faas_gitops.egg-info}/PKG-INFO +1 -1
- {faas_gitops-0.2.1 → faas_gitops-0.3.0}/src/faas_gitops.egg-info/SOURCES.txt +2 -0
- {faas_gitops-0.2.1 → faas_gitops-0.3.0}/tests/test_apply_function_changes.py +8 -4
- faas_gitops-0.3.0/tests/test_deploy_functions.py +114 -0
- {faas_gitops-0.2.1 → faas_gitops-0.3.0}/tests/test_detect_function_drift.py +59 -0
- {faas_gitops-0.2.1 → faas_gitops-0.3.0}/MANIFEST.in +0 -0
- {faas_gitops-0.2.1 → faas_gitops-0.3.0}/setup.cfg +0 -0
- {faas_gitops-0.2.1 → faas_gitops-0.3.0}/src/faas_gitops/__init__.py +0 -0
- {faas_gitops-0.2.1 → faas_gitops-0.3.0}/src/faas_gitops/adapters/__init__.py +0 -0
- {faas_gitops-0.2.1 → faas_gitops-0.3.0}/src/faas_gitops/adapters/bizy_template_provider.py +0 -0
- {faas_gitops-0.2.1 → faas_gitops-0.3.0}/src/faas_gitops/adapters/git_cli_gateway.py +0 -0
- {faas_gitops-0.2.1 → faas_gitops-0.3.0}/src/faas_gitops/cli/__init__.py +0 -0
- {faas_gitops-0.2.1 → faas_gitops-0.3.0}/src/faas_gitops/cli/parser.py +0 -0
- {faas_gitops-0.2.1 → faas_gitops-0.3.0}/src/faas_gitops/config.py +0 -0
- {faas_gitops-0.2.1 → faas_gitops-0.3.0}/src/faas_gitops/filtering.py +0 -0
- {faas_gitops-0.2.1 → faas_gitops-0.3.0}/src/faas_gitops/infra/__init__.py +0 -0
- {faas_gitops-0.2.1 → faas_gitops-0.3.0}/src/faas_gitops/infra/yaml_utils.py +0 -0
- {faas_gitops-0.2.1 → faas_gitops-0.3.0}/src/faas_gitops/protocols/__init__.py +0 -0
- {faas_gitops-0.2.1 → faas_gitops-0.3.0}/src/faas_gitops/protocols/faas_template.py +0 -0
- {faas_gitops-0.2.1 → faas_gitops-0.3.0}/src/faas_gitops/protocols/git_gateway.py +0 -0
- {faas_gitops-0.2.1 → faas_gitops-0.3.0}/src/faas_gitops/usecases/__init__.py +0 -0
- {faas_gitops-0.2.1 → faas_gitops-0.3.0}/src/faas_gitops/usecases/apply_template_changes.py +0 -0
- {faas_gitops-0.2.1 → faas_gitops-0.3.0}/src/faas_gitops/usecases/detect_template_drift.py +0 -0
- {faas_gitops-0.2.1 → faas_gitops-0.3.0}/src/faas_gitops/usecases/render_definitions.py +0 -0
- {faas_gitops-0.2.1 → faas_gitops-0.3.0}/src/faas_gitops/usecases/sync_functions.py +0 -0
- {faas_gitops-0.2.1 → faas_gitops-0.3.0}/src/faas_gitops/usecases/sync_templates.py +0 -0
- {faas_gitops-0.2.1 → faas_gitops-0.3.0}/src/faas_gitops.egg-info/dependency_links.txt +0 -0
- {faas_gitops-0.2.1 → faas_gitops-0.3.0}/src/faas_gitops.egg-info/entry_points.txt +0 -0
- {faas_gitops-0.2.1 → faas_gitops-0.3.0}/src/faas_gitops.egg-info/requires.txt +0 -0
- {faas_gitops-0.2.1 → faas_gitops-0.3.0}/src/faas_gitops.egg-info/top_level.txt +0 -0
- {faas_gitops-0.2.1 → faas_gitops-0.3.0}/tests/test_apply_template_changes.py +0 -0
- {faas_gitops-0.2.1 → faas_gitops-0.3.0}/tests/test_detect_drift.py +0 -0
- {faas_gitops-0.2.1 → faas_gitops-0.3.0}/tests/test_git_cli_gateway.py +0 -0
- {faas_gitops-0.2.1 → faas_gitops-0.3.0}/tests/test_render_definitions.py +0 -0
- {faas_gitops-0.2.1 → faas_gitops-0.3.0}/tests/test_sync_functions.py +0 -0
- {faas_gitops-0.2.1 → faas_gitops-0.3.0}/tests/test_sync_templates.py +0 -0
|
@@ -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=
|
|
101
|
+
repo_path, dry_run=dry_run, repo_slug=repo_slug,
|
|
100
102
|
)
|
|
101
|
-
|
|
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
|
|
@@ -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(
|
|
31
|
-
|
|
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
|
|
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
|
|
42
|
-
|
|
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
|
|
52
|
-
|
|
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
|
-
|
|
55
|
-
for path, status in seen.items():
|
|
69
|
+
def _path_kept(path: str) -> bool:
|
|
56
70
|
if not path.startswith(prefix + "/"):
|
|
57
|
-
|
|
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,
|
|
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)
|
|
@@ -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 /
|
|
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 /
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|