Skip to content

Commit 55d905d

Browse files
committed
fix: unblock interactive dns search
1 parent e0660ab commit 55d905d

4 files changed

Lines changed: 211 additions & 19 deletions

File tree

‎o2switch_cli/cli/interactive.py‎

Lines changed: 11 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,14 @@
77
from o2switch_cli.cli.context import AppContext
88
from o2switch_cli.cli.helpers import run_guarded_interactive
99
from o2switch_cli.cli.interactive_support import (
10+
build_dns_search_suggestions,
1011
build_domain_suggestions,
11-
build_hostname_suggestions,
1212
build_subdomain_suggestions,
1313
filter_domains,
14-
filter_hostname_results,
1514
filter_subdomains,
1615
)
1716
from o2switch_cli.cli.ui import TerminalUI
18-
from o2switch_cli.core.models import DomainDescriptor, HostnameSearchResult, SubdomainDescriptor
17+
from o2switch_cli.core.models import DomainDescriptor, SubdomainDescriptor
1918

2019
MIN_PAGE_SIZE = 6
2120
MAX_PAGE_SIZE = 12
@@ -24,7 +23,6 @@
2423
@dataclass(slots=True)
2524
class InteractiveDataCache:
2625
domains: list[DomainDescriptor] | None = None
27-
dns_index: list[HostnameSearchResult] | None = None
2826
subdomains: list[SubdomainDescriptor] | None = None
2927

3028
def get_domains(self, app_context: AppContext, ui: TerminalUI) -> list[DomainDescriptor]:
@@ -33,12 +31,6 @@ def get_domains(self, app_context: AppContext, ui: TerminalUI) -> list[DomainDes
3331
self.domains = app_context.runtime().domains.list_domains()
3432
return self.domains
3533

36-
def get_dns_index(self, app_context: AppContext, ui: TerminalUI) -> list[HostnameSearchResult]:
37-
if self.dns_index is None:
38-
with ui.status("Scanning hosted subdomains and DNS zones", spinner="dots12"):
39-
self.dns_index = app_context.runtime().dns.search("")
40-
return self.dns_index
41-
4234
def get_subdomains(self, app_context: AppContext, ui: TerminalUI) -> list[SubdomainDescriptor]:
4335
if self.subdomains is None:
4436
with ui.status("Syncing hosted subdomains", spinner="dots12"):
@@ -54,8 +46,6 @@ def invalidate(
5446
) -> None:
5547
if domains:
5648
self.domains = None
57-
if dns:
58-
self.dns_index = None
5949
if subdomains:
6050
self.subdomains = None
6151

@@ -113,16 +103,18 @@ def action(active_context: AppContext, selected_choice: str = choice) -> None:
113103
render_page=lambda page_items, window: ui.print_domains(page_items, window),
114104
)
115105
elif selected_choice == "DNS: search":
116-
results = cache.get_dns_index(active_context, ui)
106+
domains = cache.get_domains(active_context, ui)
107+
subdomains = cache.get_subdomains(active_context, ui)
117108
term = ui.prompt_realtime_search(
118109
"Search hostnames, IPs, or zones",
119-
suggestions=build_hostname_suggestions(results),
120-
help_text="Realtime DNS and hosted matches update while you type",
110+
suggestions=build_dns_search_suggestions(domains, subdomains),
111+
help_text="Suggestions update while you type; Enter runs the live DNS search",
121112
)
122-
matches = filter_hostname_results(results, term)
123-
if not matches and term.strip():
124-
with ui.status("Checking live hostname availability", spinner="earth"):
125-
matches = active_context.runtime().dns.search(term)
113+
if not term.strip():
114+
ui.print_info("Enter a hostname, IP, or zone to run a DNS search.")
115+
return
116+
with ui.status("Searching hosted subdomains and DNS zones", spinner="dots12"):
117+
matches = active_context.runtime().dns.search(term)
126118
ui.browse_pages(
127119
matches,
128120
page_size=_page_size(ui),

‎o2switch_cli/cli/interactive_support.py‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,46 @@ def build_hostname_suggestions(results: Sequence[HostnameSearchResult]) -> list[
140140
return suggestions
141141

142142

143+
def build_dns_search_suggestions(
144+
domains: Sequence[DomainDescriptor],
145+
subdomains: Sequence[SubdomainDescriptor],
146+
) -> list[SearchSuggestion]:
147+
seen: set[tuple[str, str]] = set()
148+
suggestions: list[SearchSuggestion] = []
149+
150+
for item in domains:
151+
meta = f"domain · {item.type.value}"
152+
key = (item.domain, meta)
153+
if key in seen:
154+
continue
155+
seen.add(key)
156+
suggestions.append(
157+
SearchSuggestion(
158+
value=item.domain,
159+
label=item.domain,
160+
meta=meta,
161+
search_blob=_search_blob([item.domain, item.type.value, "domain"]),
162+
)
163+
)
164+
165+
for item in subdomains:
166+
meta = f"hosted · {item.root_domain}"
167+
key = (item.fqdn, meta)
168+
if key in seen:
169+
continue
170+
seen.add(key)
171+
suggestions.append(
172+
SearchSuggestion(
173+
value=item.fqdn,
174+
label=item.fqdn,
175+
meta=meta,
176+
search_blob=_search_blob([item.fqdn, item.label, item.root_domain, item.docroot, "hosted"]),
177+
)
178+
)
179+
180+
return suggestions
181+
182+
143183
def build_subdomain_suggestions(subdomains: Sequence[SubdomainDescriptor]) -> list[SearchSuggestion]:
144184
return [
145185
SearchSuggestion(

‎tests/test_interactive.py‎

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
from __future__ import annotations
2+
3+
from contextlib import contextmanager
4+
5+
from o2switch_cli.cli.context import AppContext
6+
from o2switch_cli.cli.interactive import run_interactive_menu
7+
from o2switch_cli.config.settings import AppSettings
8+
from o2switch_cli.core.models import (
9+
DomainDescriptor,
10+
DomainType,
11+
HostnameSearchResult,
12+
SearchCategory,
13+
SubdomainDescriptor,
14+
)
15+
16+
17+
def _app_context() -> AppContext:
18+
return AppContext(
19+
settings=AppSettings(),
20+
output_format="text",
21+
dry_run=False,
22+
force=False,
23+
yes=False,
24+
verbose=False,
25+
verify_after_mutation=True,
26+
allow_prompt=True,
27+
)
28+
29+
30+
@contextmanager
31+
def _noop_status(*args, **kwargs):
32+
yield None
33+
34+
35+
def test_interactive_dns_search_skips_backend_lookup_for_blank_term(monkeypatch) -> None:
36+
class FakeDomains:
37+
def list_domains(self) -> list[DomainDescriptor]:
38+
return [DomainDescriptor(domain="ginutech.com", type=DomainType.ADDON)]
39+
40+
class FakeSubdomains:
41+
def search(self, term: str) -> list[SubdomainDescriptor]:
42+
assert term == ""
43+
return [SubdomainDescriptor(fqdn="app.ginutech.com", label="app", root_domain="ginutech.com")]
44+
45+
class FakeDNS:
46+
def search(self, term: str) -> list[HostnameSearchResult]:
47+
raise AssertionError(f"dns.search should not run for blank interactive input, got {term!r}")
48+
49+
class FakeRuntime:
50+
domains = FakeDomains()
51+
subdomains = FakeSubdomains()
52+
dns = FakeDNS()
53+
54+
answers = iter(["DNS: search", "Exit"])
55+
messages: list[str] = []
56+
57+
class FakeSelect:
58+
def ask(self):
59+
return next(answers)
60+
61+
monkeypatch.setattr(AppContext, "runtime", lambda self: FakeRuntime())
62+
monkeypatch.setattr(
63+
"o2switch_cli.cli.interactive.questionary.select",
64+
lambda *args, **kwargs: FakeSelect(),
65+
)
66+
monkeypatch.setattr("o2switch_cli.cli.interactive.TerminalUI.print_banner", lambda self: None)
67+
monkeypatch.setattr(
68+
"o2switch_cli.cli.interactive.TerminalUI.prompt_realtime_search",
69+
lambda self, *args, **kwargs: "",
70+
)
71+
monkeypatch.setattr(
72+
"o2switch_cli.cli.interactive.TerminalUI.print_info",
73+
lambda self, message: messages.append(message),
74+
)
75+
monkeypatch.setattr("o2switch_cli.cli.interactive.TerminalUI.browse_pages", lambda self, *args, **kwargs: None)
76+
monkeypatch.setattr("o2switch_cli.cli.interactive.TerminalUI.status", _noop_status)
77+
78+
run_interactive_menu(_app_context())
79+
80+
assert messages == ["Enter a hostname, IP, or zone to run a DNS search."]
81+
82+
83+
def test_interactive_dns_search_queries_submitted_term_only(monkeypatch) -> None:
84+
class FakeDomains:
85+
def list_domains(self) -> list[DomainDescriptor]:
86+
return [DomainDescriptor(domain="ginutech.com", type=DomainType.ADDON)]
87+
88+
class FakeSubdomains:
89+
def search(self, term: str) -> list[SubdomainDescriptor]:
90+
assert term == ""
91+
return [SubdomainDescriptor(fqdn="app.ginutech.com", label="app", root_domain="ginutech.com")]
92+
93+
calls: list[str] = []
94+
95+
class FakeDNS:
96+
def search(self, term: str) -> list[HostnameSearchResult]:
97+
calls.append(term)
98+
return [
99+
HostnameSearchResult(
100+
category=SearchCategory.DNS_RECORDS,
101+
hostname="name.ginutech.com",
102+
record_type="A",
103+
value="5.196.30.71",
104+
zone="ginutech.com",
105+
)
106+
]
107+
108+
class FakeRuntime:
109+
domains = FakeDomains()
110+
subdomains = FakeSubdomains()
111+
dns = FakeDNS()
112+
113+
answers = iter(["DNS: search", "Exit"])
114+
browsed: list[list[HostnameSearchResult]] = []
115+
116+
class FakeSelect:
117+
def ask(self):
118+
return next(answers)
119+
120+
monkeypatch.setattr(AppContext, "runtime", lambda self: FakeRuntime())
121+
monkeypatch.setattr(
122+
"o2switch_cli.cli.interactive.questionary.select",
123+
lambda *args, **kwargs: FakeSelect(),
124+
)
125+
monkeypatch.setattr("o2switch_cli.cli.interactive.TerminalUI.print_banner", lambda self: None)
126+
monkeypatch.setattr(
127+
"o2switch_cli.cli.interactive.TerminalUI.prompt_realtime_search",
128+
lambda self, *args, **kwargs: "name.ginutech.com",
129+
)
130+
monkeypatch.setattr("o2switch_cli.cli.interactive.TerminalUI.print_info", lambda self, message: None)
131+
monkeypatch.setattr(
132+
"o2switch_cli.cli.interactive.TerminalUI.browse_pages",
133+
lambda self, items, **kwargs: browsed.append(list(items)),
134+
)
135+
monkeypatch.setattr("o2switch_cli.cli.interactive.TerminalUI.status", _noop_status)
136+
137+
run_interactive_menu(_app_context())
138+
139+
assert calls == ["name.ginutech.com"]
140+
assert [item.hostname for item in browsed[0]] == ["name.ginutech.com"]

‎tests/test_interactive_support.py‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
from o2switch_cli.cli.interactive_support import (
4+
build_dns_search_suggestions,
45
build_hostname_suggestions,
56
filter_domains,
67
filter_hostname_results,
@@ -11,6 +12,7 @@
1112
DomainType,
1213
HostnameSearchResult,
1314
SearchCategory,
15+
SubdomainDescriptor,
1416
)
1517

1618

@@ -73,3 +75,21 @@ def test_build_hostname_suggestions_deduplicates_repeat_rows() -> None:
7375
suggestions = build_hostname_suggestions(results)
7476
assert len(suggestions) == 1
7577
assert suggestions[0].value == "odoo.ginutech.com"
78+
79+
80+
def test_build_dns_search_suggestions_combines_domains_and_subdomains() -> None:
81+
domains = [
82+
DomainDescriptor(domain="ginutech.com", type=DomainType.ADDON),
83+
DomainDescriptor(domain="ginutech.com", type=DomainType.ADDON),
84+
]
85+
subdomains = [
86+
SubdomainDescriptor(fqdn="app.ginutech.com", label="app", root_domain="ginutech.com"),
87+
SubdomainDescriptor(fqdn="app.ginutech.com", label="app", root_domain="ginutech.com"),
88+
]
89+
90+
suggestions = build_dns_search_suggestions(domains, subdomains)
91+
92+
assert [(item.value, item.meta) for item in suggestions] == [
93+
("ginutech.com", "domain · addon"),
94+
("app.ginutech.com", "hosted · ginutech.com"),
95+
]

0 commit comments

Comments
 (0)