dh-cli 0.12.4__tar.gz → 0.12.6__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.12.4 → dh_cli-0.12.6}/PKG-INFO +2 -2
- {dh_cli-0.12.4 → dh_cli-0.12.6}/pyproject.toml +1 -1
- {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/batch/aws_batch.py +44 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/batch/commands/submit.py +5 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/cloud_commands.py +67 -3
- {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/engines_studios/engine_commands.py +1 -3
- dh_cli-0.12.6/tests/batch/test_submit_job_tags.py +157 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/batch/test_submit_merge.py +38 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/engines_studios/test_retry.py +7 -20
- dh_cli-0.12.6/tests/test_aws_status.py +184 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/.gitignore +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/LICENSE +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/README.md +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/__init__.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/_identity.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/batch/__init__.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/batch/commands/__init__.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/batch/commands/boltz.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/batch/commands/cancel.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/batch/commands/clean.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/batch/commands/disco.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/batch/commands/embed_t5.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/batch/commands/equilibrator.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/batch/commands/esmfold2.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/batch/commands/finalize.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/batch/commands/list_jobs.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/batch/commands/local.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/batch/commands/logs.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/batch/commands/orca.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/batch/commands/protmpnn.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/batch/commands/protmpnn_to_boltz.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/batch/commands/retry.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/batch/commands/rfd3.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/batch/commands/status.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/batch/commands/wait_for.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/batch/fasta_utils.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/batch/h5_utils.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/batch/job_id.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/batch/manifest.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/batch/s3_transport.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/bedrock/__init__.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/bedrock/commands.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/bedrock/cost_report.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/bedrock/pricing.yaml +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/codeartifact.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/engines_studios/__init__.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/engines_studios/api_client.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/engines_studios/auth.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/engines_studios/progress.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/engines_studios/retry.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/engines_studios/ssh_config.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/engines_studios/studio_commands.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/github_commands.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/hz/__init__.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/hz/deploy.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/hz/local.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/hz/test.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/hz/tf.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/hz/users.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/main.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/sweep.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/utility_commands.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/warehouse.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/batch/__init__.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/batch/test_aws_batch_resources.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/batch/test_image_override.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/batch/test_no_train_command.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/batch/test_submit_cpu_only.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/batch/test_submit_image_validation.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/batch/test_submit_mlflow_env.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/bedrock/conftest.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/bedrock/fixtures/A_cache_write.json +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/bedrock/fixtures/B_cache_read.json +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/bedrock/fixtures/C_plain.json +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/bedrock/fixtures/D_cursor_user.json +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/bedrock/fixtures/E_service_role.json +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/bedrock/fixtures/F_legacy_shared.json +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/bedrock/fixtures/G_unknown_model.json +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/bedrock/test_build_report.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/bedrock/test_classify_arn.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/bedrock/test_cli_exit_codes.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/bedrock/test_cost_calc.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/bedrock/test_cost_command.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/bedrock/test_cur_reconciliation.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/bedrock/test_key_command.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/bedrock/test_render_formats.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/bedrock/test_resolve_base_model.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/bedrock/test_rotate.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/bedrock/test_s3_walker.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/engines_studios/__init__.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/engines_studios/test_engine_retry_commands.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/github/__init__.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/github/conftest.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/github/test_engine_role_cannot_read_github_pat.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/github/test_identity.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/github/test_login.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/github/test_login_error_paths.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/github/test_login_security.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/github/test_logout.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/github/test_rotate.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/github/test_status.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/hz/test_init.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/hz/test_suites.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/hz/test_users.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/test_cloud_gcp.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/test_disco_command.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/test_disco_jobdef.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/test_equilibrator_command.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/test_esmfold2_cli.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/test_finalize_boltz_tar.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/test_finalize_disco.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/test_finalize_disco_dispatch.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/test_finalize_equilibrator.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/test_finalize_esmfold2.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/test_finalize_protmpnn.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/test_finalize_rfd3.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/test_rfd3_command.py +0 -0
- {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/test_sweep.py +0 -0
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import logging
|
|
4
4
|
import re
|
|
5
|
+
import socket
|
|
5
6
|
import time
|
|
6
7
|
from dataclasses import dataclass
|
|
7
8
|
from typing import Any
|
|
@@ -9,6 +10,7 @@ from typing import Any
|
|
|
9
10
|
import boto3
|
|
10
11
|
from botocore.exceptions import ClientError
|
|
11
12
|
|
|
13
|
+
from dh_cli.batch.job_id import get_aws_username
|
|
12
14
|
from dh_cli.batch.manifest import (
|
|
13
15
|
BATCH_JOBS_BASE,
|
|
14
16
|
JobStatus,
|
|
@@ -29,6 +31,36 @@ class BatchError(Exception):
|
|
|
29
31
|
pass
|
|
30
32
|
|
|
31
33
|
|
|
34
|
+
def _pipeline_from_definition(job_definition: str) -> str:
|
|
35
|
+
"""Derive the pipeline name from a job definition name or ARN.
|
|
36
|
+
|
|
37
|
+
'dayhoff-embed-t5', 'dayhoff-embed-t5:3' and the full ARN all yield
|
|
38
|
+
'embed-t5'. Falls back to the bare definition name if the 'dayhoff-'
|
|
39
|
+
prefix is absent.
|
|
40
|
+
"""
|
|
41
|
+
name = job_definition.rsplit("/", 1)[-1].split(":")[0]
|
|
42
|
+
return name.removeprefix("dayhoff-")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _tag_user() -> str:
|
|
46
|
+
"""Username for the User cost tag.
|
|
47
|
+
|
|
48
|
+
'root' means get_aws_username() hit its getpass fallback in a container —
|
|
49
|
+
tagging it would collapse many people into one bucket, invisibly. Tag
|
|
50
|
+
unknown-<hostname> instead and say how to fix it.
|
|
51
|
+
"""
|
|
52
|
+
username = get_aws_username()
|
|
53
|
+
if username == "root":
|
|
54
|
+
hostname = socket.gethostname()
|
|
55
|
+
logger.warning(
|
|
56
|
+
"Could not resolve your AWS username (got system user 'root'); "
|
|
57
|
+
f"tagging this job User=unknown-{hostname}. "
|
|
58
|
+
"Set AWS_SSO_USER or run 'dh aws login' to fix cost attribution."
|
|
59
|
+
)
|
|
60
|
+
return f"unknown-{hostname}"
|
|
61
|
+
return username
|
|
62
|
+
|
|
63
|
+
|
|
32
64
|
@dataclass
|
|
33
65
|
class ArrayJobStatus:
|
|
34
66
|
"""Aggregated status for an array job."""
|
|
@@ -90,6 +122,7 @@ class BatchClient:
|
|
|
90
122
|
gpus: int | None = None,
|
|
91
123
|
image_override: str | None = None,
|
|
92
124
|
command: list[str] | None = None,
|
|
125
|
+
project: str | None = None,
|
|
93
126
|
) -> str:
|
|
94
127
|
"""Submit a job to AWS Batch.
|
|
95
128
|
|
|
@@ -103,6 +136,7 @@ class BatchClient:
|
|
|
103
136
|
timeout_seconds: Job timeout in seconds
|
|
104
137
|
retry_attempts: Number of retry attempts
|
|
105
138
|
depends_on: Job dependencies
|
|
139
|
+
project: Value for the Project cost tag (defaults to "unspecified")
|
|
106
140
|
|
|
107
141
|
Returns:
|
|
108
142
|
AWS Batch job ID
|
|
@@ -119,6 +153,16 @@ class BatchClient:
|
|
|
119
153
|
"jobDefinition": job_definition,
|
|
120
154
|
"jobQueue": job_queue,
|
|
121
155
|
"retryStrategy": {"attempts": retry_attempts},
|
|
156
|
+
# Cost attribution: propagated to the ECS task, split per task
|
|
157
|
+
# by SCAD. JobId is the dh job ID (the job name), not the AWS
|
|
158
|
+
# UUID, so humans can correlate with `dh batch status`.
|
|
159
|
+
"tags": {
|
|
160
|
+
"User": _tag_user(),
|
|
161
|
+
"Pipeline": _pipeline_from_definition(job_definition),
|
|
162
|
+
"Project": project or "unspecified",
|
|
163
|
+
"JobId": job_name,
|
|
164
|
+
},
|
|
165
|
+
"propagateTags": True,
|
|
122
166
|
}
|
|
123
167
|
|
|
124
168
|
if array_size and array_size > 1:
|
|
@@ -33,6 +33,7 @@ DEFAULT_QUEUE = "t4-1x-spot"
|
|
|
33
33
|
@click.option("--retry", default=3, type=int, help="Retry attempts")
|
|
34
34
|
@click.option("--timeout", default="6h", help="Job timeout (e.g., 6h, 1d)")
|
|
35
35
|
@click.option("--image", help="Pre-built image URI")
|
|
36
|
+
@click.option("--project", help="Project cost tag (defaults to 'unspecified')")
|
|
36
37
|
@click.option("--env", multiple=True, help="Environment variables (KEY=VALUE)")
|
|
37
38
|
@click.option("--dry-run", is_flag=True, help="Show plan without submitting")
|
|
38
39
|
@click.option("--base-path", default=BATCH_JOBS_BASE, help="Base path for job data")
|
|
@@ -50,6 +51,7 @@ def submit(
|
|
|
50
51
|
retry,
|
|
51
52
|
timeout,
|
|
52
53
|
image,
|
|
54
|
+
project,
|
|
53
55
|
env,
|
|
54
56
|
dry_run,
|
|
55
57
|
base_path,
|
|
@@ -84,6 +86,7 @@ def submit(
|
|
|
84
86
|
retry: 3
|
|
85
87
|
timeout: 6h
|
|
86
88
|
image: custom-image:tag
|
|
89
|
+
project: my-campaign # Project cost tag (optional)
|
|
87
90
|
env:
|
|
88
91
|
MY_VAR: value
|
|
89
92
|
"""
|
|
@@ -116,6 +119,7 @@ def submit(
|
|
|
116
119
|
job_retry = _pick("retry", retry)
|
|
117
120
|
job_timeout = _pick("timeout", timeout)
|
|
118
121
|
job_image = image or config.get("image")
|
|
122
|
+
job_project = project or config.get("project")
|
|
119
123
|
if job_image and not _ECR_IMAGE_RE.match(job_image):
|
|
120
124
|
raise click.BadParameter(
|
|
121
125
|
f"--image must be a fully-qualified ECR URL "
|
|
@@ -214,6 +218,7 @@ def submit(
|
|
|
214
218
|
depends_on=depends_on,
|
|
215
219
|
share_identifier=get_aws_username(),
|
|
216
220
|
image_override=job_image,
|
|
221
|
+
project=job_project,
|
|
217
222
|
)
|
|
218
223
|
|
|
219
224
|
# Update manifest with Batch job ID
|
|
@@ -19,6 +19,7 @@ import re
|
|
|
19
19
|
import shutil
|
|
20
20
|
import subprocess
|
|
21
21
|
import sys
|
|
22
|
+
import time
|
|
22
23
|
from pathlib import Path
|
|
23
24
|
from typing import List, Optional, Tuple
|
|
24
25
|
|
|
@@ -29,6 +30,8 @@ import typer
|
|
|
29
30
|
GCP_PROJECT_ID = "enzyme-discovery"
|
|
30
31
|
AWS_DEFAULT_PROFILE = "dev-devaccess"
|
|
31
32
|
AWS_CONFIG_FILE = Path.home() / ".aws" / "config"
|
|
33
|
+
AWS_SSO_CACHE_DIR = Path.home() / ".aws" / "sso" / "cache"
|
|
34
|
+
NETRC_FILE = Path.home() / ".netrc"
|
|
32
35
|
SHELL_RC_FILES = [
|
|
33
36
|
Path.home() / ".bashrc",
|
|
34
37
|
Path.home() / ".bash_profile",
|
|
@@ -398,6 +401,55 @@ def _is_aws_profile_authenticated(profile: str) -> bool:
|
|
|
398
401
|
return returncode == 0
|
|
399
402
|
|
|
400
403
|
|
|
404
|
+
def _print_sso_session_status() -> None:
|
|
405
|
+
"""Print the cached SSO access token's expiry, and note what it isn't.
|
|
406
|
+
|
|
407
|
+
A cache dir holds both the client-registration file and the access-token
|
|
408
|
+
file, and both carry `expiresAt` - the registration one is weeks out, the
|
|
409
|
+
token's is hours out. They're told apart by content shape (presence of
|
|
410
|
+
`accessToken`), never by glob order, mtime, or filename, since either of
|
|
411
|
+
those picked the registration file on the box this was written for and
|
|
412
|
+
reported six weeks of headroom that didn't exist.
|
|
413
|
+
"""
|
|
414
|
+
access_token_expiry = None
|
|
415
|
+
if AWS_SSO_CACHE_DIR.is_dir():
|
|
416
|
+
for cache_file in AWS_SSO_CACHE_DIR.glob("*.json"):
|
|
417
|
+
try:
|
|
418
|
+
data = json.loads(cache_file.read_text())
|
|
419
|
+
except (OSError, ValueError):
|
|
420
|
+
continue
|
|
421
|
+
if isinstance(data, dict) and "accessToken" in data:
|
|
422
|
+
access_token_expiry = data.get("expiresAt")
|
|
423
|
+
break
|
|
424
|
+
|
|
425
|
+
# The token is refreshed silently on ~1h cycles; the 8h portal session it
|
|
426
|
+
# rides on is not visible from inside the container at all, so the note
|
|
427
|
+
# below must stand on its own when no expiry was printed above.
|
|
428
|
+
if access_token_expiry:
|
|
429
|
+
print(f"{BLUE}SSO access token expires:{NC} {access_token_expiry}")
|
|
430
|
+
print(
|
|
431
|
+
f"{YELLOW}Note: the AWS SSO portal session's lifetime is not locally "
|
|
432
|
+
f"observable from here; the access token expiry above is not that window.{NC}"
|
|
433
|
+
)
|
|
434
|
+
else:
|
|
435
|
+
print(f"{YELLOW}Note: the AWS SSO portal session's lifetime is not locally observable from here.{NC}")
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _print_netrc_age() -> None:
|
|
439
|
+
"""Print the age of ~/.netrc.
|
|
440
|
+
|
|
441
|
+
The CodeArtifact token it holds carries no expiry, so age is the only
|
|
442
|
+
signal available, and it gates `pixi install` (and therefore `dh` itself).
|
|
443
|
+
"""
|
|
444
|
+
if not NETRC_FILE.exists():
|
|
445
|
+
return
|
|
446
|
+
try:
|
|
447
|
+
age_hours = (time.time() - NETRC_FILE.stat().st_mtime) / 3600
|
|
448
|
+
except OSError:
|
|
449
|
+
return
|
|
450
|
+
print(f"{BLUE}~/.netrc age:{NC} {age_hours:.1f} hours")
|
|
451
|
+
|
|
452
|
+
|
|
401
453
|
def _run_aws_sso_login(profile: str) -> None:
|
|
402
454
|
"""Run the AWS SSO login command for a specific profile."""
|
|
403
455
|
aws_path = _find_executable("aws")
|
|
@@ -590,13 +642,19 @@ def aws_status(
|
|
|
590
642
|
profile: Optional[str] = typer.Option(None, "--profile", "-p", help="Check specific profile instead of current."),
|
|
591
643
|
):
|
|
592
644
|
"""Show current AWS authentication status."""
|
|
593
|
-
|
|
645
|
+
try:
|
|
646
|
+
target_profile = profile or _get_current_aws_profile()
|
|
647
|
+
authenticated = _is_aws_profile_authenticated(target_profile)
|
|
648
|
+
aws_path = _find_executable("aws")
|
|
649
|
+
except FileNotFoundError as e:
|
|
650
|
+
print(f"{RED}Could not determine AWS status: {e}{NC}", file=sys.stderr)
|
|
651
|
+
raise typer.Exit(6)
|
|
652
|
+
|
|
594
653
|
print(f"{BLUE}AWS profile:{NC} {GREEN}{target_profile}{NC}")
|
|
595
654
|
|
|
596
|
-
if
|
|
655
|
+
if authenticated:
|
|
597
656
|
print(f"Credential status: {GREEN}valid{NC}")
|
|
598
657
|
# Get detailed identity information
|
|
599
|
-
aws_path = _find_executable("aws")
|
|
600
658
|
_run_command(
|
|
601
659
|
[
|
|
602
660
|
aws_path,
|
|
@@ -612,6 +670,12 @@ def aws_status(
|
|
|
612
670
|
print("\nTo authenticate, run:")
|
|
613
671
|
print(f" {YELLOW}dh aws login{NC}")
|
|
614
672
|
|
|
673
|
+
_print_sso_session_status()
|
|
674
|
+
_print_netrc_age()
|
|
675
|
+
|
|
676
|
+
if not authenticated:
|
|
677
|
+
raise typer.Exit(5)
|
|
678
|
+
|
|
615
679
|
|
|
616
680
|
@aws_app.command("login")
|
|
617
681
|
def aws_login(
|
|
@@ -147,9 +147,7 @@ def launch_engine(
|
|
|
147
147
|
# Launch the engine, retrying transient AWS capacity errors by default.
|
|
148
148
|
retry = _make_capacity_retry(engine_type, no_retry, retry_timeout, retry_interval)
|
|
149
149
|
engine = retry(
|
|
150
|
-
lambda: client.launch_engine(
|
|
151
|
-
name=name, user=user, engine_type=engine_type, boot_disk_size=boot_disk_size
|
|
152
|
-
)
|
|
150
|
+
lambda: client.launch_engine(name=name, user=user, engine_type=engine_type, boot_disk_size=boot_disk_size)
|
|
153
151
|
)
|
|
154
152
|
|
|
155
153
|
engine_id = engine["instance_id"]
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Tests for cost-attribution tags on BatchClient.submit_job.
|
|
2
|
+
|
|
3
|
+
Tags are defaulted inside submit_job — no call site passes them. They pin:
|
|
4
|
+
- the four keys User/Pipeline/Project/JobId and propagateTags=True (AC-1)
|
|
5
|
+
- User from get_aws_username(), JobId = the dh job name (AC-2)
|
|
6
|
+
- Pipeline derived from the job definition name (AC-5)
|
|
7
|
+
- Project defaults to "unspecified", overridable per submission (AC-5)
|
|
8
|
+
- the root-fallback guard tagging User=unknown-<hostname> (AC-6)
|
|
9
|
+
- both submit_array_retry re-entry paths produce tagged jobs (AC-4)
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from unittest.mock import MagicMock, patch
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _make_client():
|
|
16
|
+
"""Construct a BatchClient with mocked boto3 batch/logs clients."""
|
|
17
|
+
with patch("dh_cli.batch.aws_batch.boto3") as mock_boto3:
|
|
18
|
+
mock_batch = MagicMock()
|
|
19
|
+
mock_logs = MagicMock()
|
|
20
|
+
mock_boto3.client.side_effect = [mock_batch, mock_logs]
|
|
21
|
+
mock_batch.submit_job.return_value = {"jobId": "aws-uuid"}
|
|
22
|
+
|
|
23
|
+
from dh_cli.batch.aws_batch import BatchClient
|
|
24
|
+
|
|
25
|
+
client = BatchClient()
|
|
26
|
+
return client, mock_batch
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _submit(client, **overrides):
|
|
30
|
+
kwargs = dict(
|
|
31
|
+
job_name="dma-boltz-20260810-a3f2c9e1",
|
|
32
|
+
job_definition="dayhoff-boltz",
|
|
33
|
+
job_queue="a10g-1x-spot",
|
|
34
|
+
)
|
|
35
|
+
kwargs.update(overrides)
|
|
36
|
+
return client.submit_job(**kwargs)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@patch("dh_cli.batch.aws_batch.get_aws_username", return_value="dma")
|
|
40
|
+
class TestSubmitJobTags:
|
|
41
|
+
def test_no_tag_parameters_in_signature(self, _mock_user):
|
|
42
|
+
"""AC-3: tags are defaulted inside submit_job — call sites have no
|
|
43
|
+
tag argument to pass (or forget)."""
|
|
44
|
+
import inspect
|
|
45
|
+
|
|
46
|
+
from dh_cli.batch.aws_batch import BatchClient
|
|
47
|
+
|
|
48
|
+
params = inspect.signature(BatchClient.submit_job).parameters
|
|
49
|
+
assert "tags" not in params
|
|
50
|
+
assert "propagate_tags" not in params
|
|
51
|
+
|
|
52
|
+
def test_sends_exactly_four_tag_keys_and_propagate(self, _mock_user):
|
|
53
|
+
client, mock_batch = _make_client()
|
|
54
|
+
_submit(client)
|
|
55
|
+
submit_args = mock_batch.submit_job.call_args[1]
|
|
56
|
+
assert submit_args["propagateTags"] is True
|
|
57
|
+
assert set(submit_args["tags"]) == {"User", "Pipeline", "Project", "JobId"}
|
|
58
|
+
|
|
59
|
+
def test_user_is_aws_username_and_jobid_is_dh_job_name(self, _mock_user):
|
|
60
|
+
client, mock_batch = _make_client()
|
|
61
|
+
_submit(client)
|
|
62
|
+
tags = mock_batch.submit_job.call_args[1]["tags"]
|
|
63
|
+
assert tags["User"] == "dma"
|
|
64
|
+
assert tags["JobId"] == "dma-boltz-20260810-a3f2c9e1"
|
|
65
|
+
|
|
66
|
+
def test_pipeline_derived_from_job_definition(self, _mock_user):
|
|
67
|
+
client, mock_batch = _make_client()
|
|
68
|
+
for definition, expected in [
|
|
69
|
+
("dayhoff-embed-t5", "embed-t5"),
|
|
70
|
+
("dayhoff-boltz", "boltz"),
|
|
71
|
+
("dayhoff-generic", "generic"),
|
|
72
|
+
# ARN form, as produced by _register_image_override
|
|
73
|
+
(
|
|
74
|
+
"arn:aws:batch:us-east-1:074735440724:job-definition/dayhoff-rfd3:7",
|
|
75
|
+
"rfd3",
|
|
76
|
+
),
|
|
77
|
+
# Name:revision form
|
|
78
|
+
("dayhoff-esmfold2:3", "esmfold2"),
|
|
79
|
+
]:
|
|
80
|
+
_submit(client, job_definition=definition)
|
|
81
|
+
tags = mock_batch.submit_job.call_args[1]["tags"]
|
|
82
|
+
assert tags["Pipeline"] == expected, definition
|
|
83
|
+
|
|
84
|
+
def test_project_defaults_to_unspecified(self, _mock_user):
|
|
85
|
+
client, mock_batch = _make_client()
|
|
86
|
+
_submit(client)
|
|
87
|
+
tags = mock_batch.submit_job.call_args[1]["tags"]
|
|
88
|
+
assert tags["Project"] == "unspecified"
|
|
89
|
+
|
|
90
|
+
def test_project_overridable_per_submission(self, _mock_user):
|
|
91
|
+
client, mock_batch = _make_client()
|
|
92
|
+
_submit(client, project="cost-attribution")
|
|
93
|
+
tags = mock_batch.submit_job.call_args[1]["tags"]
|
|
94
|
+
assert tags["Project"] == "cost-attribution"
|
|
95
|
+
|
|
96
|
+
def test_share_identifier_behaviour_unchanged(self, _mock_user):
|
|
97
|
+
client, mock_batch = _make_client()
|
|
98
|
+
_submit(client, share_identifier="dma")
|
|
99
|
+
submit_args = mock_batch.submit_job.call_args[1]
|
|
100
|
+
assert submit_args["shareIdentifier"] == "dma"
|
|
101
|
+
assert submit_args["schedulingPriorityOverride"] == 0
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class TestRootFallbackGuard:
|
|
105
|
+
"""AC-6: getpass-fallback 'root' must not become the User tag."""
|
|
106
|
+
|
|
107
|
+
@patch("dh_cli.batch.aws_batch.socket.gethostname", return_value="devbox9")
|
|
108
|
+
@patch("dh_cli.batch.aws_batch.get_aws_username", return_value="root")
|
|
109
|
+
def test_root_user_tagged_unknown_hostname_with_warning(self, _mock_user, _mock_host, caplog):
|
|
110
|
+
client, mock_batch = _make_client()
|
|
111
|
+
import logging
|
|
112
|
+
|
|
113
|
+
with caplog.at_level(logging.WARNING):
|
|
114
|
+
_submit(client)
|
|
115
|
+
tags = mock_batch.submit_job.call_args[1]["tags"]
|
|
116
|
+
assert tags["User"] == "unknown-devbox9"
|
|
117
|
+
assert any("AWS_SSO_USER" in r.message for r in caplog.records)
|
|
118
|
+
|
|
119
|
+
@patch("dh_cli.batch.aws_batch.get_aws_username", return_value="dma")
|
|
120
|
+
def test_non_root_user_emits_no_warning(self, _mock_user, caplog):
|
|
121
|
+
client, mock_batch = _make_client()
|
|
122
|
+
import logging
|
|
123
|
+
|
|
124
|
+
with caplog.at_level(logging.WARNING):
|
|
125
|
+
_submit(client)
|
|
126
|
+
assert not any("AWS_SSO_USER" in r.message for r in caplog.records)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@patch("dh_cli.batch.aws_batch.get_aws_username", return_value="dma")
|
|
130
|
+
class TestArrayRetryReentriesTagged:
|
|
131
|
+
"""AC-4: both submit_array_retry re-entry paths produce tagged jobs."""
|
|
132
|
+
|
|
133
|
+
def test_single_index_reentry_tagged(self, _mock_user):
|
|
134
|
+
client, mock_batch = _make_client()
|
|
135
|
+
client.submit_array_job_with_indices(
|
|
136
|
+
job_name="dma-embed-20260810-a3f2c9e1-r1",
|
|
137
|
+
job_definition="dayhoff-embed-t5",
|
|
138
|
+
job_queue="t4-1x-spot",
|
|
139
|
+
indices=[7],
|
|
140
|
+
)
|
|
141
|
+
submit_args = mock_batch.submit_job.call_args[1]
|
|
142
|
+
assert submit_args["propagateTags"] is True
|
|
143
|
+
assert submit_args["tags"]["Pipeline"] == "embed-t5"
|
|
144
|
+
assert submit_args["tags"]["JobId"] == "dma-embed-20260810-a3f2c9e1-r1"
|
|
145
|
+
|
|
146
|
+
def test_multi_index_reentry_tagged(self, _mock_user):
|
|
147
|
+
client, mock_batch = _make_client()
|
|
148
|
+
client.submit_array_job_with_indices(
|
|
149
|
+
job_name="dma-embed-20260810-a3f2c9e1-r1",
|
|
150
|
+
job_definition="dayhoff-embed-t5",
|
|
151
|
+
job_queue="t4-1x-spot",
|
|
152
|
+
indices=[3, 5, 9],
|
|
153
|
+
)
|
|
154
|
+
submit_args = mock_batch.submit_job.call_args[1]
|
|
155
|
+
assert submit_args["propagateTags"] is True
|
|
156
|
+
assert submit_args["tags"]["Pipeline"] == "embed-t5"
|
|
157
|
+
assert submit_args["tags"]["User"] == "dma"
|
|
@@ -203,6 +203,44 @@ class TestArrayAndRetryMerge:
|
|
|
203
203
|
assert mock_client.submit_job.call_args[1]["retry_attempts"] == 5
|
|
204
204
|
|
|
205
205
|
|
|
206
|
+
class TestProjectOption:
|
|
207
|
+
"""--project / YAML `project` forwards to submit_job's project kwarg
|
|
208
|
+
(the Project cost tag); absent everywhere, None is forwarded and
|
|
209
|
+
submit_job defaults the tag to "unspecified"."""
|
|
210
|
+
|
|
211
|
+
def test_cli_project_forwarded(self, cli_runner, tmp_path):
|
|
212
|
+
result, mock_client = _invoke(
|
|
213
|
+
cli_runner,
|
|
214
|
+
["--command", "echo hi", "--project", "cost-attribution"],
|
|
215
|
+
tmp_path,
|
|
216
|
+
)
|
|
217
|
+
assert result.exit_code == 0, result.output
|
|
218
|
+
assert mock_client.submit_job.call_args[1]["project"] == "cost-attribution"
|
|
219
|
+
|
|
220
|
+
def test_yaml_project_wins_when_cli_omitted(self, cli_runner, tmp_path):
|
|
221
|
+
config_path = tmp_path / "job.yaml"
|
|
222
|
+
config_path.write_text(yaml.dump({"command": "echo hi", "project": "gauntlet"}))
|
|
223
|
+
result, mock_client = _invoke(cli_runner, ["-f", str(config_path)], tmp_path)
|
|
224
|
+
assert result.exit_code == 0, result.output
|
|
225
|
+
assert mock_client.submit_job.call_args[1]["project"] == "gauntlet"
|
|
226
|
+
|
|
227
|
+
def test_cli_project_beats_yaml(self, cli_runner, tmp_path):
|
|
228
|
+
config_path = tmp_path / "job.yaml"
|
|
229
|
+
config_path.write_text(yaml.dump({"command": "echo hi", "project": "gauntlet"}))
|
|
230
|
+
result, mock_client = _invoke(
|
|
231
|
+
cli_runner,
|
|
232
|
+
["-f", str(config_path), "--project", "poise"],
|
|
233
|
+
tmp_path,
|
|
234
|
+
)
|
|
235
|
+
assert result.exit_code == 0, result.output
|
|
236
|
+
assert mock_client.submit_job.call_args[1]["project"] == "poise"
|
|
237
|
+
|
|
238
|
+
def test_absent_everywhere_forwards_none(self, cli_runner, tmp_path):
|
|
239
|
+
result, mock_client = _invoke(cli_runner, ["--command", "echo hi"], tmp_path)
|
|
240
|
+
assert result.exit_code == 0, result.output
|
|
241
|
+
assert mock_client.submit_job.call_args[1]["project"] is None
|
|
242
|
+
|
|
243
|
+
|
|
206
244
|
class TestGpuRegressionFromReport:
|
|
207
245
|
"""The specific surprising case called out in the plan: a caller
|
|
208
246
|
who does `--gpus 1 -f yaml-with-gpus-zero.yaml` expects a GPU job,
|
|
@@ -36,9 +36,7 @@ def test_returns_immediately_on_success():
|
|
|
36
36
|
calls.append(1)
|
|
37
37
|
return "ok"
|
|
38
38
|
|
|
39
|
-
result = retry_on_capacity(
|
|
40
|
-
action, timeout=600, interval=30, monotonic=clock.monotonic, sleep=clock.sleep
|
|
41
|
-
)
|
|
39
|
+
result = retry_on_capacity(action, timeout=600, interval=30, monotonic=clock.monotonic, sleep=clock.sleep)
|
|
42
40
|
|
|
43
41
|
assert result == "ok"
|
|
44
42
|
assert len(calls) == 1
|
|
@@ -53,14 +51,11 @@ def test_retries_capacity_error_then_succeeds():
|
|
|
53
51
|
attempts["n"] += 1
|
|
54
52
|
if attempts["n"] < 3:
|
|
55
53
|
raise RuntimeError(
|
|
56
|
-
"Failed to launch instance: An error occurred "
|
|
57
|
-
"(InsufficientInstanceCapacity) when calling RunInstances"
|
|
54
|
+
"Failed to launch instance: An error occurred (InsufficientInstanceCapacity) when calling RunInstances"
|
|
58
55
|
)
|
|
59
56
|
return "launched"
|
|
60
57
|
|
|
61
|
-
result = retry_on_capacity(
|
|
62
|
-
action, timeout=600, interval=30, monotonic=clock.monotonic, sleep=clock.sleep
|
|
63
|
-
)
|
|
58
|
+
result = retry_on_capacity(action, timeout=600, interval=30, monotonic=clock.monotonic, sleep=clock.sleep)
|
|
64
59
|
|
|
65
60
|
assert result == "launched"
|
|
66
61
|
assert attempts["n"] == 3
|
|
@@ -77,9 +72,7 @@ def test_non_capacity_error_propagates_without_retry():
|
|
|
77
72
|
raise RuntimeError("VcpuLimitExceeded: vCPU quota exceeded")
|
|
78
73
|
|
|
79
74
|
with pytest.raises(RuntimeError, match="VcpuLimitExceeded"):
|
|
80
|
-
retry_on_capacity(
|
|
81
|
-
action, timeout=600, interval=30, monotonic=clock.monotonic, sleep=clock.sleep
|
|
82
|
-
)
|
|
75
|
+
retry_on_capacity(action, timeout=600, interval=30, monotonic=clock.monotonic, sleep=clock.sleep)
|
|
83
76
|
|
|
84
77
|
assert attempts["n"] == 1
|
|
85
78
|
assert clock.slept == []
|
|
@@ -94,9 +87,7 @@ def test_reraises_last_capacity_error_after_budget_exhausted():
|
|
|
94
87
|
raise RuntimeError("InsufficientInstanceCapacity: no capacity")
|
|
95
88
|
|
|
96
89
|
with pytest.raises(RuntimeError, match="InsufficientInstanceCapacity"):
|
|
97
|
-
retry_on_capacity(
|
|
98
|
-
action, timeout=60, interval=30, monotonic=clock.monotonic, sleep=clock.sleep
|
|
99
|
-
)
|
|
90
|
+
retry_on_capacity(action, timeout=60, interval=30, monotonic=clock.monotonic, sleep=clock.sleep)
|
|
100
91
|
|
|
101
92
|
# attempt@t=0 fails; next attempt (t=30) fits in the 60s budget -> sleep 30.
|
|
102
93
|
# attempt@t=30 fails; next attempt (t=60) would NOT be < 60 -> stop, no sleep.
|
|
@@ -113,9 +104,7 @@ def test_timeout_zero_means_single_attempt():
|
|
|
113
104
|
raise RuntimeError("InsufficientInstanceCapacity")
|
|
114
105
|
|
|
115
106
|
with pytest.raises(RuntimeError, match="InsufficientInstanceCapacity"):
|
|
116
|
-
retry_on_capacity(
|
|
117
|
-
action, timeout=0, interval=30, monotonic=clock.monotonic, sleep=clock.sleep
|
|
118
|
-
)
|
|
107
|
+
retry_on_capacity(action, timeout=0, interval=30, monotonic=clock.monotonic, sleep=clock.sleep)
|
|
119
108
|
|
|
120
109
|
assert attempts["n"] == 1
|
|
121
110
|
assert clock.slept == []
|
|
@@ -162,9 +151,7 @@ def test_keyboard_interrupt_during_sleep_propagates():
|
|
|
162
151
|
raise RuntimeError("InsufficientInstanceCapacity")
|
|
163
152
|
|
|
164
153
|
with pytest.raises(KeyboardInterrupt):
|
|
165
|
-
retry_on_capacity(
|
|
166
|
-
action, timeout=600, interval=30, monotonic=clock.monotonic, sleep=clock.sleep
|
|
167
|
-
)
|
|
154
|
+
retry_on_capacity(action, timeout=600, interval=30, monotonic=clock.monotonic, sleep=clock.sleep)
|
|
168
155
|
|
|
169
156
|
|
|
170
157
|
@pytest.mark.parametrize(
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Exit-code and reporting contract for `dh aws status`.
|
|
2
|
+
|
|
3
|
+
`dh aws status` is the one honest answer an unattended agent has about its own
|
|
4
|
+
AWS credentials, so it must fail closed and must never overstate the window it
|
|
5
|
+
can see:
|
|
6
|
+
|
|
7
|
+
0 — authenticated
|
|
8
|
+
5 — unauthenticated
|
|
9
|
+
6 — could not tell (e.g. no `aws` binary)
|
|
10
|
+
|
|
11
|
+
`2` stays reserved for typer's UsageError. 5 and 6 are distinct on purpose: a
|
|
12
|
+
caller must never confuse "credentials are gone" with "I could not determine
|
|
13
|
+
it", because the safe response differs.
|
|
14
|
+
|
|
15
|
+
All AWS interaction is mocked at `_run_command`; no credentials required. No
|
|
16
|
+
test asserts on a token value, and every fixture token is a placeholder.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import re
|
|
24
|
+
import time
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from unittest.mock import patch
|
|
27
|
+
|
|
28
|
+
from click.testing import CliRunner
|
|
29
|
+
from typer.main import get_command
|
|
30
|
+
|
|
31
|
+
PLACEHOLDER_TOKEN = "PLACEHOLDER-NOT-A-REAL-TOKEN"
|
|
32
|
+
|
|
33
|
+
REGISTRATION_FILE = {
|
|
34
|
+
"clientId": "placeholder-client-id",
|
|
35
|
+
"clientSecret": "PLACEHOLDER-CLIENT-SECRET",
|
|
36
|
+
"expiresAt": "2026-09-14T13:59:33Z",
|
|
37
|
+
"grantTypes": ["refresh_token", "urn:ietf:params:oauth:grant-type:device_code"],
|
|
38
|
+
"scopes": ["sso:account:access"],
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
ACCESS_TOKEN_FILE = {
|
|
42
|
+
"accessToken": PLACEHOLDER_TOKEN,
|
|
43
|
+
"clientId": "placeholder-client-id",
|
|
44
|
+
"clientSecret": "PLACEHOLDER-CLIENT-SECRET",
|
|
45
|
+
"expiresAt": "2026-08-13T04:00:00Z",
|
|
46
|
+
"refreshToken": PLACEHOLDER_TOKEN,
|
|
47
|
+
"region": "us-east-1",
|
|
48
|
+
"registrationExpiresAt": "2026-09-14T13:59:33Z",
|
|
49
|
+
"startUrl": "https://placeholder.awsapps.com/start",
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _invoke(
|
|
54
|
+
cache_dir: Path,
|
|
55
|
+
netrc_file: Path,
|
|
56
|
+
*,
|
|
57
|
+
authenticated: bool = True,
|
|
58
|
+
which_returns_path: bool = True,
|
|
59
|
+
):
|
|
60
|
+
"""Run `aws status` with the AWS CLI boundary and both files stubbed."""
|
|
61
|
+
from dh_cli import cloud_commands
|
|
62
|
+
from dh_cli.cloud_commands import aws_app
|
|
63
|
+
|
|
64
|
+
result_tuple = (0, '{"Arn": "arn:aws:sts::000000000000:assumed-role/placeholder/x"}', "")
|
|
65
|
+
if not authenticated:
|
|
66
|
+
result_tuple = (255, "", "Error loading SSO Token: Token has expired and refresh failed")
|
|
67
|
+
|
|
68
|
+
stack = [
|
|
69
|
+
patch.object(cloud_commands, "AWS_SSO_CACHE_DIR", cache_dir),
|
|
70
|
+
patch.object(cloud_commands, "NETRC_FILE", netrc_file),
|
|
71
|
+
patch.object(cloud_commands, "_run_command", return_value=result_tuple),
|
|
72
|
+
patch.dict(os.environ, {"AWS_PROFILE": "dev-devaccess"}, clear=False),
|
|
73
|
+
]
|
|
74
|
+
if which_returns_path:
|
|
75
|
+
stack.append(patch.object(cloud_commands.shutil, "which", return_value="/usr/bin/aws"))
|
|
76
|
+
else:
|
|
77
|
+
stack.append(patch.object(cloud_commands.shutil, "which", return_value=None))
|
|
78
|
+
|
|
79
|
+
runner = CliRunner()
|
|
80
|
+
for ctx in stack:
|
|
81
|
+
ctx.start()
|
|
82
|
+
try:
|
|
83
|
+
return runner.invoke(get_command(aws_app), ["status"])
|
|
84
|
+
finally:
|
|
85
|
+
for ctx in reversed(stack):
|
|
86
|
+
ctx.stop()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _write_cache(tmp_path: Path) -> Path:
|
|
90
|
+
"""Write both SSO cache shapes, with the registration file NEWER on disk."""
|
|
91
|
+
cache_dir = tmp_path / "sso" / "cache"
|
|
92
|
+
cache_dir.mkdir(parents=True)
|
|
93
|
+
token_path = cache_dir / "aaa11111.json"
|
|
94
|
+
token_path.write_text(json.dumps(ACCESS_TOKEN_FILE))
|
|
95
|
+
reg_path = cache_dir / "bbb22222.json"
|
|
96
|
+
reg_path.write_text(json.dumps(REGISTRATION_FILE))
|
|
97
|
+
# Registration file is both newer and alphabetically later: a glob taking
|
|
98
|
+
# the newest or the first hit must not be able to pass by accident.
|
|
99
|
+
old = time.time() - 86400
|
|
100
|
+
os.utime(token_path, (old, old))
|
|
101
|
+
return cache_dir
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _netrc(tmp_path: Path, age_hours: float = 5.0) -> Path:
|
|
105
|
+
netrc_file = tmp_path / ".netrc"
|
|
106
|
+
netrc_file.write_text("machine placeholder.d.codeartifact.us-east-1.amazonaws.com\n")
|
|
107
|
+
stamp = time.time() - age_hours * 3600
|
|
108
|
+
os.utime(netrc_file, (stamp, stamp))
|
|
109
|
+
return netrc_file
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_authenticated_exits_0(tmp_path):
|
|
113
|
+
result = _invoke(_write_cache(tmp_path), _netrc(tmp_path), authenticated=True)
|
|
114
|
+
assert result.exit_code == 0, result.output
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_unauthenticated_exits_5(tmp_path):
|
|
118
|
+
"""The whole point: `dh aws status && long_job` must not run the job."""
|
|
119
|
+
result = _invoke(_write_cache(tmp_path), _netrc(tmp_path), authenticated=False)
|
|
120
|
+
assert result.exit_code == 5, result.output
|
|
121
|
+
assert "not authenticated" in result.output.lower(), result.output
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_missing_aws_binary_exits_6_without_traceback(tmp_path):
|
|
125
|
+
"""`_find_executable` raises FileNotFoundError today; a diagnostic must not
|
|
126
|
+
traceback when the thing it diagnoses is missing."""
|
|
127
|
+
result = _invoke(
|
|
128
|
+
_write_cache(tmp_path),
|
|
129
|
+
_netrc(tmp_path),
|
|
130
|
+
which_returns_path=False,
|
|
131
|
+
)
|
|
132
|
+
assert result.exit_code == 6, result.output
|
|
133
|
+
assert not isinstance(result.exception, FileNotFoundError), result.exception
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_reports_access_token_expiry_not_registration_expiry(tmp_path):
|
|
137
|
+
"""The regression test for the trap.
|
|
138
|
+
|
|
139
|
+
Both cache files carry `expiresAt`. On a real box the registration file
|
|
140
|
+
read 2026-09-14 beside an access token expiring the same afternoon, so a
|
|
141
|
+
glob taking the newest or first hit reports six weeks of headroom that does
|
|
142
|
+
not exist. Selection must be by content shape: the file with `accessToken`.
|
|
143
|
+
"""
|
|
144
|
+
result = _invoke(_write_cache(tmp_path), _netrc(tmp_path), authenticated=True)
|
|
145
|
+
assert result.exit_code == 0, result.output
|
|
146
|
+
assert "2026-08-13" in result.output, result.output
|
|
147
|
+
assert "2026-09-14" not in result.output, result.output
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def test_missing_cache_directory_claims_no_window(tmp_path):
|
|
151
|
+
"""No cache is a reason to say nothing, not to guess."""
|
|
152
|
+
result = _invoke(tmp_path / "absent", tmp_path / "no-netrc", authenticated=True)
|
|
153
|
+
assert result.exit_code == 0, result.output
|
|
154
|
+
assert result.exception is None, result.exception
|
|
155
|
+
assert not re.search(r"\d{4}-\d{2}-\d{2}", result.output), result.output
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def test_reports_netrc_age(tmp_path):
|
|
159
|
+
"""The CodeArtifact token records no expiry, so age is the only signal —
|
|
160
|
+
and it gates `pixi install`, and therefore `dh` itself."""
|
|
161
|
+
result = _invoke(_write_cache(tmp_path), _netrc(tmp_path, age_hours=5.0))
|
|
162
|
+
netrc_lines = [ln for ln in result.output.splitlines() if "netrc" in ln.lower()]
|
|
163
|
+
assert netrc_lines, result.output
|
|
164
|
+
assert "5" in netrc_lines[0], netrc_lines[0]
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def test_states_portal_session_is_not_locally_observable(tmp_path):
|
|
168
|
+
"""The access-token expiry is not the window. Nothing inside a devcon can
|
|
169
|
+
see the portal session's lifetime, and the output must say so rather than
|
|
170
|
+
let the token clock be read as the deadline."""
|
|
171
|
+
result = _invoke(_write_cache(tmp_path), _netrc(tmp_path))
|
|
172
|
+
lowered = result.output.lower()
|
|
173
|
+
assert "portal" in lowered, result.output
|
|
174
|
+
assert "not locally observable" in lowered, result.output
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def test_no_boto3_import():
|
|
178
|
+
"""`dh` installs through CodeArtifact, which needs working AWS auth, so a
|
|
179
|
+
diagnostic that cannot run when auth is broken is useless exactly when it
|
|
180
|
+
is needed."""
|
|
181
|
+
from dh_cli import cloud_commands
|
|
182
|
+
|
|
183
|
+
source = Path(cloud_commands.__file__).read_text()
|
|
184
|
+
assert "boto3" not in source, "cloud_commands.py must stay standard-library-only"
|
|
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
|
|
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
|
|
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
|