Skip to content

Commit 39dc9a6

Browse files
authored
[ISV-5618] Fix check_upgrade_graph_loop for community operators (#788)
* [ISV-5618] Fix check_upgrade_graph_loop - Update the static test to use proper dfs cycle detection to avoid performance issues leading to timeouts with operators using skipRange - Convert the static check into an Operator static check (as opposed to a Bundle static check) as it involves parsing multiple bundles - Fix log messages emitted by the skip_fbc decorator Signed-off-by: Maurizio Porrato <[email protected]>
1 parent b3c7d16 commit 39dc9a6

File tree

6 files changed

+152
-143
lines changed

6 files changed

+152
-143
lines changed

operator-pipeline-images/operatorcert/static_tests/community/bundle.py

-69
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
import re
1313
import subprocess
1414
from collections.abc import Iterator
15-
from typing import Any, List
1615

1716
from operator_repo import Bundle
1817
from operator_repo.checks import CheckResult, Fail, Warn
@@ -106,12 +105,6 @@ def ocp_to_k8s_ver(ocp_ver: str) -> str:
106105
return f"{k8s.major}.{k8s.minor}"
107106

108107

109-
class GraphLoopException(Exception):
110-
"""
111-
Exception raised when a loop is detected in the update graph
112-
"""
113-
114-
115108
def run_operator_sdk_bundle_validate(
116109
bundle: Bundle, test_suite_selector: str
117110
) -> Iterator[CheckResult]:
@@ -327,68 +320,6 @@ def check_api_version_constraints(bundle: Bundle) -> Iterator[CheckResult]:
327320
)
328321

329322

330-
@skip_fbc
331-
def check_upgrade_graph_loop(bundle: Bundle) -> Iterator[CheckResult]:
332-
"""
333-
Detect loops in the upgrade graph
334-
335-
Example:
336-
337-
Channel beta: A -> B -> C -> B
338-
339-
Args:
340-
bundle (Bundle): Operator bundle
341-
342-
Yields:
343-
Iterator[CheckResult]: Failure if a loop is detected
344-
"""
345-
all_channels: set[str] = set(bundle.channels)
346-
if bundle.default_channel is not None:
347-
all_channels.add(bundle.default_channel)
348-
operator = bundle.operator
349-
for channel in sorted(all_channels):
350-
visited: List[Bundle] = []
351-
try:
352-
channel_bundles = operator.channel_bundles(channel)
353-
try:
354-
graph = operator.update_graph(channel)
355-
except (NotImplementedError, ValueError) as exc:
356-
yield Fail(str(exc))
357-
return
358-
follow_graph(graph, channel_bundles[0], visited)
359-
except GraphLoopException as exc:
360-
yield Fail(str(exc))
361-
362-
363-
def follow_graph(graph: Any, bundle: Bundle, visited: List[Bundle]) -> List[Bundle]:
364-
"""
365-
Follow operator upgrade graph and raise exception if loop is detected
366-
367-
Args:
368-
graph (Any): Operator update graph
369-
bundle (Bundle): Current bundle that started the graph traversal
370-
visited (List[Bundle]): List of bundles visited so far
371-
372-
Raises:
373-
GraphLoopException: Graph loop detected
374-
375-
Returns:
376-
List[Bundle]: List of bundles visited so far
377-
"""
378-
if bundle in visited:
379-
visited.append(bundle)
380-
raise GraphLoopException(f"Upgrade graph loop detected for bundle: {visited}")
381-
if bundle not in graph:
382-
return visited
383-
384-
visited.append(bundle)
385-
next_bundles = graph[bundle]
386-
for next_bundle in next_bundles:
387-
visited_copy = visited.copy()
388-
follow_graph(graph, next_bundle, visited_copy)
389-
return visited
390-
391-
392323
@skip_fbc
393324
def check_replaces_availability(bundle: Bundle) -> Iterator[CheckResult]:
394325
"""

operator-pipeline-images/operatorcert/static_tests/community/operator.py

+75-1
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@
77
"""
88

99
from collections.abc import Iterator
10+
from typing import Dict, Optional
1011

11-
from operator_repo import Operator
12+
from operator_repo import Operator, Bundle
1213
from operator_repo.checks import CheckResult, Fail, Warn
1314
from operatorcert.static_tests.helpers import skip_fbc
1415

@@ -36,3 +37,76 @@ def check_ci_upgrade_graph(operator: Operator) -> Iterator[CheckResult]:
3637
yield Fail(
3738
f"The 'updateGraph' option in ci.yaml must be one of {allowed_graphs}"
3839
)
40+
41+
42+
@skip_fbc
43+
def check_upgrade_graph_loop(operator: Operator) -> Iterator[CheckResult]:
44+
"""
45+
Detect loops in the upgrade graph
46+
47+
Example:
48+
49+
Channel beta: A -> B -> C -> B
50+
51+
Args:
52+
operator (Operator): Operator
53+
54+
Yields:
55+
Iterator[CheckResult]: Failure if a loop is detected
56+
"""
57+
all_channels: set[str] = set()
58+
for bundle in operator.all_bundles():
59+
all_channels.update(bundle.channels)
60+
if bundle.default_channel is not None:
61+
all_channels.add(bundle.default_channel)
62+
for channel in sorted(all_channels):
63+
try:
64+
graph = operator.update_graph(channel)
65+
except (NotImplementedError, ValueError) as exc:
66+
yield Fail(str(exc))
67+
return
68+
back_edge = has_cycle(graph)
69+
if back_edge is not None:
70+
node_from, node_to = back_edge
71+
yield Fail(f"Upgrade graph loop detected: {node_from} -> {node_to}")
72+
73+
74+
def has_cycle(graph: Dict[Bundle, set[Bundle]]) -> Optional[tuple[Bundle, Bundle]]:
75+
"""
76+
Detects cycles in an update graph using regular DFS
77+
78+
Args:
79+
graph: Upgrade graph
80+
81+
Returns:
82+
- None if no cycle is detected
83+
- A tuple representing the edge (from_bundle, to_bundle) causing the cycle
84+
"""
85+
86+
def dfs(
87+
node: Bundle, visited: set[Bundle], rec_stack: set[Bundle]
88+
) -> Optional[tuple[Bundle, Bundle]]:
89+
visited.add(node)
90+
rec_stack.add(node)
91+
92+
for neighbor in graph.get(node, set()):
93+
if neighbor not in visited:
94+
result = dfs(neighbor, visited, rec_stack)
95+
if result:
96+
return result
97+
elif neighbor in rec_stack:
98+
return (node, neighbor)
99+
100+
rec_stack.remove(node)
101+
return None
102+
103+
visited: set[Bundle] = set()
104+
rec_stack: set[Bundle] = set()
105+
106+
for node in graph:
107+
if node not in visited:
108+
result = dfs(node, visited, rec_stack)
109+
if result:
110+
return result
111+
112+
return None

operator-pipeline-images/operatorcert/static_tests/helpers.py

+1-2
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,7 @@ def wrapper(*args: Any, **kwargs: Any) -> Iterator[Any]:
3131
config = operator.config if operator else {}
3232
if not config.get("fbc", {}).get("enabled", False):
3333
yield from func(*args, **kwargs)
34-
35-
if operator:
34+
else:
3635
LOGGER.info(
3736
"Skipping %s for FBC enabled operator %s",
3837
func.__name__,

operator-pipeline-images/tests/static_tests/community/test_bundle.py

+1-71
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import re
22
from pathlib import Path
3-
from typing import Any, Dict, List, Optional, Set
3+
from typing import Any, Dict, Optional, Set
44
from unittest.mock import MagicMock, patch, PropertyMock
55

66
import pytest
@@ -14,7 +14,6 @@
1414
run_operator_sdk_bundle_validate,
1515
check_api_version_constraints,
1616
check_replaces_availability,
17-
check_upgrade_graph_loop,
1817
check_using_fbc,
1918
ocp_to_k8s_ver,
2019
)
@@ -512,75 +511,6 @@ def test_check_api_version_constraints(
512511
assert set(check_api_version_constraints(bundle)) == expected
513512

514513

515-
def test_check_upgrade_graph_loop(tmp_path: Path) -> None:
516-
create_files(
517-
tmp_path,
518-
bundle_files("hello", "0.0.1"),
519-
bundle_files("hello", "0.0.2", csv={"spec": {"replaces": "hello.v0.0.1"}}),
520-
)
521-
522-
repo = Repo(tmp_path)
523-
operator = repo.operator("hello")
524-
bundle = operator.bundle("0.0.1")
525-
with patch.object(
526-
type(operator), "config", new_callable=PropertyMock
527-
) as mock_config:
528-
mock_config.return_value = {"updateGraph": "replaces-mode"}
529-
is_loop = list(check_upgrade_graph_loop(bundle))
530-
assert is_loop == []
531-
532-
with patch.object(
533-
type(operator), "config", new_callable=PropertyMock
534-
) as mock_config:
535-
mock_config.return_value = {"updateGraph": "unknown-mode"}
536-
is_loop = list(check_upgrade_graph_loop(bundle))
537-
assert is_loop == [
538-
Fail("Operator(hello): unsupported updateGraph value: unknown-mode")
539-
]
540-
541-
# Both bundles replace each other
542-
create_files(
543-
tmp_path,
544-
bundle_files("hello", "0.0.1", csv={"spec": {"replaces": "hello.v0.0.2"}}),
545-
bundle_files("hello", "0.0.2", csv={"spec": {"replaces": "hello.v0.0.1"}}),
546-
)
547-
548-
repo = Repo(tmp_path)
549-
operator = repo.operator("hello")
550-
bundle = operator.bundle("0.0.1")
551-
with patch.object(
552-
type(operator), "config", new_callable=PropertyMock
553-
) as mock_config:
554-
mock_config.return_value = {"updateGraph": "replaces-mode"}
555-
is_loop = list(check_upgrade_graph_loop(bundle))
556-
assert len(is_loop) == 1 and isinstance(is_loop[0], Fail)
557-
assert (
558-
is_loop[0].reason
559-
== "Upgrade graph loop detected for bundle: [Bundle(hello/0.0.1), "
560-
"Bundle(hello/0.0.2), Bundle(hello/0.0.1)]"
561-
)
562-
563-
# Malformed .spec.replaces
564-
create_files(
565-
tmp_path,
566-
bundle_files("malformed", "0.0.1", csv={"spec": {"replaces": ""}}),
567-
)
568-
569-
repo = Repo(tmp_path)
570-
operator = repo.operator("malformed")
571-
bundle = operator.bundle("0.0.1")
572-
with patch.object(
573-
type(operator), "config", new_callable=PropertyMock
574-
) as mock_config:
575-
mock_config.return_value = {"updateGraph": "replaces-mode"}
576-
failures = list(check_upgrade_graph_loop(bundle))
577-
assert len(failures) == 1 and isinstance(failures[0], Fail)
578-
assert (
579-
"Bundle(malformed/0.0.1) has invalid 'replaces' field:"
580-
in failures[0].reason
581-
)
582-
583-
584514
def test_check_replaces_availability_no_replaces(
585515
tmp_path: Path,
586516
) -> None:

operator-pipeline-images/tests/static_tests/community/test_operator.py

+71
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@
22
from typing import Any
33

44
import pytest
5+
from unittest.mock import patch, PropertyMock
56
from operator_repo import Repo
67
from operator_repo.checks import Fail, Warn
78
from operatorcert.static_tests.community.operator import (
89
check_ci_upgrade_graph,
910
check_operator_name_unique,
11+
check_upgrade_graph_loop,
1012
)
1113
from tests.utils import bundle_files, create_files
1214

@@ -76,3 +78,72 @@ def test_check_ci_upgrade_graph(graph_mode: str, expected: Any, tmp_path: Path)
7678
result = check_ci_upgrade_graph(operator)
7779

7880
assert set(result) == expected
81+
82+
83+
def test_check_upgrade_graph_loop(tmp_path: Path) -> None:
84+
create_files(
85+
tmp_path,
86+
bundle_files("hello", "0.0.1"),
87+
bundle_files("hello", "0.0.2", csv={"spec": {"replaces": "hello.v0.0.1"}}),
88+
)
89+
90+
repo = Repo(tmp_path)
91+
operator = repo.operator("hello")
92+
bundle = operator.bundle("0.0.1")
93+
with patch.object(
94+
type(operator), "config", new_callable=PropertyMock
95+
) as mock_config:
96+
mock_config.return_value = {"updateGraph": "replaces-mode"}
97+
is_loop = list(check_upgrade_graph_loop(operator))
98+
assert is_loop == []
99+
100+
with patch.object(
101+
type(operator), "config", new_callable=PropertyMock
102+
) as mock_config:
103+
mock_config.return_value = {"updateGraph": "unknown-mode"}
104+
is_loop = list(check_upgrade_graph_loop(operator))
105+
assert is_loop == [
106+
Fail("Operator(hello): unsupported updateGraph value: unknown-mode")
107+
]
108+
109+
# Both bundles replace each other
110+
create_files(
111+
tmp_path,
112+
bundle_files("hello", "0.0.1", csv={"spec": {"replaces": "hello.v0.0.2"}}),
113+
bundle_files("hello", "0.0.2", csv={"spec": {"replaces": "hello.v0.0.1"}}),
114+
)
115+
116+
repo = Repo(tmp_path)
117+
operator = repo.operator("hello")
118+
bundle = operator.bundle("0.0.1")
119+
with patch.object(
120+
type(operator), "config", new_callable=PropertyMock
121+
) as mock_config:
122+
mock_config.return_value = {"updateGraph": "replaces-mode"}
123+
is_loop = list(check_upgrade_graph_loop(operator))
124+
assert len(is_loop) == 1 and isinstance(is_loop[0], Fail)
125+
assert (
126+
"Upgrade graph loop detected:" in is_loop[0].reason
127+
and "Bundle(hello/0.0.1)" in is_loop[0].reason
128+
and "Bundle(hello/0.0.2)" in is_loop[0].reason
129+
)
130+
131+
# Malformed .spec.replaces
132+
create_files(
133+
tmp_path,
134+
bundle_files("malformed", "0.0.1", csv={"spec": {"replaces": ""}}),
135+
)
136+
137+
repo = Repo(tmp_path)
138+
operator = repo.operator("malformed")
139+
bundle = operator.bundle("0.0.1")
140+
with patch.object(
141+
type(operator), "config", new_callable=PropertyMock
142+
) as mock_config:
143+
mock_config.return_value = {"updateGraph": "replaces-mode"}
144+
failures = list(check_upgrade_graph_loop(operator))
145+
assert len(failures) == 1 and isinstance(failures[0], Fail)
146+
assert (
147+
"Bundle(malformed/0.0.1) has invalid 'replaces' field:"
148+
in failures[0].reason
149+
)

operator-pipeline-images/tests/static_tests/test_helpers.py

+4
Original file line numberDiff line numberDiff line change
@@ -46,13 +46,17 @@ def check_unknown(unknown: None) -> Iterator[str]:
4646
]
4747
)
4848

49+
mock_logger.reset_mock()
4950
operator.config = {"fbc": {"enabled": False}}
5051
assert list(check_bundle(bundle)) == ["processed"]
5152
assert list(check_operator(operator)) == ["processed"]
53+
mock_logger.assert_not_called()
5254

55+
mock_logger.reset_mock()
5356
operator.config = {}
5457
assert list(check_bundle(bundle)) == ["processed"]
5558
assert list(check_operator(operator)) == ["processed"]
59+
mock_logger.assert_not_called()
5660

5761
# if no operator provided wrapped func is executed
5862
assert list(check_unknown(None)) == ["processed"]

0 commit comments

Comments
 (0)