Skip to content

Commit 50a52ba

Browse files
committed
feat: implement interactive o2switch cli
1 parent d1b3921 commit 50a52ba

33 files changed

Lines changed: 2325 additions & 2 deletions

‎.gitignore‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
.venv/
2+
__pycache__/
3+
.pytest_cache/
4+
.ruff_cache/
5+
.mypy_cache/
6+
.coverage
7+
*.egg-info/
8+
.env
9+

‎README.md‎

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,50 @@
22

33
CLI interactif pour gerer domaines, sous-domaines cPanel et enregistrements DNS sur un hebergement o2switch/cPanel.
44

5-
## Specs
5+
## Installation
66

7-
Le pack de specifications projet est dans [`docs/specs/README.md`](docs/specs/README.md).
7+
```bash
8+
python3 -m venv .venv
9+
.venv/bin/python -m pip install -e '.[dev]'
10+
```
11+
12+
## Configuration
813

914
Convention de nommage:
1015

1116
- binaire CLI: `o2switch-cli`
1217
- package Python: `o2switch_cli`
1318
- variables d'environnement: `O2SWITCH_CLI_*`
19+
20+
Variables attendues:
21+
22+
```bash
23+
export O2SWITCH_CLI_CPANEL_HOST=saule.o2switch.net
24+
export O2SWITCH_CLI_CPANEL_USER=mon_user
25+
export O2SWITCH_CLI_CPANEL_TOKEN=mon_token
26+
```
27+
28+
## Usage
29+
30+
```bash
31+
.venv/bin/o2switch-cli --help
32+
.venv/bin/o2switch-cli domains list
33+
.venv/bin/o2switch-cli dns upsert --host odoo-staging.ginutech.com --ip 203.0.113.25
34+
.venv/bin/o2switch-cli dns delete --host odoo-staging.ginutech.com --dry-run
35+
.venv/bin/o2switch-cli subdomains create --root ginutech.com --label odoo-staging --ip 203.0.113.25
36+
.venv/bin/o2switch-cli config show --json
37+
```
38+
39+
Sans sous-commande, le binaire ouvre le mode interactif si le terminal est TTY.
40+
41+
## Development
42+
43+
```bash
44+
.venv/bin/ruff check .
45+
.venv/bin/ruff format .
46+
.venv/bin/pytest
47+
```
48+
49+
## Specs
50+
51+
Le pack de specifications projet est dans [`docs/specs/README.md`](docs/specs/README.md).

‎o2switch_cli/__init__.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
__all__ = ["__version__"]
2+
3+
__version__ = "0.1.0"

‎o2switch_cli/__main__.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
from o2switch_cli.cli.main import run
2+
3+
if __name__ == "__main__":
4+
run()

‎o2switch_cli/cli/__init__.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from o2switch_cli.cli.main import app, run
2+
3+
__all__ = ["app", "run"]

‎o2switch_cli/cli/config_cmd.py‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
from __future__ import annotations
2+
3+
import typer
4+
5+
from o2switch_cli.cli.helpers import run_guarded
6+
from o2switch_cli.cli.ui import TerminalUI
7+
from o2switch_cli.config.settings import settings_summary
8+
9+
app = typer.Typer(help="Inspect active configuration and API reachability.", rich_markup_mode="rich")
10+
11+
12+
@app.command("show")
13+
def show_config(ctx: typer.Context) -> None:
14+
def action(app_context):
15+
ui = TerminalUI(app_context.console, app_context.output_format)
16+
ui.print_mapping("Active Configuration", settings_summary(app_context.settings))
17+
18+
run_guarded(ctx, action)
19+
20+
21+
@app.command("test")
22+
def test_config(ctx: typer.Context) -> None:
23+
def action(app_context):
24+
ui = TerminalUI(app_context.console, app_context.output_format)
25+
domains = app_context.runtime().domains.list_domains()
26+
ui.print_mapping(
27+
"API Access",
28+
{
29+
"cpanel_host": app_context.settings.cpanel_host,
30+
"cpanel_user": app_context.settings.cpanel_user,
31+
"reachable_domains": len(domains),
32+
},
33+
)
34+
35+
run_guarded(ctx, action)

‎o2switch_cli/cli/context.py‎

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
from __future__ import annotations
2+
3+
import sys
4+
from dataclasses import dataclass, field
5+
6+
import typer
7+
from rich.console import Console
8+
9+
from o2switch_cli.config.settings import AppSettings
10+
from o2switch_cli.core.audit import AuditService
11+
from o2switch_cli.core.auth import ensure_credentials
12+
from o2switch_cli.core.cpanel_client import CpanelClient
13+
from o2switch_cli.core.dns_service import DNSService
14+
from o2switch_cli.core.domain_service import DomainService
15+
from o2switch_cli.core.errors import CliAppError
16+
from o2switch_cli.core.subdomain_service import SubdomainService
17+
from o2switch_cli.infra.resolver import DNSResolver
18+
19+
20+
@dataclass(slots=True)
21+
class RuntimeServices:
22+
client: CpanelClient
23+
domains: DomainService
24+
dns: DNSService
25+
subdomains: SubdomainService
26+
27+
28+
@dataclass(slots=True)
29+
class AppContext:
30+
settings: AppSettings
31+
output_format: str
32+
dry_run: bool
33+
force: bool
34+
yes: bool
35+
verbose: bool
36+
verify_after_mutation: bool
37+
allow_prompt: bool
38+
console: Console = field(default_factory=lambda: Console())
39+
_runtime: RuntimeServices | None = None
40+
41+
def runtime(self) -> RuntimeServices:
42+
if self._runtime is None:
43+
self.settings = ensure_credentials(self.settings, allow_prompt=self.allow_prompt)
44+
client = CpanelClient.from_settings(self.settings)
45+
audit = AuditService(
46+
audit_log_path=self.settings.audit_log_path,
47+
actor=self.settings.cpanel_user or "system",
48+
)
49+
domains = DomainService(client)
50+
resolver = DNSResolver()
51+
dns = DNSService(client, domains, resolver, audit)
52+
subdomains = SubdomainService(
53+
client,
54+
domains,
55+
dns,
56+
audit,
57+
self.settings.reserved_labels,
58+
)
59+
self._runtime = RuntimeServices(client=client, domains=domains, dns=dns, subdomains=subdomains)
60+
return self._runtime
61+
62+
def shutdown(self) -> None:
63+
if self._runtime is not None:
64+
self._runtime.client.close()
65+
66+
67+
def build_context(
68+
*,
69+
settings: AppSettings,
70+
json_output: bool,
71+
dry_run: bool,
72+
force: bool,
73+
yes: bool,
74+
verbose: bool,
75+
no_verify: bool,
76+
) -> AppContext:
77+
return AppContext(
78+
settings=settings,
79+
output_format="json" if json_output else settings.output_format,
80+
dry_run=dry_run,
81+
force=force,
82+
yes=yes,
83+
verbose=verbose,
84+
verify_after_mutation=settings.verify_dns_after_mutation and not no_verify,
85+
allow_prompt=sys.stdin.isatty() and not json_output,
86+
)
87+
88+
89+
def get_app_context(ctx: typer.Context) -> AppContext:
90+
if not isinstance(ctx.obj, AppContext):
91+
raise RuntimeError("Application context was not initialized.")
92+
return ctx.obj
93+
94+
95+
def raise_for_error(app_context: AppContext, error: CliAppError) -> None:
96+
from o2switch_cli.cli.ui import TerminalUI
97+
98+
TerminalUI(app_context.console, app_context.output_format).print_error(error.to_envelope())
99+
raise typer.Exit(error.exit_code)

‎o2switch_cli/cli/dns.py‎

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
from __future__ import annotations
2+
3+
import typer
4+
5+
from o2switch_cli.cli.helpers import confirm_plan, run_guarded
6+
from o2switch_cli.cli.ui import TerminalUI
7+
8+
app = typer.Typer(help="Search, upsert, delete, and verify DNS records.", rich_markup_mode="rich")
9+
10+
11+
@app.command("search")
12+
def search_dns(ctx: typer.Context, term: str) -> None:
13+
def action(app_context):
14+
ui = TerminalUI(app_context.console, app_context.output_format)
15+
records = app_context.runtime().dns.search(term)
16+
ui.print_records(records)
17+
18+
run_guarded(ctx, action)
19+
20+
21+
@app.command("upsert")
22+
def upsert_dns(
23+
ctx: typer.Context,
24+
host: str = typer.Option(..., "--host"),
25+
ip: str = typer.Option(..., "--ip"),
26+
ttl: int | None = typer.Option(None, "--ttl"),
27+
) -> None:
28+
def action(app_context):
29+
ui = TerminalUI(app_context.console, app_context.output_format)
30+
effective_ttl = ttl or app_context.settings.default_ttl
31+
zone, _, _, plan = app_context.runtime().dns.plan_upsert_a_record(
32+
host, ip, effective_ttl, force=app_context.force
33+
)
34+
if not confirm_plan(app_context, ui, plan, zone=zone):
35+
ui.print_info("Mutation cancelled.")
36+
return
37+
_, result = app_context.runtime().dns.upsert_a_record(
38+
host,
39+
ip,
40+
effective_ttl,
41+
dry_run=app_context.dry_run,
42+
force=app_context.force,
43+
verify=app_context.verify_after_mutation,
44+
)
45+
ui.print_result(result)
46+
47+
run_guarded(ctx, action)
48+
49+
50+
@app.command("delete")
51+
def delete_dns(ctx: typer.Context, host: str = typer.Option(..., "--host")) -> None:
52+
def action(app_context):
53+
ui = TerminalUI(app_context.console, app_context.output_format)
54+
zone, _, _, plan = app_context.runtime().dns.plan_delete_a_record(host, force=app_context.force)
55+
if not confirm_plan(app_context, ui, plan, zone=zone):
56+
ui.print_info("Mutation cancelled.")
57+
return
58+
_, result = app_context.runtime().dns.delete_a_record(
59+
host,
60+
dry_run=app_context.dry_run,
61+
force=app_context.force,
62+
verify=app_context.verify_after_mutation,
63+
)
64+
ui.print_result(result)
65+
66+
run_guarded(ctx, action)
67+
68+
69+
@app.command("verify")
70+
def verify_dns(
71+
ctx: typer.Context,
72+
host: str = typer.Option(..., "--host"),
73+
ip: str | None = typer.Option(None, "--ip"),
74+
) -> None:
75+
def action(app_context):
76+
ui = TerminalUI(app_context.console, app_context.output_format)
77+
result = app_context.runtime().dns.verify_record(host, ip)
78+
ui.print_result(result)
79+
80+
run_guarded(ctx, action)

‎o2switch_cli/cli/domains.py‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
from __future__ import annotations
2+
3+
import typer
4+
5+
from o2switch_cli.cli.helpers import run_guarded
6+
from o2switch_cli.cli.ui import TerminalUI
7+
8+
app = typer.Typer(help="List and search account domains.", rich_markup_mode="rich")
9+
10+
11+
@app.command("list")
12+
def list_domains(ctx: typer.Context) -> None:
13+
def action(app_context):
14+
ui = TerminalUI(app_context.console, app_context.output_format)
15+
domains = app_context.runtime().domains.list_domains()
16+
ui.print_domains(domains)
17+
18+
run_guarded(ctx, action)
19+
20+
21+
@app.command("search")
22+
def search_domains(ctx: typer.Context, term: str) -> None:
23+
def action(app_context):
24+
ui = TerminalUI(app_context.console, app_context.output_format)
25+
domains = app_context.runtime().domains.search(term)
26+
ui.print_domains(domains)
27+
28+
run_guarded(ctx, action)

‎o2switch_cli/cli/helpers.py‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
from __future__ import annotations
2+
3+
from collections.abc import Callable
4+
from typing import TypeVar
5+
6+
import typer
7+
8+
from o2switch_cli.cli.context import AppContext, get_app_context, raise_for_error
9+
from o2switch_cli.cli.ui import TerminalUI
10+
from o2switch_cli.core.errors import CliAppError, TransportAppError
11+
from o2switch_cli.core.models import MutationPlan, PlannedAction
12+
13+
T = TypeVar("T")
14+
15+
16+
def run_guarded(ctx: typer.Context, action: Callable[[AppContext], T]) -> T:
17+
app_context = get_app_context(ctx)
18+
try:
19+
return action(app_context)
20+
except CliAppError as error:
21+
raise_for_error(app_context, error)
22+
except typer.Exit:
23+
raise
24+
except Exception as error: # pragma: no cover
25+
raise_for_error(app_context, TransportAppError("runtime", str(error)))
26+
raise RuntimeError("unreachable")
27+
28+
29+
def confirm_plan(app_context: AppContext, ui: TerminalUI, plan: MutationPlan, *, zone: str | None = None) -> bool:
30+
if ui.output_format != "json":
31+
ui.print_plan(plan, zone=zone)
32+
if app_context.dry_run or app_context.yes or plan.planned_action is PlannedAction.NOOP:
33+
return True
34+
return ui.confirm("Apply this change?")

0 commit comments

Comments
 (0)