dh-cli 0.8.7__tar.gz → 0.9.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.
- {dh_cli-0.8.7 → dh_cli-0.9.0}/PKG-INFO +1 -1
- {dh_cli-0.8.7 → dh_cli-0.9.0}/pyproject.toml +1 -1
- {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/batch/__init__.py +2 -0
- {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/batch/commands/finalize.py +36 -0
- dh_cli-0.9.0/src/dh_cli/batch/commands/rfd3.py +402 -0
- {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/bedrock/commands.py +124 -2
- {dh_cli-0.8.7 → dh_cli-0.9.0}/tests/bedrock/test_cost_command.py +30 -7
- {dh_cli-0.8.7 → dh_cli-0.9.0}/tests/bedrock/test_key_command.py +26 -0
- dh_cli-0.9.0/tests/bedrock/test_rotate.py +280 -0
- {dh_cli-0.8.7 → dh_cli-0.9.0}/tests/github/conftest.py +1 -1
- dh_cli-0.9.0/tests/test_finalize_rfd3.py +130 -0
- dh_cli-0.9.0/tests/test_rfd3_command.py +456 -0
- {dh_cli-0.8.7 → dh_cli-0.9.0}/.gitignore +0 -0
- {dh_cli-0.8.7 → dh_cli-0.9.0}/LICENSE +0 -0
- {dh_cli-0.8.7 → dh_cli-0.9.0}/README.md +0 -0
- {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/__init__.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/_identity.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/batch/aws_batch.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/batch/commands/__init__.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/batch/commands/boltz.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/batch/commands/cancel.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/batch/commands/clean.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/batch/commands/embed_t5.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/batch/commands/list_jobs.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/batch/commands/local.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/batch/commands/logs.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/batch/commands/orca.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/batch/commands/protmpnn.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/batch/commands/protmpnn_to_boltz.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/batch/commands/retry.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/batch/commands/status.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/batch/commands/submit.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/batch/commands/train.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/batch/commands/wait_for.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/batch/fasta_utils.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/batch/h5_utils.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/batch/job_id.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/batch/manifest.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/batch/s3_transport.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/bedrock/__init__.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/bedrock/cost_report.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/bedrock/pricing.yaml +0 -0
- {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/cloud_commands.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/codeartifact.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/engines_studios/__init__.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/engines_studios/api_client.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/engines_studios/auth.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/engines_studios/engine_commands.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/engines_studios/progress.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/engines_studios/ssh_config.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/engines_studios/studio_commands.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/github_commands.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/hz/__init__.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/hz/deploy.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/hz/local.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/hz/test.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/hz/tf.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/hz/users.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/main.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/utility_commands.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/warehouse.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.9.0}/tests/batch/__init__.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.9.0}/tests/batch/test_aws_batch_resources.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.9.0}/tests/batch/test_image_override.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.9.0}/tests/batch/test_submit_cpu_only.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.9.0}/tests/batch/test_submit_image_validation.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.9.0}/tests/batch/test_submit_merge.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.9.0}/tests/bedrock/conftest.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.9.0}/tests/bedrock/fixtures/A_cache_write.json +0 -0
- {dh_cli-0.8.7 → dh_cli-0.9.0}/tests/bedrock/fixtures/B_cache_read.json +0 -0
- {dh_cli-0.8.7 → dh_cli-0.9.0}/tests/bedrock/fixtures/C_plain.json +0 -0
- {dh_cli-0.8.7 → dh_cli-0.9.0}/tests/bedrock/fixtures/D_cursor_user.json +0 -0
- {dh_cli-0.8.7 → dh_cli-0.9.0}/tests/bedrock/fixtures/E_service_role.json +0 -0
- {dh_cli-0.8.7 → dh_cli-0.9.0}/tests/bedrock/fixtures/F_legacy_shared.json +0 -0
- {dh_cli-0.8.7 → dh_cli-0.9.0}/tests/bedrock/fixtures/G_unknown_model.json +0 -0
- {dh_cli-0.8.7 → dh_cli-0.9.0}/tests/bedrock/test_build_report.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.9.0}/tests/bedrock/test_classify_arn.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.9.0}/tests/bedrock/test_cli_exit_codes.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.9.0}/tests/bedrock/test_cost_calc.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.9.0}/tests/bedrock/test_cur_reconciliation.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.9.0}/tests/bedrock/test_render_formats.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.9.0}/tests/bedrock/test_resolve_base_model.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.9.0}/tests/bedrock/test_s3_walker.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.9.0}/tests/github/__init__.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.9.0}/tests/github/test_engine_role_cannot_read_github_pat.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.9.0}/tests/github/test_identity.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.9.0}/tests/github/test_login.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.9.0}/tests/github/test_login_error_paths.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.9.0}/tests/github/test_login_security.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.9.0}/tests/github/test_logout.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.9.0}/tests/github/test_rotate.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.9.0}/tests/github/test_status.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.9.0}/tests/hz/test_init.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.9.0}/tests/hz/test_suites.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.9.0}/tests/hz/test_users.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.9.0}/tests/test_cloud_gcp.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.9.0}/tests/test_finalize_boltz_tar.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.9.0}/tests/test_finalize_protmpnn.py +0 -0
|
@@ -22,6 +22,7 @@ from .commands.orca import orca
|
|
|
22
22
|
from .commands.protmpnn import protmpnn
|
|
23
23
|
from .commands.protmpnn_to_boltz import protmpnn_to_boltz
|
|
24
24
|
from .commands.retry import retry
|
|
25
|
+
from .commands.rfd3 import rfd3
|
|
25
26
|
from .commands.status import status
|
|
26
27
|
from .commands.submit import submit
|
|
27
28
|
from .commands.train import train
|
|
@@ -102,6 +103,7 @@ batch_cli.add_command(boltz)
|
|
|
102
103
|
batch_cli.add_command(protmpnn)
|
|
103
104
|
batch_cli.add_command(orca)
|
|
104
105
|
batch_cli.add_command(protmpnn_to_boltz, name="protmpnn-to-boltz")
|
|
106
|
+
batch_cli.add_command(rfd3)
|
|
105
107
|
batch_cli.add_command(train)
|
|
106
108
|
|
|
107
109
|
__all__ = ["batch_cli"]
|
|
@@ -143,6 +143,8 @@ def finalize(job_id, output, force, keep_intermediates, full_output, skip_dedup,
|
|
|
143
143
|
_finalize_boltz(output_dir, output_path, full_output=full_output)
|
|
144
144
|
elif manifest.pipeline == "protmpnn":
|
|
145
145
|
_finalize_protmpnn(output_dir, output_path)
|
|
146
|
+
elif manifest.pipeline == "rfd3":
|
|
147
|
+
_finalize_rfd3(output_dir, output_path)
|
|
146
148
|
else:
|
|
147
149
|
_finalize_generic(output_dir, output_path)
|
|
148
150
|
|
|
@@ -544,3 +546,37 @@ def _finalize_protmpnn(output_dir: Path, output_path: Path):
|
|
|
544
546
|
|
|
545
547
|
click.echo(f"{num_variants} variants from {num_configs} config(s), top confidence: {top_conf}")
|
|
546
548
|
click.echo(click.style(f"Results: {output_path / 'results.csv'}", fg="green"))
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def _finalize_rfd3(output_dir: Path, output_path: Path):
|
|
552
|
+
"""Collate completed RFD3 per-config backbone dirs into the final output.
|
|
553
|
+
|
|
554
|
+
The worker promotes each config's flat `.cif.gz` + `.json` design files
|
|
555
|
+
into `output/<stem>/` and drops a `output/rfd3_<stem>.done` marker only
|
|
556
|
+
when validation passed. Finalize copies each completed config's directory
|
|
557
|
+
into `output_path/<stem>/`, skipping configs without a `.done` marker and
|
|
558
|
+
the loose worker-index markers.
|
|
559
|
+
"""
|
|
560
|
+
completed = []
|
|
561
|
+
for marker in sorted(output_dir.glob("rfd3_*.done")):
|
|
562
|
+
stem = marker.name[len("rfd3_") : -len(".done")]
|
|
563
|
+
if (output_dir / stem).is_dir():
|
|
564
|
+
completed.append(stem)
|
|
565
|
+
|
|
566
|
+
if not completed:
|
|
567
|
+
click.echo("No completed RFD3 configs found in output directory.", err=True)
|
|
568
|
+
raise SystemExit(1)
|
|
569
|
+
|
|
570
|
+
output_path.mkdir(parents=True, exist_ok=True)
|
|
571
|
+
|
|
572
|
+
copied_count = 0
|
|
573
|
+
for stem in completed:
|
|
574
|
+
dest = output_path / stem
|
|
575
|
+
if dest.exists():
|
|
576
|
+
click.echo(f" Skipping {stem} (already exists)")
|
|
577
|
+
continue
|
|
578
|
+
shutil.copytree(output_dir / stem, dest)
|
|
579
|
+
copied_count += 1
|
|
580
|
+
|
|
581
|
+
click.echo(f"Collated {copied_count} RFD3 config(s)")
|
|
582
|
+
click.echo(click.style(f"Results: {output_path}", fg="green"))
|
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
"""RFD3 protein backbone design pipeline command."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
import platform
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
import yaml
|
|
11
|
+
|
|
12
|
+
from ..aws_batch import BatchClient, BatchError, resolve_dependency
|
|
13
|
+
from ..job_id import generate_job_id, get_aws_username
|
|
14
|
+
from ..manifest import (
|
|
15
|
+
BATCH_JOBS_BASE,
|
|
16
|
+
BatchConfig,
|
|
17
|
+
InputConfig,
|
|
18
|
+
JobManifest,
|
|
19
|
+
JobStatus,
|
|
20
|
+
OutputConfig,
|
|
21
|
+
create_job_directory,
|
|
22
|
+
save_manifest,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
DEFAULT_QUEUE = "a10g-1x-spot"
|
|
26
|
+
MAX_WORKERS = 50
|
|
27
|
+
FILES_PER_WORKER = 2
|
|
28
|
+
DEFAULT_JOB_DEFINITION = "dayhoff-rfd3"
|
|
29
|
+
DEFAULT_IMAGE_URI = "074735440724.dkr.ecr.us-east-1.amazonaws.com/dayhoff:rfd3-latest"
|
|
30
|
+
OFFLINE_ENV_VAR = "HF_HUB_OFFLINE"
|
|
31
|
+
|
|
32
|
+
CHECKPOINT_SUBTREE = "/primordial/.cache/foundry/checkpoints"
|
|
33
|
+
PRIMORDIAL_CHECKPOINT_DIR = Path(CHECKPOINT_SUBTREE)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@click.command()
|
|
37
|
+
@click.argument("input_dir", type=click.Path(exists=True))
|
|
38
|
+
@click.option(
|
|
39
|
+
"--workers",
|
|
40
|
+
default=None,
|
|
41
|
+
type=int,
|
|
42
|
+
help="Number of parallel workers [default: ~1 per 2 files]",
|
|
43
|
+
)
|
|
44
|
+
@click.option(
|
|
45
|
+
"--queue",
|
|
46
|
+
default=DEFAULT_QUEUE,
|
|
47
|
+
help=f"Batch queue [default: {DEFAULT_QUEUE}]",
|
|
48
|
+
)
|
|
49
|
+
@click.option("--dry-run", is_flag=True, help="Show plan without submitting")
|
|
50
|
+
@click.option(
|
|
51
|
+
"--local",
|
|
52
|
+
"run_local",
|
|
53
|
+
is_flag=True,
|
|
54
|
+
help="Force local execution via Docker",
|
|
55
|
+
)
|
|
56
|
+
@click.option(
|
|
57
|
+
"--remote",
|
|
58
|
+
"run_remote",
|
|
59
|
+
is_flag=True,
|
|
60
|
+
help="Force Batch submission (override auto-detect)",
|
|
61
|
+
)
|
|
62
|
+
@click.option("--base-path", default=BATCH_JOBS_BASE, help="Base path for job data")
|
|
63
|
+
@click.option("--after", "after", multiple=True, help="Job ID(s) to wait for before starting")
|
|
64
|
+
@click.option("--num-designs", default=1, type=int, help="Number of designs per config")
|
|
65
|
+
def rfd3(
|
|
66
|
+
input_dir,
|
|
67
|
+
workers,
|
|
68
|
+
queue,
|
|
69
|
+
dry_run,
|
|
70
|
+
run_local,
|
|
71
|
+
run_remote,
|
|
72
|
+
base_path,
|
|
73
|
+
after,
|
|
74
|
+
num_designs,
|
|
75
|
+
):
|
|
76
|
+
"""Design protein backbones with RFD3.
|
|
77
|
+
|
|
78
|
+
Processes a directory of YAML config files, each specifying design
|
|
79
|
+
parameters and an optional motif structure.
|
|
80
|
+
|
|
81
|
+
\b
|
|
82
|
+
Examples:
|
|
83
|
+
# Run on a GPU engine (auto-detects GPU, runs locally)
|
|
84
|
+
dh batch rfd3 input/
|
|
85
|
+
|
|
86
|
+
# Force remote Batch submission
|
|
87
|
+
dh batch rfd3 input/ --remote
|
|
88
|
+
|
|
89
|
+
# Preview what would run
|
|
90
|
+
dh batch rfd3 input/ --dry-run
|
|
91
|
+
|
|
92
|
+
# Run locally via Docker
|
|
93
|
+
dh batch rfd3 input/ --local
|
|
94
|
+
"""
|
|
95
|
+
input_path = Path(input_dir).resolve()
|
|
96
|
+
|
|
97
|
+
if run_local:
|
|
98
|
+
_run_local_mode(input_path, base_path)
|
|
99
|
+
return
|
|
100
|
+
|
|
101
|
+
if not run_remote and not dry_run:
|
|
102
|
+
if _has_local_gpu():
|
|
103
|
+
click.echo("GPU detected — running locally (use --remote to override)")
|
|
104
|
+
_run_local_mode(input_path, base_path)
|
|
105
|
+
return
|
|
106
|
+
|
|
107
|
+
_submit_batch_job(input_path, workers, queue, dry_run, base_path, after, num_designs)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _has_local_gpu() -> bool:
|
|
111
|
+
"""Check if a local NVIDIA GPU is available."""
|
|
112
|
+
try:
|
|
113
|
+
result = subprocess.run(
|
|
114
|
+
["nvidia-smi"],
|
|
115
|
+
capture_output=True,
|
|
116
|
+
timeout=5,
|
|
117
|
+
)
|
|
118
|
+
return result.returncode == 0
|
|
119
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
120
|
+
return False
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _has_nvidia_runtime() -> bool:
|
|
124
|
+
"""Check if the NVIDIA container runtime is available to Docker."""
|
|
125
|
+
try:
|
|
126
|
+
result = subprocess.run(
|
|
127
|
+
["docker", "info", "--format", "{{json .Runtimes}}"],
|
|
128
|
+
capture_output=True,
|
|
129
|
+
text=True,
|
|
130
|
+
timeout=5,
|
|
131
|
+
)
|
|
132
|
+
return result.returncode == 0 and "nvidia" in result.stdout
|
|
133
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
134
|
+
return False
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _discover_configs(input_path: Path) -> list[Path]:
|
|
138
|
+
return sorted(input_path.glob("*.yaml"))
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _validate_motif_paths(yaml_path: Path, input_root: Path):
|
|
142
|
+
"""Reject absolute, escaping, or missing motif paths referenced by a config."""
|
|
143
|
+
with open(yaml_path) as f:
|
|
144
|
+
data = yaml.safe_load(f)
|
|
145
|
+
|
|
146
|
+
if not isinstance(data, dict):
|
|
147
|
+
return
|
|
148
|
+
|
|
149
|
+
for entry in data.values():
|
|
150
|
+
if not isinstance(entry, dict) or "input" not in entry:
|
|
151
|
+
continue
|
|
152
|
+
motif = entry["input"]
|
|
153
|
+
|
|
154
|
+
if Path(motif).is_absolute():
|
|
155
|
+
raise click.ClickException(f"Motif path must be relative, got absolute: {motif}")
|
|
156
|
+
|
|
157
|
+
resolved = (input_root / motif).resolve()
|
|
158
|
+
if not resolved.is_relative_to(input_root):
|
|
159
|
+
raise click.ClickException(f"Motif path escapes input dir: {motif}")
|
|
160
|
+
|
|
161
|
+
if not resolved.exists():
|
|
162
|
+
raise click.ClickException(f"Motif file not found: {motif}")
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _validate_inputs(input_path: Path) -> list[Path]:
|
|
166
|
+
"""Discover and validate configs; raise a clean CLI error on any violation."""
|
|
167
|
+
configs = _discover_configs(input_path)
|
|
168
|
+
if not configs:
|
|
169
|
+
raise click.ClickException(f"No YAML config files found in {input_path}")
|
|
170
|
+
|
|
171
|
+
for config in configs:
|
|
172
|
+
_validate_motif_paths(config, input_path)
|
|
173
|
+
|
|
174
|
+
return configs
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _referenced_motifs(config: Path) -> list[str]:
|
|
178
|
+
"""Return the relative motif ``input:`` paths referenced by a config."""
|
|
179
|
+
with open(config) as f:
|
|
180
|
+
data = yaml.safe_load(f)
|
|
181
|
+
|
|
182
|
+
if not isinstance(data, dict):
|
|
183
|
+
return []
|
|
184
|
+
|
|
185
|
+
motifs = []
|
|
186
|
+
for entry in data.values():
|
|
187
|
+
if isinstance(entry, dict) and "input" in entry:
|
|
188
|
+
motifs.append(entry["input"])
|
|
189
|
+
return motifs
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _copy_inputs_to_job_dir(input_path: Path, job_dir: Path) -> int:
|
|
193
|
+
"""Copy configs and their referenced motif files into the job directory.
|
|
194
|
+
|
|
195
|
+
Motifs are copied at their relative subpath (e.g. ``motifs/x.pdb``) so
|
|
196
|
+
RFD3 resolves the config's relative ``input:`` path from the copied dir.
|
|
197
|
+
"""
|
|
198
|
+
dest = job_dir / "input"
|
|
199
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
200
|
+
|
|
201
|
+
count = 0
|
|
202
|
+
for yaml_file in sorted(input_path.glob("*.yaml")):
|
|
203
|
+
shutil.copy2(yaml_file, dest / yaml_file.name)
|
|
204
|
+
count += 1
|
|
205
|
+
for motif in _referenced_motifs(yaml_file):
|
|
206
|
+
src = input_path / motif
|
|
207
|
+
target = dest / motif
|
|
208
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
209
|
+
shutil.copy2(src, target)
|
|
210
|
+
|
|
211
|
+
return count
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _submit_batch_job(
|
|
215
|
+
input_path: Path,
|
|
216
|
+
workers: int | None,
|
|
217
|
+
queue: str,
|
|
218
|
+
dry_run: bool,
|
|
219
|
+
base_path: str,
|
|
220
|
+
after: tuple[str, ...],
|
|
221
|
+
num_designs: int,
|
|
222
|
+
):
|
|
223
|
+
"""Submit an RFD3 job to AWS Batch."""
|
|
224
|
+
click.echo(f"Scanning {input_path} for YAML files...")
|
|
225
|
+
configs = _validate_inputs(input_path)
|
|
226
|
+
num_files = len(configs)
|
|
227
|
+
click.echo(f"Found {num_files} config(s) to process")
|
|
228
|
+
|
|
229
|
+
if workers is None:
|
|
230
|
+
workers = max(1, min(math.ceil(num_files / FILES_PER_WORKER), MAX_WORKERS))
|
|
231
|
+
array_size = min(num_files, workers)
|
|
232
|
+
|
|
233
|
+
job_id = generate_job_id("rfd3")
|
|
234
|
+
|
|
235
|
+
click.echo()
|
|
236
|
+
click.echo(f"Job ID: {job_id}")
|
|
237
|
+
click.echo(f"Input: {input_path}")
|
|
238
|
+
click.echo(f"Configs: {num_files}")
|
|
239
|
+
click.echo(f"Workers: {array_size}")
|
|
240
|
+
click.echo(f"Num designs: {num_designs}")
|
|
241
|
+
click.echo(f"Queue: {DEFAULT_QUEUE}")
|
|
242
|
+
click.echo(f"Job definition: {DEFAULT_JOB_DEFINITION}")
|
|
243
|
+
click.echo(f"Image: {DEFAULT_IMAGE_URI}")
|
|
244
|
+
|
|
245
|
+
if dry_run:
|
|
246
|
+
click.echo()
|
|
247
|
+
click.echo(click.style("Dry run - job not submitted (REMOTE plan)", fg="yellow"))
|
|
248
|
+
return
|
|
249
|
+
|
|
250
|
+
if not click.confirm("\nSubmit job?", default=True):
|
|
251
|
+
click.echo("Cancelled.")
|
|
252
|
+
raise SystemExit(0)
|
|
253
|
+
click.echo()
|
|
254
|
+
|
|
255
|
+
client = BatchClient()
|
|
256
|
+
|
|
257
|
+
response = client.batch.describe_job_definitions(jobDefinitionName=DEFAULT_JOB_DEFINITION, status="ACTIVE")
|
|
258
|
+
if not response.get("jobDefinitions"):
|
|
259
|
+
raise click.ClickException("rfd3 job definition not yet deployed; apply Terraform in environments/dev first")
|
|
260
|
+
|
|
261
|
+
job_dir = create_job_directory(job_id, base_path)
|
|
262
|
+
click.echo(f"Created job directory: {job_dir}")
|
|
263
|
+
|
|
264
|
+
click.echo("Copying input files...")
|
|
265
|
+
copied = _copy_inputs_to_job_dir(input_path, job_dir)
|
|
266
|
+
click.echo(f"Copied {copied} YAML files")
|
|
267
|
+
|
|
268
|
+
manifest = JobManifest(
|
|
269
|
+
job_id=job_id,
|
|
270
|
+
user=job_id.split("-")[0],
|
|
271
|
+
pipeline="rfd3",
|
|
272
|
+
status=JobStatus.PENDING,
|
|
273
|
+
image_uri=DEFAULT_IMAGE_URI,
|
|
274
|
+
input=InputConfig(
|
|
275
|
+
source=str(input_path),
|
|
276
|
+
num_sequences=num_files,
|
|
277
|
+
num_chunks=array_size,
|
|
278
|
+
),
|
|
279
|
+
batch=BatchConfig(
|
|
280
|
+
queue=queue,
|
|
281
|
+
job_definition=DEFAULT_JOB_DEFINITION,
|
|
282
|
+
array_size=array_size,
|
|
283
|
+
),
|
|
284
|
+
output=OutputConfig(
|
|
285
|
+
destination=None,
|
|
286
|
+
finalized=False,
|
|
287
|
+
),
|
|
288
|
+
depends_on=list(after) if after else None,
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
save_manifest(manifest, base_path)
|
|
292
|
+
|
|
293
|
+
try:
|
|
294
|
+
resolved = [resolve_dependency(jid, base_path) for jid in after]
|
|
295
|
+
depends_on = [{"jobId": aws_id} for aws_id in resolved if aws_id is not None] or None
|
|
296
|
+
|
|
297
|
+
environment = {
|
|
298
|
+
"JOB_DIR": str(job_dir),
|
|
299
|
+
"JOB_ID": job_id,
|
|
300
|
+
"BATCH_ARRAY_SIZE": str(array_size),
|
|
301
|
+
"BATCH_NUM_FILES": str(num_files),
|
|
302
|
+
"FOUNDRY_CHECKPOINT_DIRS": CHECKPOINT_SUBTREE,
|
|
303
|
+
OFFLINE_ENV_VAR: "1",
|
|
304
|
+
"NUM_DESIGNS": str(num_designs),
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
batch_job_id = client.submit_job(
|
|
308
|
+
job_name=job_id,
|
|
309
|
+
job_definition=DEFAULT_JOB_DEFINITION,
|
|
310
|
+
job_queue=queue,
|
|
311
|
+
array_size=array_size,
|
|
312
|
+
environment=environment,
|
|
313
|
+
timeout_seconds=12 * 3600,
|
|
314
|
+
retry_attempts=2,
|
|
315
|
+
depends_on=depends_on,
|
|
316
|
+
share_identifier=get_aws_username(),
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
manifest.status = JobStatus.SUBMITTED
|
|
320
|
+
manifest.batch.job_id = batch_job_id
|
|
321
|
+
save_manifest(manifest, base_path)
|
|
322
|
+
|
|
323
|
+
click.echo()
|
|
324
|
+
click.echo(click.style("Job submitted successfully!", fg="green"))
|
|
325
|
+
click.echo()
|
|
326
|
+
click.echo(f"AWS Batch Job ID: {batch_job_id}")
|
|
327
|
+
if depends_on:
|
|
328
|
+
click.echo(f"Waiting on: {', '.join(after)}")
|
|
329
|
+
click.echo()
|
|
330
|
+
click.echo("Next steps:")
|
|
331
|
+
click.echo(f" Check status: dh batch status {job_id}")
|
|
332
|
+
click.echo(f" View logs: dh batch logs {job_id}")
|
|
333
|
+
click.echo(f" Cancel: dh batch cancel {job_id}")
|
|
334
|
+
click.echo()
|
|
335
|
+
click.echo("After completion:")
|
|
336
|
+
click.echo(f" Finalize: dh batch finalize {job_id} --output ./results/")
|
|
337
|
+
|
|
338
|
+
except BatchError as e:
|
|
339
|
+
manifest.status = JobStatus.FAILED
|
|
340
|
+
manifest.error_message = str(e)
|
|
341
|
+
save_manifest(manifest, base_path)
|
|
342
|
+
click.echo(click.style(f"Failed to submit job: {e}", fg="red"), err=True)
|
|
343
|
+
raise SystemExit(1)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _run_local_mode(input_path: Path, base_path: str = BATCH_JOBS_BASE):
|
|
347
|
+
"""Run RFD3 locally in a Docker container after host-side fast-fail checks."""
|
|
348
|
+
machine = platform.machine()
|
|
349
|
+
if machine not in {"x86_64", "amd64"}:
|
|
350
|
+
raise click.ClickException(
|
|
351
|
+
f"Local RFD3 requires an x86_64 host (got {machine}); "
|
|
352
|
+
"no local image exists for this architecture. Use --remote instead."
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
if not PRIMORDIAL_CHECKPOINT_DIR.exists() or not any(PRIMORDIAL_CHECKPOINT_DIR.iterdir()):
|
|
356
|
+
raise click.ClickException(
|
|
357
|
+
f"Foundry checkpoints not found at {PRIMORDIAL_CHECKPOINT_DIR}; "
|
|
358
|
+
"this host cannot run RFD3 locally. Use --remote instead."
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
if not _has_nvidia_runtime():
|
|
362
|
+
raise click.ClickException(
|
|
363
|
+
"NVIDIA container runtime not available to Docker; cannot run RFD3 locally. Use --remote instead."
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
configs = _validate_inputs(input_path)
|
|
367
|
+
|
|
368
|
+
temp_job_dir = input_path / ".local_rfd3_job"
|
|
369
|
+
if temp_job_dir.exists():
|
|
370
|
+
shutil.rmtree(temp_job_dir)
|
|
371
|
+
(temp_job_dir / "output").mkdir(parents=True)
|
|
372
|
+
_copy_inputs_to_job_dir(input_path, temp_job_dir)
|
|
373
|
+
|
|
374
|
+
click.echo(f"Running RFD3 locally on {len(configs)} config(s)...")
|
|
375
|
+
|
|
376
|
+
cmd = [
|
|
377
|
+
"docker",
|
|
378
|
+
"run",
|
|
379
|
+
"--rm",
|
|
380
|
+
"--gpus",
|
|
381
|
+
"all",
|
|
382
|
+
"-v",
|
|
383
|
+
"/primordial:/primordial",
|
|
384
|
+
"-v",
|
|
385
|
+
f"{temp_job_dir}:{temp_job_dir}",
|
|
386
|
+
"-e",
|
|
387
|
+
f"JOB_DIR={temp_job_dir}",
|
|
388
|
+
"-e",
|
|
389
|
+
"AWS_BATCH_JOB_ARRAY_INDEX=0",
|
|
390
|
+
"-e",
|
|
391
|
+
"BATCH_ARRAY_SIZE=1",
|
|
392
|
+
"-e",
|
|
393
|
+
f"BATCH_NUM_FILES={len(configs)}",
|
|
394
|
+
"-e",
|
|
395
|
+
f"FOUNDRY_CHECKPOINT_DIRS={CHECKPOINT_SUBTREE}",
|
|
396
|
+
"-e",
|
|
397
|
+
f"{OFFLINE_ENV_VAR}=1",
|
|
398
|
+
DEFAULT_IMAGE_URI,
|
|
399
|
+
]
|
|
400
|
+
|
|
401
|
+
click.echo(f"Running: {' '.join(cmd)}")
|
|
402
|
+
subprocess.run(cmd)
|
|
@@ -17,15 +17,30 @@ from __future__ import annotations
|
|
|
17
17
|
|
|
18
18
|
import datetime as dt
|
|
19
19
|
import json
|
|
20
|
+
import re
|
|
20
21
|
import sys
|
|
21
|
-
from typing import Optional
|
|
22
|
+
from typing import Optional, TypedDict
|
|
23
|
+
from uuid import uuid4
|
|
22
24
|
|
|
23
25
|
import click
|
|
24
26
|
|
|
25
27
|
from dh_cli._identity import HandleResolutionError, resolve_handle_from_session
|
|
28
|
+
from dh_cli.github_commands import _scrub_token
|
|
26
29
|
|
|
27
30
|
from . import cost_report as cr
|
|
28
31
|
|
|
32
|
+
|
|
33
|
+
class BedrockKeyPayload(TypedDict):
|
|
34
|
+
"""The JSON shape stored in `cursor-bedrock/<handle>`."""
|
|
35
|
+
|
|
36
|
+
access_key_id: str
|
|
37
|
+
secret_access_key: str
|
|
38
|
+
region: str
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# Matches the admin SSO role within an assumed-role ARN.
|
|
42
|
+
_ADMIN_ROLE_RE = re.compile(r"AWSReservedSSO_DevAdminAccess_")
|
|
43
|
+
|
|
29
44
|
# Hard-coded for the dev account. Kept here (not as user-facing flags)
|
|
30
45
|
# because they never change in practice and making every dev remember
|
|
31
46
|
# them is the whole reason this command exists.
|
|
@@ -124,9 +139,18 @@ def bedrock_key(handle: Optional[str], region: str, mode: str):
|
|
|
124
139
|
resolved = handle or _resolve_handle()
|
|
125
140
|
secret_id = f"{_SECRET_PREFIX}{resolved}"
|
|
126
141
|
|
|
142
|
+
sm = boto3.client("secretsmanager", region_name=region)
|
|
143
|
+
not_found = getattr(sm.exceptions, "ResourceNotFoundException", None)
|
|
144
|
+
if not (isinstance(not_found, type) and issubclass(not_found, BaseException)):
|
|
145
|
+
not_found = ()
|
|
127
146
|
try:
|
|
128
|
-
sm = boto3.client("secretsmanager", region_name=region)
|
|
129
147
|
resp = sm.get_secret_value(SecretId=secret_id)
|
|
148
|
+
except not_found:
|
|
149
|
+
raise click.ClickException(
|
|
150
|
+
f"No key yet for `{resolved}` (secret `{secret_id}` has no current "
|
|
151
|
+
f"version). Ask an admin to run `dh bedrock rotate --handle {resolved}` "
|
|
152
|
+
"to mint your first key."
|
|
153
|
+
)
|
|
130
154
|
except Exception as exc:
|
|
131
155
|
raise click.ClickException(
|
|
132
156
|
f"Could not read secret `{secret_id}`: {exc}\n"
|
|
@@ -151,6 +175,104 @@ def bedrock_key(handle: Optional[str], region: str, mode: str):
|
|
|
151
175
|
click.echo(json.dumps(payload, indent=2))
|
|
152
176
|
|
|
153
177
|
|
|
178
|
+
# --------------------------------------------------------------------------
|
|
179
|
+
# `dh bedrock rotate`
|
|
180
|
+
# --------------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@bedrock.command("rotate")
|
|
184
|
+
@click.option(
|
|
185
|
+
"--handle",
|
|
186
|
+
"-u",
|
|
187
|
+
required=True,
|
|
188
|
+
help="Developer handle (e.g. 'dma'). Mints a key for `dev-cursor-<handle>`.",
|
|
189
|
+
)
|
|
190
|
+
@click.option(
|
|
191
|
+
"--region",
|
|
192
|
+
default=_DEFAULT_REGION,
|
|
193
|
+
show_default=True,
|
|
194
|
+
help="AWS region for the Secrets Manager secret.",
|
|
195
|
+
)
|
|
196
|
+
def bedrock_rotate(handle: str, region: str):
|
|
197
|
+
"""Admin: mint a fresh Cursor Bedrock key for a developer.
|
|
198
|
+
|
|
199
|
+
Runs as `dev-admin`. Creates a new IAM access key for
|
|
200
|
+
`dev-cursor-<handle>`, writes it to `cursor-bedrock/<handle>`, then
|
|
201
|
+
deletes the prior key (delete-now).
|
|
202
|
+
|
|
203
|
+
\b
|
|
204
|
+
Examples:
|
|
205
|
+
dh bedrock rotate --handle dma
|
|
206
|
+
"""
|
|
207
|
+
import boto3
|
|
208
|
+
|
|
209
|
+
user_name = f"dev-cursor-{handle}"
|
|
210
|
+
secret_id = f"{_SECRET_PREFIX}{handle}"
|
|
211
|
+
|
|
212
|
+
# Admin pre-check (defense-in-depth) — before any AWS mutation.
|
|
213
|
+
sts = boto3.Session().client("sts")
|
|
214
|
+
caller_arn = sts.get_caller_identity()["Arn"]
|
|
215
|
+
if not _ADMIN_ROLE_RE.search(caller_arn):
|
|
216
|
+
raise click.ClickException(
|
|
217
|
+
f"This command must be run as dev-admin (`awslogin dev-admin`). "
|
|
218
|
+
f"Current caller is not a DevAdminAccess role: {caller_arn}"
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
iam = boto3.client("iam")
|
|
222
|
+
sm = boto3.client("secretsmanager", region_name=region)
|
|
223
|
+
|
|
224
|
+
# Preflight — before create_access_key.
|
|
225
|
+
try:
|
|
226
|
+
resp = iam.list_access_keys(UserName=user_name)
|
|
227
|
+
except Exception as exc:
|
|
228
|
+
raise click.ClickException(
|
|
229
|
+
f"Could not list access keys for `{user_name}`: {exc}\n"
|
|
230
|
+
f"The IAM user may not exist yet — run `tfa` to provision it first."
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
prior_active_ids = [
|
|
234
|
+
meta["AccessKeyId"] for meta in resp.get("AccessKeyMetadata", []) if meta.get("Status") == "Active"
|
|
235
|
+
]
|
|
236
|
+
if len(prior_active_ids) >= 2:
|
|
237
|
+
raise click.ClickException(
|
|
238
|
+
f"`{user_name}` already has 2 active keys (the IAM per-user limit). Delete one before rotating."
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
# Transaction: create → put → delete prior.
|
|
242
|
+
create_resp = iam.create_access_key(UserName=user_name)
|
|
243
|
+
new_akid = create_resp["AccessKey"]["AccessKeyId"]
|
|
244
|
+
new_secret = create_resp["AccessKey"]["SecretAccessKey"]
|
|
245
|
+
|
|
246
|
+
payload: BedrockKeyPayload = {
|
|
247
|
+
"access_key_id": new_akid,
|
|
248
|
+
"secret_access_key": new_secret,
|
|
249
|
+
"region": region,
|
|
250
|
+
}
|
|
251
|
+
try:
|
|
252
|
+
sm.put_secret_value(
|
|
253
|
+
SecretId=secret_id,
|
|
254
|
+
SecretString=json.dumps(payload),
|
|
255
|
+
ClientRequestToken=uuid4().hex,
|
|
256
|
+
)
|
|
257
|
+
except Exception as exc:
|
|
258
|
+
# Roll back the just-created key. Scrub the secret BEFORE building
|
|
259
|
+
# the message so the ClickException output can never leak it.
|
|
260
|
+
iam.delete_access_key(UserName=user_name, AccessKeyId=new_akid)
|
|
261
|
+
scrubbed = _scrub_token(str(exc), new_secret)
|
|
262
|
+
raise click.ClickException(
|
|
263
|
+
f"PutSecretValue failed for `{secret_id}`; rolled back new key "
|
|
264
|
+
f"{new_akid}. No prior key was deleted. Error: {scrubbed}"
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
# Only after put succeeds: delete prior active keys (not the new one).
|
|
268
|
+
for old_id in prior_active_ids:
|
|
269
|
+
if old_id == new_akid:
|
|
270
|
+
continue
|
|
271
|
+
iam.delete_access_key(UserName=user_name, AccessKeyId=old_id)
|
|
272
|
+
|
|
273
|
+
click.echo(f"Rotated `{secret_id}`. New access_key_id: {new_akid}")
|
|
274
|
+
|
|
275
|
+
|
|
154
276
|
# --------------------------------------------------------------------------
|
|
155
277
|
# `dh bedrock cost`
|
|
156
278
|
# --------------------------------------------------------------------------
|