dh-cli 0.3.2__tar.gz → 0.4.1__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.
- {dh_cli-0.3.2 → dh_cli-0.4.1}/PKG-INFO +1 -1
- {dh_cli-0.3.2 → dh_cli-0.4.1}/pyproject.toml +1 -1
- dh_cli-0.4.1/src/dh_cli/hz/__init__.py +67 -0
- dh_cli-0.4.1/src/dh_cli/hz/deploy.py +37 -0
- dh_cli-0.4.1/src/dh_cli/hz/local.py +21 -0
- dh_cli-0.4.1/src/dh_cli/hz/test.py +111 -0
- dh_cli-0.4.1/src/dh_cli/hz/tf.py +53 -0
- dh_cli-0.4.1/src/dh_cli/hz/users.py +179 -0
- {dh_cli-0.3.2 → dh_cli-0.4.1}/src/dh_cli/main.py +5 -0
- dh_cli-0.4.1/tests/hz/test_init.py +68 -0
- dh_cli-0.4.1/tests/hz/test_suites.py +102 -0
- dh_cli-0.4.1/tests/hz/test_users.py +208 -0
- {dh_cli-0.3.2 → dh_cli-0.4.1}/.gitignore +0 -0
- {dh_cli-0.3.2 → dh_cli-0.4.1}/LICENSE +0 -0
- {dh_cli-0.3.2 → dh_cli-0.4.1}/README.md +0 -0
- {dh_cli-0.3.2 → dh_cli-0.4.1}/src/dh_cli/__init__.py +0 -0
- {dh_cli-0.3.2 → dh_cli-0.4.1}/src/dh_cli/batch/__init__.py +0 -0
- {dh_cli-0.3.2 → dh_cli-0.4.1}/src/dh_cli/batch/aws_batch.py +0 -0
- {dh_cli-0.3.2 → dh_cli-0.4.1}/src/dh_cli/batch/commands/__init__.py +0 -0
- {dh_cli-0.3.2 → dh_cli-0.4.1}/src/dh_cli/batch/commands/boltz.py +0 -0
- {dh_cli-0.3.2 → dh_cli-0.4.1}/src/dh_cli/batch/commands/cancel.py +0 -0
- {dh_cli-0.3.2 → dh_cli-0.4.1}/src/dh_cli/batch/commands/clean.py +0 -0
- {dh_cli-0.3.2 → dh_cli-0.4.1}/src/dh_cli/batch/commands/embed_t5.py +0 -0
- {dh_cli-0.3.2 → dh_cli-0.4.1}/src/dh_cli/batch/commands/finalize.py +0 -0
- {dh_cli-0.3.2 → dh_cli-0.4.1}/src/dh_cli/batch/commands/list_jobs.py +0 -0
- {dh_cli-0.3.2 → dh_cli-0.4.1}/src/dh_cli/batch/commands/local.py +0 -0
- {dh_cli-0.3.2 → dh_cli-0.4.1}/src/dh_cli/batch/commands/logs.py +0 -0
- {dh_cli-0.3.2 → dh_cli-0.4.1}/src/dh_cli/batch/commands/protmpnn.py +0 -0
- {dh_cli-0.3.2 → dh_cli-0.4.1}/src/dh_cli/batch/commands/protmpnn_to_boltz.py +0 -0
- {dh_cli-0.3.2 → dh_cli-0.4.1}/src/dh_cli/batch/commands/retry.py +0 -0
- {dh_cli-0.3.2 → dh_cli-0.4.1}/src/dh_cli/batch/commands/status.py +0 -0
- {dh_cli-0.3.2 → dh_cli-0.4.1}/src/dh_cli/batch/commands/submit.py +0 -0
- {dh_cli-0.3.2 → dh_cli-0.4.1}/src/dh_cli/batch/commands/train.py +0 -0
- {dh_cli-0.3.2 → dh_cli-0.4.1}/src/dh_cli/batch/commands/wait_for.py +0 -0
- {dh_cli-0.3.2 → dh_cli-0.4.1}/src/dh_cli/batch/fasta_utils.py +0 -0
- {dh_cli-0.3.2 → dh_cli-0.4.1}/src/dh_cli/batch/h5_utils.py +0 -0
- {dh_cli-0.3.2 → dh_cli-0.4.1}/src/dh_cli/batch/job_id.py +0 -0
- {dh_cli-0.3.2 → dh_cli-0.4.1}/src/dh_cli/batch/manifest.py +0 -0
- {dh_cli-0.3.2 → dh_cli-0.4.1}/src/dh_cli/batch/s3_transport.py +0 -0
- {dh_cli-0.3.2 → dh_cli-0.4.1}/src/dh_cli/cloud_commands.py +0 -0
- {dh_cli-0.3.2 → dh_cli-0.4.1}/src/dh_cli/codeartifact.py +0 -0
- {dh_cli-0.3.2 → dh_cli-0.4.1}/src/dh_cli/engines_studios/__init__.py +0 -0
- {dh_cli-0.3.2 → dh_cli-0.4.1}/src/dh_cli/engines_studios/api_client.py +0 -0
- {dh_cli-0.3.2 → dh_cli-0.4.1}/src/dh_cli/engines_studios/auth.py +0 -0
- {dh_cli-0.3.2 → dh_cli-0.4.1}/src/dh_cli/engines_studios/engine_commands.py +0 -0
- {dh_cli-0.3.2 → dh_cli-0.4.1}/src/dh_cli/engines_studios/progress.py +0 -0
- {dh_cli-0.3.2 → dh_cli-0.4.1}/src/dh_cli/engines_studios/ssh_config.py +0 -0
- {dh_cli-0.3.2 → dh_cli-0.4.1}/src/dh_cli/engines_studios/studio_commands.py +0 -0
- {dh_cli-0.3.2 → dh_cli-0.4.1}/src/dh_cli/github_commands.py +0 -0
- {dh_cli-0.3.2 → dh_cli-0.4.1}/src/dh_cli/utility_commands.py +0 -0
- {dh_cli-0.3.2 → dh_cli-0.4.1}/src/dh_cli/warehouse.py +0 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""dh hz — Horizyn API management commands."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
hz_app = typer.Typer(
|
|
9
|
+
help="Manage Horizyn API: users, deployments, local servers, and tests.",
|
|
10
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _find_workspace_root() -> Path:
|
|
15
|
+
root = os.environ.get("WORKSPACE_ROOT")
|
|
16
|
+
if root:
|
|
17
|
+
p = Path(root)
|
|
18
|
+
if p.is_dir():
|
|
19
|
+
return p
|
|
20
|
+
|
|
21
|
+
for child in Path("/workspaces").iterdir():
|
|
22
|
+
if child.is_dir() and not child.name.startswith("."):
|
|
23
|
+
return child
|
|
24
|
+
|
|
25
|
+
typer.echo("Cannot determine workspace root. Set $WORKSPACE_ROOT.", err=True)
|
|
26
|
+
raise typer.Exit(1)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def require_repo(name: str) -> Path:
|
|
30
|
+
"""Return the path to a cloned repo, or exit with a helpful error."""
|
|
31
|
+
root = _find_workspace_root()
|
|
32
|
+
repo = root / name
|
|
33
|
+
if not repo.is_dir():
|
|
34
|
+
typer.echo(
|
|
35
|
+
f"Repository '{name}' not found at {repo}.\n"
|
|
36
|
+
f"Clone it with: dc clone {name}",
|
|
37
|
+
err=True,
|
|
38
|
+
)
|
|
39
|
+
raise typer.Exit(1)
|
|
40
|
+
return repo
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def run_script(script: Path, args: list[str] | None = None, cwd: Path | None = None) -> None:
|
|
44
|
+
"""Run a shell script, streaming output. Exit on failure."""
|
|
45
|
+
import subprocess
|
|
46
|
+
|
|
47
|
+
if not script.exists():
|
|
48
|
+
typer.echo(f"Script not found: {script}", err=True)
|
|
49
|
+
raise typer.Exit(1)
|
|
50
|
+
|
|
51
|
+
cmd = ["bash", str(script)] + (args or [])
|
|
52
|
+
result = subprocess.run(cmd, cwd=cwd)
|
|
53
|
+
if result.returncode != 0:
|
|
54
|
+
raise typer.Exit(result.returncode)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
from dh_cli.hz.users import users_app # noqa: E402
|
|
58
|
+
from dh_cli.hz.deploy import deploy_app # noqa: E402
|
|
59
|
+
from dh_cli.hz.local import local_app # noqa: E402
|
|
60
|
+
from dh_cli.hz.test import test_app # noqa: E402
|
|
61
|
+
from dh_cli.hz.tf import tf_app # noqa: E402
|
|
62
|
+
|
|
63
|
+
hz_app.add_typer(users_app, name="users", help="Manage authorized users and tiers.")
|
|
64
|
+
hz_app.add_typer(deploy_app, name="deploy", help="Deploy API, docking, or frontend.")
|
|
65
|
+
hz_app.add_typer(local_app, name="local", help="Run local development servers.")
|
|
66
|
+
hz_app.add_typer(test_app, name="test", help="Run integration test suites.")
|
|
67
|
+
hz_app.add_typer(tf_app, name="tf", help="Terraform apply for Horizyn infrastructure.")
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Deploy API server, docking service, or frontend."""
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from dh_cli.hz import require_repo, run_script
|
|
6
|
+
|
|
7
|
+
deploy_app = typer.Typer(context_settings={"help_option_names": ["-h", "--help"]})
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@deploy_app.command("api")
|
|
11
|
+
def deploy_api(
|
|
12
|
+
env: str = typer.Option("prod", "--env", "-e", help="Environment: prod or dev."),
|
|
13
|
+
):
|
|
14
|
+
"""Build and deploy the Horizyn API server."""
|
|
15
|
+
repo = require_repo("horizyn-api")
|
|
16
|
+
script = repo / f"server/build-and-push-{env}.sh"
|
|
17
|
+
run_script(script, cwd=repo)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@deploy_app.command("docking")
|
|
21
|
+
def deploy_docking(
|
|
22
|
+
env: str = typer.Option("prod", "--env", "-e", help="Environment: prod or dev."),
|
|
23
|
+
):
|
|
24
|
+
"""Build and deploy the docking service."""
|
|
25
|
+
repo = require_repo("horizyn-api")
|
|
26
|
+
script = repo / f"docking_service/build-and-push-{env}.sh"
|
|
27
|
+
run_script(script, cwd=repo)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@deploy_app.command("frontend")
|
|
31
|
+
def deploy_frontend(
|
|
32
|
+
env: str = typer.Option("prod", "--env", "-e", help="Environment: prod or dev."),
|
|
33
|
+
):
|
|
34
|
+
"""Build and deploy the Horizyn frontend."""
|
|
35
|
+
repo = require_repo("horizyn-frontend")
|
|
36
|
+
script = repo / "scripts/deploy.sh"
|
|
37
|
+
run_script(script, args=[env], cwd=repo)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Run local development servers."""
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from dh_cli.hz import require_repo, run_script
|
|
6
|
+
|
|
7
|
+
local_app = typer.Typer(context_settings={"help_option_names": ["-h", "--help"]})
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@local_app.command("api")
|
|
11
|
+
def local_api():
|
|
12
|
+
"""Start the local Horizyn API server in Docker."""
|
|
13
|
+
repo = require_repo("horizyn-api")
|
|
14
|
+
run_script(repo / "server/run_local_server.sh", cwd=repo)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@local_app.command("docking")
|
|
18
|
+
def local_docking():
|
|
19
|
+
"""Start the local docking service in Docker."""
|
|
20
|
+
repo = require_repo("horizyn-api")
|
|
21
|
+
run_script(repo / "docking_service/run_local_server.sh", cwd=repo)
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Run integration test suites."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from dh_cli.hz import require_repo, run_script
|
|
10
|
+
|
|
11
|
+
test_app = typer.Typer(context_settings={"help_option_names": ["-h", "--help"]})
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _discover_suites(test_dir: Path) -> list[tuple[int, str, Path]]:
|
|
15
|
+
"""Return sorted list of (number, name, path) for numbered test scripts."""
|
|
16
|
+
suites = []
|
|
17
|
+
for f in test_dir.glob("*.sh"):
|
|
18
|
+
m = re.match(r"^(\d+)_(.+)\.sh$", f.name)
|
|
19
|
+
if m:
|
|
20
|
+
suites.append((int(m.group(1)), m.group(2), f))
|
|
21
|
+
return sorted(suites, key=lambda t: t[0])
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _resolve_suites(
|
|
25
|
+
available: list[tuple[int, str, Path]], selectors: list[str]
|
|
26
|
+
) -> list[tuple[int, str, Path]]:
|
|
27
|
+
"""Match selectors (numbers or name substrings) to available suites."""
|
|
28
|
+
matched = []
|
|
29
|
+
for sel in selectors:
|
|
30
|
+
sel = sel.strip()
|
|
31
|
+
found = False
|
|
32
|
+
if sel.isdigit():
|
|
33
|
+
num = int(sel)
|
|
34
|
+
for suite in available:
|
|
35
|
+
if suite[0] == num:
|
|
36
|
+
matched.append(suite)
|
|
37
|
+
found = True
|
|
38
|
+
break
|
|
39
|
+
if not found:
|
|
40
|
+
for suite in available:
|
|
41
|
+
if sel.lower() in suite[1].lower():
|
|
42
|
+
matched.append(suite)
|
|
43
|
+
found = True
|
|
44
|
+
break
|
|
45
|
+
if not found:
|
|
46
|
+
typer.echo(f"No suite matching '{sel}'.", err=True)
|
|
47
|
+
raise typer.Exit(1)
|
|
48
|
+
return matched
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@test_app.command("list")
|
|
52
|
+
def list_tests(
|
|
53
|
+
target: str = typer.Option("local", "--target", "-t", help="local or deployed."),
|
|
54
|
+
):
|
|
55
|
+
"""Show available test suites."""
|
|
56
|
+
repo = require_repo("horizyn-api")
|
|
57
|
+
test_dir = repo / f"test/server/{target}"
|
|
58
|
+
if not test_dir.is_dir():
|
|
59
|
+
typer.echo(f"Test directory not found: {test_dir}", err=True)
|
|
60
|
+
raise typer.Exit(1)
|
|
61
|
+
|
|
62
|
+
suites = _discover_suites(test_dir)
|
|
63
|
+
typer.echo(f"\n {target} test suites:\n")
|
|
64
|
+
for num, name, path in suites:
|
|
65
|
+
typer.echo(f" {num:3d} {name}")
|
|
66
|
+
typer.echo()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@test_app.command("local")
|
|
70
|
+
def test_local(
|
|
71
|
+
suites: Optional[list[str]] = typer.Argument(None, help="Suite numbers or names. Omit to run all."),
|
|
72
|
+
):
|
|
73
|
+
"""Run local integration tests."""
|
|
74
|
+
repo = require_repo("horizyn-api")
|
|
75
|
+
test_dir = repo / "test/server/local"
|
|
76
|
+
|
|
77
|
+
if not suites:
|
|
78
|
+
run_script(test_dir / "run_all_tests.sh", cwd=test_dir)
|
|
79
|
+
return
|
|
80
|
+
|
|
81
|
+
available = _discover_suites(test_dir)
|
|
82
|
+
selected = _resolve_suites(available, suites)
|
|
83
|
+
|
|
84
|
+
for num, name, path in selected:
|
|
85
|
+
typer.echo(f"\n{'='*60}")
|
|
86
|
+
typer.echo(f" Running: {num}_{name}")
|
|
87
|
+
typer.echo(f"{'='*60}\n")
|
|
88
|
+
run_script(path, cwd=test_dir)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@test_app.command("deployed")
|
|
92
|
+
def test_deployed(
|
|
93
|
+
suites: Optional[list[str]] = typer.Argument(None, help="Suite numbers or names. Omit to run all."),
|
|
94
|
+
env: str = typer.Option("dev", "--env", "-e", help="Environment: dev or prod."),
|
|
95
|
+
):
|
|
96
|
+
"""Run deployed integration tests."""
|
|
97
|
+
repo = require_repo("horizyn-api")
|
|
98
|
+
test_dir = repo / "test/server/deployed"
|
|
99
|
+
|
|
100
|
+
if not suites:
|
|
101
|
+
run_script(test_dir / "run_all_tests.sh", args=[env], cwd=test_dir)
|
|
102
|
+
return
|
|
103
|
+
|
|
104
|
+
available = _discover_suites(test_dir)
|
|
105
|
+
selected = _resolve_suites(available, suites)
|
|
106
|
+
|
|
107
|
+
for num, name, path in selected:
|
|
108
|
+
typer.echo(f"\n{'='*60}")
|
|
109
|
+
typer.echo(f" Running: {num}_{name}")
|
|
110
|
+
typer.echo(f"{'='*60}\n")
|
|
111
|
+
run_script(path, args=[env], cwd=test_dir)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Terraform deployments for Horizyn infrastructure."""
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from dh_cli.hz import require_repo, run_script
|
|
6
|
+
|
|
7
|
+
tf_app = typer.Typer(context_settings={"help_option_names": ["-h", "--help"]})
|
|
8
|
+
|
|
9
|
+
TF_MODULES = {
|
|
10
|
+
("api", "dev"): "terraform/environments/dev/horizyn_api/api",
|
|
11
|
+
("api", "prod"): "terraform/environments/dev/horizyn_api_prod",
|
|
12
|
+
("frontend", "dev"): "terraform/environments/dev/horizyn_frontend",
|
|
13
|
+
("frontend", "prod"): "terraform/environments/dev/horizyn_frontend_prod",
|
|
14
|
+
("docking", "dev"): "terraform/environments/dev/boltz_docking/service",
|
|
15
|
+
("docking", "prod"): "terraform/environments/dev/boltz_docking_prod/service",
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _run_tf(target: str, env: str, action: str) -> None:
|
|
20
|
+
repo = require_repo("blueprints")
|
|
21
|
+
module_path = TF_MODULES.get((target, env))
|
|
22
|
+
if not module_path:
|
|
23
|
+
typer.echo(f"Unknown combination: {target} / {env}", err=True)
|
|
24
|
+
raise typer.Exit(1)
|
|
25
|
+
script = repo / "scripts/terraform-deploy"
|
|
26
|
+
run_script(script, args=[module_path, action], cwd=repo)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@tf_app.command("api")
|
|
30
|
+
def tf_api(
|
|
31
|
+
env: str = typer.Option("prod", "--env", "-e", help="Environment: prod or dev."),
|
|
32
|
+
yolo: bool = typer.Option(False, "--yolo", help="Auto-approve (no confirmation prompt)."),
|
|
33
|
+
):
|
|
34
|
+
"""Terraform apply for the Horizyn API server."""
|
|
35
|
+
_run_tf("api", env, "yolo" if yolo else "plan")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@tf_app.command("frontend")
|
|
39
|
+
def tf_frontend(
|
|
40
|
+
env: str = typer.Option("prod", "--env", "-e", help="Environment: prod or dev."),
|
|
41
|
+
yolo: bool = typer.Option(False, "--yolo", help="Auto-approve (no confirmation prompt)."),
|
|
42
|
+
):
|
|
43
|
+
"""Terraform apply for the Horizyn frontend."""
|
|
44
|
+
_run_tf("frontend", env, "yolo" if yolo else "plan")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@tf_app.command("docking")
|
|
48
|
+
def tf_docking(
|
|
49
|
+
env: str = typer.Option("prod", "--env", "-e", help="Environment: prod or dev."),
|
|
50
|
+
yolo: bool = typer.Option(False, "--yolo", help="Auto-approve (no confirmation prompt)."),
|
|
51
|
+
):
|
|
52
|
+
"""Terraform apply for the Boltz docking service."""
|
|
53
|
+
_run_tf("docking", env, "yolo" if yolo else "plan")
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""User management via SSM parameters."""
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
users_app = typer.Typer(context_settings={"help_option_names": ["-h", "--help"]})
|
|
6
|
+
|
|
7
|
+
SSM_PARAMS = {
|
|
8
|
+
"allowed_emails": "/horizyn/{env}/auth/allowed_emails",
|
|
9
|
+
"allowed_domains": "/horizyn/{env}/auth/allowed_domains",
|
|
10
|
+
"admin_domains": "/horizyn/{env}/auth/admin_domains",
|
|
11
|
+
"admin_emails": "/horizyn/{env}/auth/admin_emails",
|
|
12
|
+
"alpha_emails": "/horizyn/{env}/auth/alpha_emails",
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _ssm_client():
|
|
17
|
+
import boto3
|
|
18
|
+
|
|
19
|
+
return boto3.client("ssm", region_name="us-east-1")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _get_param(ssm, name: str) -> str:
|
|
23
|
+
try:
|
|
24
|
+
resp = ssm.get_parameter(Name=name)
|
|
25
|
+
return resp["Parameter"]["Value"]
|
|
26
|
+
except ssm.exceptions.ParameterNotFound:
|
|
27
|
+
return ""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _put_param(ssm, name: str, value: str) -> None:
|
|
31
|
+
ssm.put_parameter(Name=name, Value=value, Type="String", Overwrite=True)
|
|
32
|
+
typer.echo(f"Updated {name}")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _parse_csv(raw: str) -> set[str]:
|
|
36
|
+
return {e.strip().lower() for e in raw.split(",") if e.strip()}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _format_csv(emails: set[str]) -> str:
|
|
40
|
+
return ",".join(sorted(emails))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@users_app.command("list")
|
|
44
|
+
def list_users(
|
|
45
|
+
env: str = typer.Option("prod", "--env", "-e", help="Environment: prod or dev."),
|
|
46
|
+
):
|
|
47
|
+
"""Show all authorized users and their tiers."""
|
|
48
|
+
ssm = _ssm_client()
|
|
49
|
+
|
|
50
|
+
admin_domains = _parse_csv(_get_param(ssm, SSM_PARAMS["admin_domains"].format(env=env)))
|
|
51
|
+
admin_emails = _parse_csv(_get_param(ssm, SSM_PARAMS["admin_emails"].format(env=env)))
|
|
52
|
+
alpha_emails = _parse_csv(_get_param(ssm, SSM_PARAMS["alpha_emails"].format(env=env)))
|
|
53
|
+
allowed_domains = _parse_csv(_get_param(ssm, SSM_PARAMS["allowed_domains"].format(env=env)))
|
|
54
|
+
allowed_emails = _parse_csv(_get_param(ssm, SSM_PARAMS["allowed_emails"].format(env=env)))
|
|
55
|
+
|
|
56
|
+
typer.echo(f"\n Horizyn users ({env})\n")
|
|
57
|
+
|
|
58
|
+
def _print_section(label: str, items: set[str]) -> None:
|
|
59
|
+
typer.echo(f" {label}:")
|
|
60
|
+
if not items:
|
|
61
|
+
typer.echo(" (none)")
|
|
62
|
+
else:
|
|
63
|
+
for item in sorted(items):
|
|
64
|
+
typer.echo(f" {item}")
|
|
65
|
+
|
|
66
|
+
_print_section("Admin domains", admin_domains)
|
|
67
|
+
_print_section("Admin emails", admin_emails)
|
|
68
|
+
_print_section("Alpha emails", alpha_emails)
|
|
69
|
+
_print_section("Allowed domains", allowed_domains)
|
|
70
|
+
_print_section("Allowed emails", allowed_emails)
|
|
71
|
+
typer.echo()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@users_app.command("add")
|
|
75
|
+
def add_user(
|
|
76
|
+
email: str = typer.Argument(help="Email address to add."),
|
|
77
|
+
env: str = typer.Option("prod", "--env", "-e", help="Environment: prod or dev."),
|
|
78
|
+
tier: str = typer.Option("beta", "--tier", "-t", help="Tier: beta or alpha."),
|
|
79
|
+
):
|
|
80
|
+
"""Add a user to the authorized emails list (and optionally set tier)."""
|
|
81
|
+
email = email.strip().lower()
|
|
82
|
+
ssm = _ssm_client()
|
|
83
|
+
|
|
84
|
+
allowed = _parse_csv(_get_param(ssm, SSM_PARAMS["allowed_emails"].format(env=env)))
|
|
85
|
+
if email in allowed:
|
|
86
|
+
typer.echo(f"{email} is already in allowed_emails for {env}.")
|
|
87
|
+
else:
|
|
88
|
+
allowed.add(email)
|
|
89
|
+
_put_param(ssm, SSM_PARAMS["allowed_emails"].format(env=env), _format_csv(allowed))
|
|
90
|
+
|
|
91
|
+
if tier == "alpha":
|
|
92
|
+
alphas = _parse_csv(_get_param(ssm, SSM_PARAMS["alpha_emails"].format(env=env)))
|
|
93
|
+
if email in alphas:
|
|
94
|
+
typer.echo(f"{email} is already in alpha_emails for {env}.")
|
|
95
|
+
else:
|
|
96
|
+
alphas.add(email)
|
|
97
|
+
_put_param(ssm, SSM_PARAMS["alpha_emails"].format(env=env), _format_csv(alphas))
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@users_app.command("remove")
|
|
101
|
+
def remove_user(
|
|
102
|
+
email: str = typer.Argument(help="Email address to remove."),
|
|
103
|
+
env: str = typer.Option("prod", "--env", "-e", help="Environment: prod or dev."),
|
|
104
|
+
):
|
|
105
|
+
"""Remove a user from all email lists for the given environment."""
|
|
106
|
+
email = email.strip().lower()
|
|
107
|
+
ssm = _ssm_client()
|
|
108
|
+
|
|
109
|
+
for param_key in ("allowed_emails", "admin_emails", "alpha_emails"):
|
|
110
|
+
param_name = SSM_PARAMS[param_key].format(env=env)
|
|
111
|
+
current = _parse_csv(_get_param(ssm, param_name))
|
|
112
|
+
if email in current:
|
|
113
|
+
current.discard(email)
|
|
114
|
+
_put_param(ssm, param_name, _format_csv(current))
|
|
115
|
+
typer.echo(f" Removed {email} from {param_key}")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@users_app.command("promote")
|
|
119
|
+
def promote_user(
|
|
120
|
+
email: str = typer.Argument(help="Email address to promote."),
|
|
121
|
+
tier: str = typer.Option(..., "--tier", "-t", help="Target tier: alpha."),
|
|
122
|
+
env: str = typer.Option("prod", "--env", "-e", help="Environment: prod or dev."),
|
|
123
|
+
):
|
|
124
|
+
"""Promote a user to a higher tier."""
|
|
125
|
+
email = email.strip().lower()
|
|
126
|
+
|
|
127
|
+
if tier not in ("alpha",):
|
|
128
|
+
typer.echo("Only --tier alpha is supported. Admin is domain-based.", err=True)
|
|
129
|
+
raise typer.Exit(1)
|
|
130
|
+
|
|
131
|
+
ssm = _ssm_client()
|
|
132
|
+
|
|
133
|
+
allowed = _parse_csv(_get_param(ssm, SSM_PARAMS["allowed_emails"].format(env=env)))
|
|
134
|
+
if email not in allowed:
|
|
135
|
+
typer.echo(f"{email} is not in allowed_emails for {env}. Add them first.", err=True)
|
|
136
|
+
raise typer.Exit(1)
|
|
137
|
+
|
|
138
|
+
alphas = _parse_csv(_get_param(ssm, SSM_PARAMS["alpha_emails"].format(env=env)))
|
|
139
|
+
if email in alphas:
|
|
140
|
+
typer.echo(f"{email} is already alpha in {env}.")
|
|
141
|
+
else:
|
|
142
|
+
alphas.add(email)
|
|
143
|
+
_put_param(ssm, SSM_PARAMS["alpha_emails"].format(env=env), _format_csv(alphas))
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@users_app.command("find")
|
|
147
|
+
def find_user(
|
|
148
|
+
email: str = typer.Argument(help="Email address to search for."),
|
|
149
|
+
):
|
|
150
|
+
"""Search for a user across both prod and dev environments."""
|
|
151
|
+
email = email.strip().lower()
|
|
152
|
+
ssm = _ssm_client()
|
|
153
|
+
|
|
154
|
+
domain = email.split("@")[-1] if "@" in email else ""
|
|
155
|
+
|
|
156
|
+
typer.echo(f"\n Searching for: {email}\n")
|
|
157
|
+
|
|
158
|
+
for env in ("prod", "dev"):
|
|
159
|
+
matches: list[str] = []
|
|
160
|
+
|
|
161
|
+
for param_key in SSM_PARAMS:
|
|
162
|
+
param_name = SSM_PARAMS[param_key].format(env=env)
|
|
163
|
+
values = _parse_csv(_get_param(ssm, param_name))
|
|
164
|
+
|
|
165
|
+
if param_key.endswith("_domains"):
|
|
166
|
+
if domain and domain in values:
|
|
167
|
+
matches.append(f"{param_key} (via @{domain})")
|
|
168
|
+
else:
|
|
169
|
+
if email in values:
|
|
170
|
+
matches.append(param_key)
|
|
171
|
+
|
|
172
|
+
if matches:
|
|
173
|
+
typer.echo(f" {env}: found")
|
|
174
|
+
for m in matches:
|
|
175
|
+
typer.echo(f" - {m}")
|
|
176
|
+
else:
|
|
177
|
+
typer.echo(f" {env}: not found")
|
|
178
|
+
|
|
179
|
+
typer.echo()
|
|
@@ -33,6 +33,11 @@ app.add_typer(gcp_app, name="gcp", help="Manage GCP authentication and impersona
|
|
|
33
33
|
app.add_typer(aws_app, name="aws", help="Manage AWS SSO authentication.")
|
|
34
34
|
app.add_typer(gh_app, name="gh", help="Manage GitHub authentication.")
|
|
35
35
|
|
|
36
|
+
# Horizyn API management
|
|
37
|
+
from dh_cli.hz import hz_app
|
|
38
|
+
|
|
39
|
+
app.add_typer(hz_app, name="hz", help="Manage Horizyn API: users, deployments, tests.")
|
|
40
|
+
|
|
36
41
|
|
|
37
42
|
# Engine and Studio commands (v2 - Click-based, passthrough wrapper)
|
|
38
43
|
@app.command(
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Tests for dh hz workspace discovery and repo resolution."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from unittest.mock import patch
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
from click.exceptions import Exit
|
|
8
|
+
|
|
9
|
+
from dh_cli.hz import _find_workspace_root, require_repo
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TestFindWorkspaceRoot:
|
|
13
|
+
def test_uses_workspace_root_env_var(self, tmp_path):
|
|
14
|
+
with patch.dict("os.environ", {"WORKSPACE_ROOT": str(tmp_path)}):
|
|
15
|
+
assert _find_workspace_root() == tmp_path
|
|
16
|
+
|
|
17
|
+
def test_falls_back_to_scanning_workspaces(self, tmp_path):
|
|
18
|
+
user_dir = tmp_path / "alice"
|
|
19
|
+
user_dir.mkdir()
|
|
20
|
+
|
|
21
|
+
with (
|
|
22
|
+
patch.dict("os.environ", {"WORKSPACE_ROOT": ""}),
|
|
23
|
+
patch("dh_cli.hz.Path") as mock_path,
|
|
24
|
+
):
|
|
25
|
+
# WORKSPACE_ROOT="" → falsy, so it hits the scan branch
|
|
26
|
+
# Make Path("/workspaces") return something that iterates to user_dir
|
|
27
|
+
def path_factory(arg):
|
|
28
|
+
if arg == "/workspaces":
|
|
29
|
+
p = Path(tmp_path)
|
|
30
|
+
return p
|
|
31
|
+
return Path(arg)
|
|
32
|
+
|
|
33
|
+
mock_path.side_effect = path_factory
|
|
34
|
+
|
|
35
|
+
result = _find_workspace_root()
|
|
36
|
+
assert result == user_dir
|
|
37
|
+
|
|
38
|
+
def test_exits_when_nothing_found(self, tmp_path):
|
|
39
|
+
empty_ws = tmp_path / "empty_workspaces"
|
|
40
|
+
empty_ws.mkdir()
|
|
41
|
+
|
|
42
|
+
with (
|
|
43
|
+
patch.dict("os.environ", {"WORKSPACE_ROOT": ""}),
|
|
44
|
+
patch("dh_cli.hz.Path") as mock_path,
|
|
45
|
+
):
|
|
46
|
+
def path_factory(arg):
|
|
47
|
+
if arg == "/workspaces":
|
|
48
|
+
return Path(empty_ws)
|
|
49
|
+
return Path(arg)
|
|
50
|
+
|
|
51
|
+
mock_path.side_effect = path_factory
|
|
52
|
+
|
|
53
|
+
with pytest.raises(Exit):
|
|
54
|
+
_find_workspace_root()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class TestRequireRepo:
|
|
58
|
+
def test_returns_repo_path(self, tmp_path):
|
|
59
|
+
repo = tmp_path / "horizyn-api"
|
|
60
|
+
repo.mkdir()
|
|
61
|
+
|
|
62
|
+
with patch("dh_cli.hz._find_workspace_root", return_value=tmp_path):
|
|
63
|
+
assert require_repo("horizyn-api") == repo
|
|
64
|
+
|
|
65
|
+
def test_exits_when_repo_missing(self, tmp_path):
|
|
66
|
+
with patch("dh_cli.hz._find_workspace_root", return_value=tmp_path):
|
|
67
|
+
with pytest.raises(Exit):
|
|
68
|
+
require_repo("horizyn-api")
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Tests for dh hz test suite discovery and resolution."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from click.exceptions import Exit
|
|
7
|
+
|
|
8
|
+
from dh_cli.hz.test import _discover_suites, _resolve_suites
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@pytest.fixture
|
|
12
|
+
def test_dir(tmp_path):
|
|
13
|
+
"""Create a fake test directory with numbered scripts."""
|
|
14
|
+
scripts = [
|
|
15
|
+
"1_fast.sh",
|
|
16
|
+
"2_full_sets.sh",
|
|
17
|
+
"3_custom_sets.sh",
|
|
18
|
+
"10_error_handling.sh",
|
|
19
|
+
"20_feature_bundle.sh",
|
|
20
|
+
"common.sh",
|
|
21
|
+
"run_all_tests.sh",
|
|
22
|
+
]
|
|
23
|
+
for name in scripts:
|
|
24
|
+
(tmp_path / name).write_text("#!/bin/bash\necho test")
|
|
25
|
+
return tmp_path
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class TestDiscoverSuites:
|
|
29
|
+
def test_finds_numbered_scripts(self, test_dir):
|
|
30
|
+
suites = _discover_suites(test_dir)
|
|
31
|
+
numbers = [s[0] for s in suites]
|
|
32
|
+
assert numbers == [1, 2, 3, 10, 20]
|
|
33
|
+
|
|
34
|
+
def test_extracts_names(self, test_dir):
|
|
35
|
+
suites = _discover_suites(test_dir)
|
|
36
|
+
names = [s[1] for s in suites]
|
|
37
|
+
assert "fast" in names
|
|
38
|
+
assert "full_sets" in names
|
|
39
|
+
assert "feature_bundle" in names
|
|
40
|
+
|
|
41
|
+
def test_excludes_non_numbered_scripts(self, test_dir):
|
|
42
|
+
suites = _discover_suites(test_dir)
|
|
43
|
+
names = [s[1] for s in suites]
|
|
44
|
+
assert "common" not in names
|
|
45
|
+
assert "run_all_tests" not in names
|
|
46
|
+
|
|
47
|
+
def test_returns_correct_paths(self, test_dir):
|
|
48
|
+
suites = _discover_suites(test_dir)
|
|
49
|
+
for num, name, path in suites:
|
|
50
|
+
assert path.exists()
|
|
51
|
+
assert path.name == f"{num}_{name}.sh"
|
|
52
|
+
|
|
53
|
+
def test_sorted_by_number(self, test_dir):
|
|
54
|
+
suites = _discover_suites(test_dir)
|
|
55
|
+
numbers = [s[0] for s in suites]
|
|
56
|
+
assert numbers == sorted(numbers)
|
|
57
|
+
|
|
58
|
+
def test_empty_directory(self, tmp_path):
|
|
59
|
+
assert _discover_suites(tmp_path) == []
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class TestResolveSuites:
|
|
63
|
+
@pytest.fixture
|
|
64
|
+
def available(self, test_dir):
|
|
65
|
+
return _discover_suites(test_dir)
|
|
66
|
+
|
|
67
|
+
def test_resolve_by_number(self, available):
|
|
68
|
+
result = _resolve_suites(available, ["1"])
|
|
69
|
+
assert len(result) == 1
|
|
70
|
+
assert result[0][0] == 1
|
|
71
|
+
|
|
72
|
+
def test_resolve_multiple_numbers(self, available):
|
|
73
|
+
result = _resolve_suites(available, ["1", "3", "20"])
|
|
74
|
+
assert [s[0] for s in result] == [1, 3, 20]
|
|
75
|
+
|
|
76
|
+
def test_resolve_by_name(self, available):
|
|
77
|
+
result = _resolve_suites(available, ["fast"])
|
|
78
|
+
assert len(result) == 1
|
|
79
|
+
assert result[0][1] == "fast"
|
|
80
|
+
|
|
81
|
+
def test_resolve_by_name_substring(self, available):
|
|
82
|
+
result = _resolve_suites(available, ["error"])
|
|
83
|
+
assert len(result) == 1
|
|
84
|
+
assert result[0][1] == "error_handling"
|
|
85
|
+
|
|
86
|
+
def test_resolve_mixed_numbers_and_names(self, available):
|
|
87
|
+
result = _resolve_suites(available, ["1", "error"])
|
|
88
|
+
assert len(result) == 2
|
|
89
|
+
assert result[0][0] == 1
|
|
90
|
+
assert result[1][1] == "error_handling"
|
|
91
|
+
|
|
92
|
+
def test_unknown_selector_exits(self, available):
|
|
93
|
+
with pytest.raises(Exit):
|
|
94
|
+
_resolve_suites(available, ["999"])
|
|
95
|
+
|
|
96
|
+
def test_unknown_name_exits(self, available):
|
|
97
|
+
with pytest.raises(Exit):
|
|
98
|
+
_resolve_suites(available, ["nonexistent"])
|
|
99
|
+
|
|
100
|
+
def test_name_match_is_case_insensitive(self, available):
|
|
101
|
+
result = _resolve_suites(available, ["FAST"])
|
|
102
|
+
assert result[0][1] == "fast"
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""Tests for dh hz users SSM management."""
|
|
2
|
+
|
|
3
|
+
from unittest.mock import MagicMock, patch
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from click.exceptions import Exit
|
|
7
|
+
|
|
8
|
+
from dh_cli.hz.users import _format_csv, _parse_csv
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TestParseCSV:
|
|
12
|
+
def test_parses_comma_separated(self):
|
|
13
|
+
assert _parse_csv("alice@x.com,bob@y.com") == {"alice@x.com", "bob@y.com"}
|
|
14
|
+
|
|
15
|
+
def test_strips_whitespace(self):
|
|
16
|
+
assert _parse_csv(" alice@x.com , bob@y.com ") == {"alice@x.com", "bob@y.com"}
|
|
17
|
+
|
|
18
|
+
def test_lowercases(self):
|
|
19
|
+
assert _parse_csv("Alice@X.COM") == {"alice@x.com"}
|
|
20
|
+
|
|
21
|
+
def test_empty_string(self):
|
|
22
|
+
assert _parse_csv("") == set()
|
|
23
|
+
|
|
24
|
+
def test_trailing_comma(self):
|
|
25
|
+
assert _parse_csv("alice@x.com,") == {"alice@x.com"}
|
|
26
|
+
|
|
27
|
+
def test_deduplicates(self):
|
|
28
|
+
assert _parse_csv("a@x.com,a@x.com") == {"a@x.com"}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class TestFormatCSV:
|
|
32
|
+
def test_sorts_alphabetically(self):
|
|
33
|
+
assert _format_csv({"bob@y.com", "alice@x.com"}) == "alice@x.com,bob@y.com"
|
|
34
|
+
|
|
35
|
+
def test_empty_set(self):
|
|
36
|
+
assert _format_csv(set()) == ""
|
|
37
|
+
|
|
38
|
+
def test_single_email(self):
|
|
39
|
+
assert _format_csv({"alice@x.com"}) == "alice@x.com"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class TestAddUser:
|
|
43
|
+
def _make_ssm(self, params: dict[str, str]):
|
|
44
|
+
"""Create a mock SSM client with given param values."""
|
|
45
|
+
ssm = MagicMock()
|
|
46
|
+
|
|
47
|
+
not_found = type("ParameterNotFound", (Exception,), {})
|
|
48
|
+
ssm.exceptions.ParameterNotFound = not_found
|
|
49
|
+
|
|
50
|
+
def get_parameter(Name):
|
|
51
|
+
if Name in params:
|
|
52
|
+
return {"Parameter": {"Value": params[Name]}}
|
|
53
|
+
raise not_found()
|
|
54
|
+
|
|
55
|
+
ssm.get_parameter.side_effect = get_parameter
|
|
56
|
+
|
|
57
|
+
def put_parameter(Name, Value, Type, Overwrite):
|
|
58
|
+
params[Name] = Value
|
|
59
|
+
|
|
60
|
+
ssm.put_parameter.side_effect = put_parameter
|
|
61
|
+
return ssm
|
|
62
|
+
|
|
63
|
+
def test_add_beta_user(self):
|
|
64
|
+
params = {"/horizyn/prod/auth/allowed_emails": "existing@x.com"}
|
|
65
|
+
ssm = self._make_ssm(params)
|
|
66
|
+
|
|
67
|
+
with patch("dh_cli.hz.users._ssm_client", return_value=ssm):
|
|
68
|
+
from dh_cli.hz.users import add_user
|
|
69
|
+
|
|
70
|
+
# Invoke the typer command's underlying logic
|
|
71
|
+
ctx = MagicMock()
|
|
72
|
+
add_user("new@y.com", env="prod", tier="beta")
|
|
73
|
+
|
|
74
|
+
assert "new@y.com" in params["/horizyn/prod/auth/allowed_emails"]
|
|
75
|
+
assert "existing@x.com" in params["/horizyn/prod/auth/allowed_emails"]
|
|
76
|
+
|
|
77
|
+
def test_add_alpha_user(self):
|
|
78
|
+
params = {
|
|
79
|
+
"/horizyn/prod/auth/allowed_emails": "existing@x.com",
|
|
80
|
+
"/horizyn/prod/auth/alpha_emails": "",
|
|
81
|
+
}
|
|
82
|
+
ssm = self._make_ssm(params)
|
|
83
|
+
|
|
84
|
+
with patch("dh_cli.hz.users._ssm_client", return_value=ssm):
|
|
85
|
+
from dh_cli.hz.users import add_user
|
|
86
|
+
|
|
87
|
+
add_user("new@y.com", env="prod", tier="alpha")
|
|
88
|
+
|
|
89
|
+
assert "new@y.com" in params["/horizyn/prod/auth/allowed_emails"]
|
|
90
|
+
assert "new@y.com" in params["/horizyn/prod/auth/alpha_emails"]
|
|
91
|
+
|
|
92
|
+
def test_add_existing_user_is_idempotent(self):
|
|
93
|
+
params = {"/horizyn/prod/auth/allowed_emails": "existing@x.com"}
|
|
94
|
+
ssm = self._make_ssm(params)
|
|
95
|
+
|
|
96
|
+
with patch("dh_cli.hz.users._ssm_client", return_value=ssm):
|
|
97
|
+
from dh_cli.hz.users import add_user
|
|
98
|
+
|
|
99
|
+
add_user("existing@x.com", env="prod", tier="beta")
|
|
100
|
+
|
|
101
|
+
ssm.put_parameter.assert_not_called()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class TestRemoveUser:
|
|
105
|
+
def _make_ssm(self, params: dict[str, str]):
|
|
106
|
+
ssm = MagicMock()
|
|
107
|
+
|
|
108
|
+
not_found = type("ParameterNotFound", (Exception,), {})
|
|
109
|
+
ssm.exceptions.ParameterNotFound = not_found
|
|
110
|
+
|
|
111
|
+
def get_parameter(Name):
|
|
112
|
+
if Name in params:
|
|
113
|
+
return {"Parameter": {"Value": params[Name]}}
|
|
114
|
+
raise not_found()
|
|
115
|
+
|
|
116
|
+
ssm.get_parameter.side_effect = get_parameter
|
|
117
|
+
|
|
118
|
+
def put_parameter(Name, Value, Type, Overwrite):
|
|
119
|
+
params[Name] = Value
|
|
120
|
+
|
|
121
|
+
ssm.put_parameter.side_effect = put_parameter
|
|
122
|
+
return ssm
|
|
123
|
+
|
|
124
|
+
def test_remove_from_all_lists(self):
|
|
125
|
+
params = {
|
|
126
|
+
"/horizyn/prod/auth/allowed_emails": "alice@x.com,bob@y.com",
|
|
127
|
+
"/horizyn/prod/auth/admin_emails": "alice@x.com",
|
|
128
|
+
"/horizyn/prod/auth/alpha_emails": "alice@x.com",
|
|
129
|
+
}
|
|
130
|
+
ssm = self._make_ssm(params)
|
|
131
|
+
|
|
132
|
+
with patch("dh_cli.hz.users._ssm_client", return_value=ssm):
|
|
133
|
+
from dh_cli.hz.users import remove_user
|
|
134
|
+
|
|
135
|
+
remove_user("alice@x.com", env="prod")
|
|
136
|
+
|
|
137
|
+
assert "alice@x.com" not in params["/horizyn/prod/auth/allowed_emails"]
|
|
138
|
+
assert "bob@y.com" in params["/horizyn/prod/auth/allowed_emails"]
|
|
139
|
+
assert "alice@x.com" not in params["/horizyn/prod/auth/admin_emails"]
|
|
140
|
+
assert "alice@x.com" not in params["/horizyn/prod/auth/alpha_emails"]
|
|
141
|
+
|
|
142
|
+
def test_remove_nonexistent_user_is_noop(self):
|
|
143
|
+
params = {
|
|
144
|
+
"/horizyn/prod/auth/allowed_emails": "bob@y.com",
|
|
145
|
+
"/horizyn/prod/auth/admin_emails": "",
|
|
146
|
+
"/horizyn/prod/auth/alpha_emails": "",
|
|
147
|
+
}
|
|
148
|
+
ssm = self._make_ssm(params)
|
|
149
|
+
|
|
150
|
+
with patch("dh_cli.hz.users._ssm_client", return_value=ssm):
|
|
151
|
+
from dh_cli.hz.users import remove_user
|
|
152
|
+
|
|
153
|
+
remove_user("ghost@z.com", env="prod")
|
|
154
|
+
|
|
155
|
+
ssm.put_parameter.assert_not_called()
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class TestPromoteUser:
|
|
159
|
+
def _make_ssm(self, params: dict[str, str]):
|
|
160
|
+
ssm = MagicMock()
|
|
161
|
+
|
|
162
|
+
not_found = type("ParameterNotFound", (Exception,), {})
|
|
163
|
+
ssm.exceptions.ParameterNotFound = not_found
|
|
164
|
+
|
|
165
|
+
def get_parameter(Name):
|
|
166
|
+
if Name in params:
|
|
167
|
+
return {"Parameter": {"Value": params[Name]}}
|
|
168
|
+
raise not_found()
|
|
169
|
+
|
|
170
|
+
ssm.get_parameter.side_effect = get_parameter
|
|
171
|
+
|
|
172
|
+
def put_parameter(Name, Value, Type, Overwrite):
|
|
173
|
+
params[Name] = Value
|
|
174
|
+
|
|
175
|
+
ssm.put_parameter.side_effect = put_parameter
|
|
176
|
+
return ssm
|
|
177
|
+
|
|
178
|
+
def test_promote_to_alpha(self):
|
|
179
|
+
params = {
|
|
180
|
+
"/horizyn/prod/auth/allowed_emails": "alice@x.com",
|
|
181
|
+
"/horizyn/prod/auth/alpha_emails": "",
|
|
182
|
+
}
|
|
183
|
+
ssm = self._make_ssm(params)
|
|
184
|
+
|
|
185
|
+
with patch("dh_cli.hz.users._ssm_client", return_value=ssm):
|
|
186
|
+
from dh_cli.hz.users import promote_user
|
|
187
|
+
|
|
188
|
+
promote_user("alice@x.com", tier="alpha", env="prod")
|
|
189
|
+
|
|
190
|
+
assert "alice@x.com" in params["/horizyn/prod/auth/alpha_emails"]
|
|
191
|
+
|
|
192
|
+
def test_promote_nonexistent_user_exits(self):
|
|
193
|
+
params = {
|
|
194
|
+
"/horizyn/prod/auth/allowed_emails": "bob@y.com",
|
|
195
|
+
}
|
|
196
|
+
ssm = self._make_ssm(params)
|
|
197
|
+
|
|
198
|
+
with patch("dh_cli.hz.users._ssm_client", return_value=ssm):
|
|
199
|
+
from dh_cli.hz.users import promote_user
|
|
200
|
+
|
|
201
|
+
with pytest.raises(Exit):
|
|
202
|
+
promote_user("ghost@z.com", tier="alpha", env="prod")
|
|
203
|
+
|
|
204
|
+
def test_promote_invalid_tier_exits(self):
|
|
205
|
+
with pytest.raises(Exit):
|
|
206
|
+
from dh_cli.hz.users import promote_user
|
|
207
|
+
|
|
208
|
+
promote_user("alice@x.com", tier="superadmin", env="prod")
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|