gen3-dataops-toolkit 2.0.0__py3-none-any.whl
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.
- g3dt/__init__.py +0 -0
- g3dt/cli/__init__.py +5 -0
- g3dt/cli/_internal/__init__.py +1 -0
- g3dt/cli/_internal/dispatch.py +428 -0
- g3dt/cli/_internal/registry.py +65 -0
- g3dt/cli/_internal/resolve.py +22 -0
- g3dt/cli/_internal/runner.py +76 -0
- g3dt/cli/_internal/safety.py +110 -0
- g3dt/cli/config_cmds.py +202 -0
- g3dt/cli/delete_cmds.py +101 -0
- g3dt/cli/dict_cmds.py +102 -0
- g3dt/cli/ec2_cmds.py +114 -0
- g3dt/cli/indexd_cmds.py +57 -0
- g3dt/cli/jobs.py +83 -0
- g3dt/cli/k8s.py +54 -0
- g3dt/cli/main.py +110 -0
- g3dt/cli/metadata.py +76 -0
- g3dt/cli/synth.py +206 -0
- g3dt/config.py +393 -0
- g3dt/indexd/__init__.py +0 -0
- g3dt/indexd/indexd_registrar.py +244 -0
- g3dt/ingest/ingest.py +629 -0
- g3dt/resolver.py +163 -0
- g3dt/services/delete/delete_all_metadata_for_project.py +170 -0
- g3dt/services/delete/delete_metadata.sh +153 -0
- g3dt/services/delete/delete_metadata_by_guid.py +338 -0
- g3dt/services/dictionary/deploy_dd.sh +65 -0
- g3dt/services/dictionary/pull_dict.sh +59 -0
- g3dt/services/dictionary/upload_dictionary.py +109 -0
- g3dt/services/indexd/register_indexd.py +240 -0
- g3dt/services/k8s_ops/argocd_restart_etl.sh +140 -0
- g3dt/services/k8s_ops/argocd_restart_ms.sh +102 -0
- g3dt/services/k8s_ops/argocd_restart_schema.sh +106 -0
- g3dt/services/k8s_ops/login_to_pod.sh +110 -0
- g3dt/services/k8s_ops/restart_etl_and_ms.sh +56 -0
- g3dt/services/synthetic_data/delete_synth_metadata_sheepdog.py +183 -0
- g3dt/services/synthetic_data/full_deploy_dd_and_synth.sh +124 -0
- g3dt/services/synthetic_data/generate_synth_metadata.sh +133 -0
- g3dt/services/synthetic_data/upload_synth_metadata_sheepdog.py +165 -0
- g3dt/services/upload/metadata/upload_all_studies.sh +108 -0
- g3dt/services/upload/metadata/upload_metadata.py +152 -0
- g3dt/upload/__init__.py +1 -0
- g3dt/upload/metadata_deleter.py +265 -0
- g3dt/upload/metadata_submitter.py +1093 -0
- g3dt/upload/upload_synthdata_s3.py +164 -0
- g3dt/utils/athena_utils.py +834 -0
- g3dt/utils/dbt_utils.py +66 -0
- g3dt/utils/release_writer.py +188 -0
- g3dt/validate/validate.py +609 -0
- gen3_dataops_toolkit-2.0.0.dist-info/METADATA +125 -0
- gen3_dataops_toolkit-2.0.0.dist-info/RECORD +53 -0
- gen3_dataops_toolkit-2.0.0.dist-info/WHEEL +4 -0
- gen3_dataops_toolkit-2.0.0.dist-info/entry_points.txt +3 -0
g3dt/__init__.py
ADDED
|
File without changes
|
g3dt/cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Internal helpers for the acdc CLI (subprocess runner, safety guards, EC2 dispatch)."""
|
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
"""Hybrid local/EC2 execution for long-running data-plane commands.
|
|
2
|
+
|
|
3
|
+
Short, interactive steps run locally (see :mod:`runner`). Long jobs (metadata
|
|
4
|
+
upload/delete, synth upload/delete, indexd register) can instead run on the
|
|
5
|
+
project's per-env EC2 job box via ``--on ec2``. The mechanism is **AWS SSM Run
|
|
6
|
+
Command**, which is disconnect-safe by construction: the SSM agent executes the
|
|
7
|
+
job server-side, so the laptop can sleep and you re-attach by ``run_id``.
|
|
8
|
+
|
|
9
|
+
Everything is resolved from the env's own SSM tree (``/{project}/{env}/...``):
|
|
10
|
+
the target instance (``ec2/instanceId``), the S3 log destination
|
|
11
|
+
(``ec2/logBucket`` + ``ec2/logPrefix``), and the CloudWatch log group
|
|
12
|
+
(``ec2/logGroup``). Dispatching to another environment's box is structurally
|
|
13
|
+
impossible — there is nothing local to misconfigure.
|
|
14
|
+
|
|
15
|
+
Authentication split (the crux):
|
|
16
|
+
* the laptop authenticates the SSM call with the *local* env's named
|
|
17
|
+
profile (from the marker's ``profiles:`` map);
|
|
18
|
+
* the job on the box runs under the ``*_ec2`` pseudo-env, which uses the
|
|
19
|
+
ambient instance profile.
|
|
20
|
+
|
|
21
|
+
The box needs no repository, git credentials, or poetry: CDK user-data
|
|
22
|
+
pip-installs the pinned toolkit, so the remote command is a bare ``g3dt ...``
|
|
23
|
+
console-script invocation.
|
|
24
|
+
"""
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import datetime
|
|
28
|
+
import shlex
|
|
29
|
+
import time
|
|
30
|
+
from enum import Enum
|
|
31
|
+
from typing import Callable, List, Optional, Sequence, Tuple
|
|
32
|
+
|
|
33
|
+
import typer
|
|
34
|
+
from botocore.exceptions import ClientError
|
|
35
|
+
|
|
36
|
+
from g3dt.config import (
|
|
37
|
+
ConfigError,
|
|
38
|
+
EnvConfig,
|
|
39
|
+
aws_profile_for,
|
|
40
|
+
env_base,
|
|
41
|
+
load_marker,
|
|
42
|
+
require_project,
|
|
43
|
+
resolve_env,
|
|
44
|
+
script_env,
|
|
45
|
+
)
|
|
46
|
+
from g3dt.upload.metadata_submitter import create_boto3_session
|
|
47
|
+
from g3dt.cli._internal import registry, runner
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Target(str, Enum):
|
|
51
|
+
"""Where a command should execute."""
|
|
52
|
+
|
|
53
|
+
local = "local"
|
|
54
|
+
ec2 = "ec2"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def new_run_id(label: str) -> str:
|
|
58
|
+
"""Build a sortable, human-friendly run id: ``20260615T1430-metadata-upload``."""
|
|
59
|
+
ts = datetime.datetime.now().strftime("%Y%m%dT%H%M%S")
|
|
60
|
+
return f"{ts}-{label}"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def resolve_dispatch_envs(env: str) -> Tuple[EnvConfig, EnvConfig]:
|
|
64
|
+
"""Return ``(auth_env, remote_env)`` for an EC2 dispatch.
|
|
65
|
+
|
|
66
|
+
``auth_env`` is the non-ec2 form used to authenticate the SSM call from the
|
|
67
|
+
laptop (named profile from the marker); ``remote_env`` is the ``*_ec2``
|
|
68
|
+
variant the remote job runs under (ambient instance profile). Both resolve
|
|
69
|
+
the same SSM tree, so they can never disagree on names. Accepts either the
|
|
70
|
+
base or the ``_ec2`` form for ``env``.
|
|
71
|
+
"""
|
|
72
|
+
base = env_base(env)
|
|
73
|
+
return resolve_env(base), resolve_env(f"{base}_ec2")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def build_remote_command(run_id: str, remote_argv: Sequence[str]) -> str:
|
|
77
|
+
"""Build the shell snippet SSM runs on the EC2 box.
|
|
78
|
+
|
|
79
|
+
``remote_argv`` is a ``g3dt`` CLI argv (e.g. ``["metadata", "upload", ...]``)
|
|
80
|
+
— the box has the toolkit pip-installed by CDK user-data, so no repo clone,
|
|
81
|
+
``git pull``, or poetry is involved. Output tees to an on-box archive under
|
|
82
|
+
``~/.g3dt/logs/`` and to stdout, which SSM forwards to CloudWatch Logs for
|
|
83
|
+
live ``g3dt jobs logs --follow``. PYTHONUNBUFFERED keeps the stream
|
|
84
|
+
line-by-line; ``set -o pipefail`` keeps the job's exit status flowing
|
|
85
|
+
through ``tee`` so failures still mark the invocation Failed.
|
|
86
|
+
"""
|
|
87
|
+
inner = " ".join(shlex.quote(str(a)) for a in remote_argv)
|
|
88
|
+
return " && ".join(
|
|
89
|
+
[
|
|
90
|
+
"set -euo pipefail",
|
|
91
|
+
"mkdir -p ~/.g3dt/logs",
|
|
92
|
+
f"PYTHONUNBUFFERED=1 g3dt {inner} 2>&1 | tee ~/.g3dt/logs/{run_id}.log",
|
|
93
|
+
]
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def dispatch_ssm(
|
|
98
|
+
auth_env: EnvConfig,
|
|
99
|
+
remote_env: EnvConfig,
|
|
100
|
+
remote_argv: Sequence[str],
|
|
101
|
+
label: str,
|
|
102
|
+
) -> str:
|
|
103
|
+
"""Launch a ``g3dt`` argv on the env's EC2 box via SSM Run Command."""
|
|
104
|
+
from g3dt import resolver
|
|
105
|
+
|
|
106
|
+
if not remote_env.ec2_instance_id:
|
|
107
|
+
typer.secho(
|
|
108
|
+
f"No ec2/instanceId published for env '{env_base(remote_env.name)}'. "
|
|
109
|
+
f"Has the ec2-job-runner stack been deployed? "
|
|
110
|
+
f"(cdk deploy in gen3-aws-data-pipeline)",
|
|
111
|
+
fg=typer.colors.RED,
|
|
112
|
+
err=True,
|
|
113
|
+
)
|
|
114
|
+
raise typer.Exit(2)
|
|
115
|
+
|
|
116
|
+
# Log destinations come from the same SSM tree as the instance id.
|
|
117
|
+
rc = resolver.resolve(
|
|
118
|
+
require_project(), env_base(remote_env.name), profile=auth_env.aws_profile
|
|
119
|
+
)
|
|
120
|
+
log_bucket, log_prefix = rc.ec2_log_bucket, rc.ec2_log_prefix
|
|
121
|
+
log_group = rc.ec2_log_group
|
|
122
|
+
|
|
123
|
+
session = create_boto3_session(
|
|
124
|
+
aws_profile=auth_env.aws_profile, aws_region=auth_env.region
|
|
125
|
+
)
|
|
126
|
+
ssm = session.client("ssm")
|
|
127
|
+
run_id = new_run_id(label)
|
|
128
|
+
# SSM AWS-RunShellScript executes as root, whose PATH and HOME differ from
|
|
129
|
+
# the operator user's. Run the job inside a login shell for the box's user
|
|
130
|
+
# so ~, PATH (incl. the pip console script), and /etc/profile.d/g3dt.sh
|
|
131
|
+
# resolve.
|
|
132
|
+
remote_user = remote_env.ssh_user or "ec2-user"
|
|
133
|
+
command = "runuser -l {user} -c {script}".format(
|
|
134
|
+
user=remote_user,
|
|
135
|
+
script=shlex.quote(build_remote_command(run_id, remote_argv)),
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
resp = ssm.send_command(
|
|
139
|
+
InstanceIds=[remote_env.ec2_instance_id],
|
|
140
|
+
DocumentName="AWS-RunShellScript",
|
|
141
|
+
Parameters={"commands": [command], "executionTimeout": ["172800"]},
|
|
142
|
+
OutputS3BucketName=log_bucket,
|
|
143
|
+
OutputS3KeyPrefix=f"{log_prefix}/{run_id}",
|
|
144
|
+
CloudWatchOutputConfig={
|
|
145
|
+
"CloudWatchLogGroupName": log_group,
|
|
146
|
+
"CloudWatchOutputEnabled": True,
|
|
147
|
+
},
|
|
148
|
+
Comment=f"g3dt {run_id}",
|
|
149
|
+
)
|
|
150
|
+
command_id = resp["Command"]["CommandId"]
|
|
151
|
+
s3_log_uri = f"s3://{log_bucket}/{log_prefix}/{run_id}"
|
|
152
|
+
registry.record(
|
|
153
|
+
run_id,
|
|
154
|
+
command_id=command_id,
|
|
155
|
+
instance_id=remote_env.ec2_instance_id,
|
|
156
|
+
env=remote_env.name,
|
|
157
|
+
argv=list(remote_argv),
|
|
158
|
+
mechanism="ssm",
|
|
159
|
+
s3_log_uri=s3_log_uri,
|
|
160
|
+
cw_log_group=log_group,
|
|
161
|
+
started_at=run_id.split("-", 1)[0],
|
|
162
|
+
)
|
|
163
|
+
_print_dispatch_banner(run_id, remote_env)
|
|
164
|
+
return run_id
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def run_or_dispatch(
|
|
168
|
+
on: Target,
|
|
169
|
+
env: str,
|
|
170
|
+
script_relpath: str,
|
|
171
|
+
build_args: Callable[[str], Sequence[str]],
|
|
172
|
+
label: str,
|
|
173
|
+
*,
|
|
174
|
+
interpreter: str = "python",
|
|
175
|
+
remote_cli: Optional[Callable[[str], Sequence[str]]] = None,
|
|
176
|
+
) -> None:
|
|
177
|
+
"""Run a packaged service script locally, or dispatch the CLI form to EC2.
|
|
178
|
+
|
|
179
|
+
``build_args(env_name)`` returns the service-script arguments for the given
|
|
180
|
+
``--env`` value; locally we run ``<interpreter> <packaged script> <args>``
|
|
181
|
+
with the resolved env exported as ``G3DT_*`` variables.
|
|
182
|
+
|
|
183
|
+
``remote_cli(env_name)`` returns the equivalent ``g3dt`` subcommand argv
|
|
184
|
+
for the ``*_ec2`` env — on the box the CLI re-enters this function with
|
|
185
|
+
``on=local`` and runs the same packaged script there.
|
|
186
|
+
|
|
187
|
+
``interpreter`` is ``"python"`` (run with the venv interpreter) or ``"bash"``.
|
|
188
|
+
"""
|
|
189
|
+
try:
|
|
190
|
+
if on == Target.local:
|
|
191
|
+
local_env = resolve_env(env)
|
|
192
|
+
args = build_args(local_env.name)
|
|
193
|
+
build = runner.bash_script if interpreter == "bash" else runner.python_script
|
|
194
|
+
runner.run(build(script_relpath, *args), env=script_env(local_env))
|
|
195
|
+
return
|
|
196
|
+
if remote_cli is None:
|
|
197
|
+
raise ConfigError(f"{label} does not support --on ec2.")
|
|
198
|
+
auth_env, remote_env = resolve_dispatch_envs(env)
|
|
199
|
+
except ConfigError as exc:
|
|
200
|
+
typer.secho(str(exc), fg=typer.colors.RED, err=True)
|
|
201
|
+
raise typer.Exit(1)
|
|
202
|
+
|
|
203
|
+
dispatch_ssm(auth_env, remote_env, list(remote_cli(remote_env.name)), label)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# --------------------------------------------------------------------------- #
|
|
207
|
+
# Watching dispatched runs (g3dt jobs) #
|
|
208
|
+
# --------------------------------------------------------------------------- #
|
|
209
|
+
def _auth_session_for_run(rec: dict):
|
|
210
|
+
"""Authenticate follow-up calls (status/logs/stop) for a recorded run.
|
|
211
|
+
|
|
212
|
+
Uses the marker's profile for the run's base env; region from the marker
|
|
213
|
+
(no SSM read needed just to poll an invocation).
|
|
214
|
+
"""
|
|
215
|
+
marker = load_marker()
|
|
216
|
+
return create_boto3_session(
|
|
217
|
+
aws_profile=aws_profile_for(rec["env"], marker),
|
|
218
|
+
aws_region=marker["region"],
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def status(run_id: str) -> dict:
|
|
223
|
+
"""Return the SSM invocation status for a dispatched run."""
|
|
224
|
+
rec = registry.get(run_id)
|
|
225
|
+
if not rec:
|
|
226
|
+
typer.secho(f"Unknown run id: {run_id}", fg=typer.colors.RED, err=True)
|
|
227
|
+
raise typer.Exit(1)
|
|
228
|
+
if not rec.get("command_id"):
|
|
229
|
+
typer.secho(
|
|
230
|
+
f"Run {run_id} has no SSM command id; check it on the box.",
|
|
231
|
+
fg=typer.colors.YELLOW,
|
|
232
|
+
)
|
|
233
|
+
return rec
|
|
234
|
+
ssm = _auth_session_for_run(rec).client("ssm")
|
|
235
|
+
return ssm.get_command_invocation(
|
|
236
|
+
CommandId=rec["command_id"], InstanceId=rec["instance_id"]
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def status_label(rec: dict) -> str:
|
|
241
|
+
"""Best-effort current status for the all-runs listing (never raises).
|
|
242
|
+
|
|
243
|
+
Unlike :func:`status`, this is keyed off the registry record and stays quiet
|
|
244
|
+
so it can be called in a loop.
|
|
245
|
+
"""
|
|
246
|
+
if not rec.get("command_id"):
|
|
247
|
+
return "n/a"
|
|
248
|
+
ssm = _auth_session_for_run(rec).client("ssm")
|
|
249
|
+
try:
|
|
250
|
+
inv = ssm.get_command_invocation(
|
|
251
|
+
CommandId=rec["command_id"], InstanceId=rec["instance_id"]
|
|
252
|
+
)
|
|
253
|
+
return inv.get("Status", "unknown")
|
|
254
|
+
except ssm.exceptions.InvocationDoesNotExist:
|
|
255
|
+
return "pending" # sent but not yet registered, or aged out of SSM history
|
|
256
|
+
except ClientError:
|
|
257
|
+
return "unknown"
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def stop(run_id: str) -> None:
|
|
261
|
+
"""Cancel a running SSM-dispatched job.
|
|
262
|
+
|
|
263
|
+
Asks the SSM agent to terminate the in-flight command on the box.
|
|
264
|
+
Cancellation isn't instantaneous and isn't guaranteed by AWS, but in
|
|
265
|
+
practice it stops the process; ``g3dt jobs status <run_id>`` shows
|
|
266
|
+
``Cancelled`` once it takes effect.
|
|
267
|
+
"""
|
|
268
|
+
rec = registry.get(run_id)
|
|
269
|
+
if not rec:
|
|
270
|
+
typer.secho(f"Unknown run id: {run_id}", fg=typer.colors.RED, err=True)
|
|
271
|
+
raise typer.Exit(1)
|
|
272
|
+
if not rec.get("command_id"):
|
|
273
|
+
typer.secho(
|
|
274
|
+
f"Run {run_id} has no SSM command id and can't be stopped remotely.",
|
|
275
|
+
fg=typer.colors.YELLOW,
|
|
276
|
+
err=True,
|
|
277
|
+
)
|
|
278
|
+
raise typer.Exit(1)
|
|
279
|
+
current = status_label(rec)
|
|
280
|
+
if current in _TERMINAL_STATUSES:
|
|
281
|
+
typer.secho(f"Run {run_id} already finished: {current}", fg=typer.colors.YELLOW)
|
|
282
|
+
return
|
|
283
|
+
ssm = _auth_session_for_run(rec).client("ssm")
|
|
284
|
+
try:
|
|
285
|
+
ssm.cancel_command(
|
|
286
|
+
CommandId=rec["command_id"], InstanceIds=[rec["instance_id"]]
|
|
287
|
+
)
|
|
288
|
+
except ClientError as exc:
|
|
289
|
+
typer.secho(f"Could not stop {run_id}: {exc}", fg=typer.colors.RED, err=True)
|
|
290
|
+
raise typer.Exit(1)
|
|
291
|
+
typer.secho(
|
|
292
|
+
f"Stop requested for {run_id} ({rec['instance_id']}).", fg=typer.colors.GREEN
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _read_s3_logs(rec: dict) -> str:
|
|
297
|
+
"""Best-effort fetch of the full stdout/stderr SSM wrote to S3."""
|
|
298
|
+
uri = rec.get("s3_log_uri") or ""
|
|
299
|
+
if not uri.startswith("s3://"):
|
|
300
|
+
return ""
|
|
301
|
+
bucket, _, prefix = uri[len("s3://"):].partition("/")
|
|
302
|
+
s3 = _auth_session_for_run(rec).client("s3")
|
|
303
|
+
listing = s3.list_objects_v2(Bucket=bucket, Prefix=prefix)
|
|
304
|
+
chunks: List[str] = []
|
|
305
|
+
for obj in listing.get("Contents", []):
|
|
306
|
+
key = obj["Key"]
|
|
307
|
+
if key.endswith(("stdout", "stderr")):
|
|
308
|
+
body = s3.get_object(Bucket=bucket, Key=key)["Body"].read()
|
|
309
|
+
chunks.append(body.decode("utf-8", errors="replace"))
|
|
310
|
+
return "\n".join(chunks)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
#: SSM invocation states that mean the run is over.
|
|
314
|
+
_TERMINAL_STATUSES = ("Success", "Failed", "Cancelled", "TimedOut")
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _read_cw_logs(rec: dict, start_time: int = 0) -> List[dict]:
|
|
318
|
+
"""Fetch CloudWatch Logs events for a run's SSM stdout/stderr streams.
|
|
319
|
+
|
|
320
|
+
SSM names the streams ``<command-id>/<instance-id>/<plugin>/{stdout,stderr}``,
|
|
321
|
+
so the ``<command-id>/<instance-id>`` prefix captures both. ``start_time``
|
|
322
|
+
(epoch millis) lets the follow loop pull only newer events; callers de-dup
|
|
323
|
+
by ``eventId``. Returns ``[]`` if the group/stream doesn't exist yet (run
|
|
324
|
+
just started, or the instance role lacks CloudWatch Logs write permission).
|
|
325
|
+
"""
|
|
326
|
+
group = rec.get("cw_log_group")
|
|
327
|
+
command_id = rec.get("command_id")
|
|
328
|
+
instance_id = rec.get("instance_id")
|
|
329
|
+
if not (group and command_id and instance_id):
|
|
330
|
+
return []
|
|
331
|
+
client = _auth_session_for_run(rec).client("logs")
|
|
332
|
+
kwargs = {
|
|
333
|
+
"logGroupName": group,
|
|
334
|
+
"logStreamNamePrefix": f"{command_id}/{instance_id}",
|
|
335
|
+
"startTime": start_time,
|
|
336
|
+
}
|
|
337
|
+
events: List[dict] = []
|
|
338
|
+
try:
|
|
339
|
+
while True:
|
|
340
|
+
resp = client.filter_log_events(**kwargs)
|
|
341
|
+
events.extend(resp.get("events", []))
|
|
342
|
+
token = resp.get("nextToken")
|
|
343
|
+
if not token:
|
|
344
|
+
break
|
|
345
|
+
kwargs["nextToken"] = token
|
|
346
|
+
except client.exceptions.ResourceNotFoundException:
|
|
347
|
+
return []
|
|
348
|
+
return events
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _stream_cw_logs(run_id: str, rec: dict, *, follow: bool, poll_seconds: int) -> None:
|
|
352
|
+
"""Print a run's CloudWatch output, optionally following until it finishes."""
|
|
353
|
+
seen_ids: set = set()
|
|
354
|
+
last_ts = 0
|
|
355
|
+
|
|
356
|
+
def drain() -> None:
|
|
357
|
+
nonlocal last_ts
|
|
358
|
+
for ev in sorted(_read_cw_logs(rec, last_ts), key=lambda e: e["timestamp"]):
|
|
359
|
+
if ev["eventId"] in seen_ids:
|
|
360
|
+
continue
|
|
361
|
+
seen_ids.add(ev["eventId"])
|
|
362
|
+
last_ts = max(last_ts, ev["timestamp"])
|
|
363
|
+
typer.echo(ev["message"])
|
|
364
|
+
|
|
365
|
+
while True:
|
|
366
|
+
drain()
|
|
367
|
+
if not follow:
|
|
368
|
+
break
|
|
369
|
+
inv = status(run_id)
|
|
370
|
+
if isinstance(inv, dict) and inv.get("Status") in _TERMINAL_STATUSES:
|
|
371
|
+
drain() # catch events ingested between the last drain and completion
|
|
372
|
+
typer.secho(f"\n[run {run_id} finished: {inv['Status']}]",
|
|
373
|
+
fg=typer.colors.BRIGHT_BLACK)
|
|
374
|
+
break
|
|
375
|
+
time.sleep(poll_seconds)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def logs(run_id: str, follow: bool = False, poll_seconds: int = 5) -> None:
|
|
379
|
+
"""Print (and optionally follow) the logs for a dispatched run."""
|
|
380
|
+
rec = registry.get(run_id)
|
|
381
|
+
if not rec:
|
|
382
|
+
typer.secho(f"Unknown run id: {run_id}", fg=typer.colors.RED, err=True)
|
|
383
|
+
raise typer.Exit(1)
|
|
384
|
+
|
|
385
|
+
# Runs dispatched with CloudWatch output stream live; S3 output is only
|
|
386
|
+
# uploaded on completion, so it is the fallback.
|
|
387
|
+
if rec.get("cw_log_group"):
|
|
388
|
+
_stream_cw_logs(run_id, rec, follow=follow, poll_seconds=poll_seconds)
|
|
389
|
+
return
|
|
390
|
+
|
|
391
|
+
seen = 0
|
|
392
|
+
while True:
|
|
393
|
+
text = _read_s3_logs(rec)
|
|
394
|
+
if len(text) > seen:
|
|
395
|
+
typer.echo(text[seen:], nl=False)
|
|
396
|
+
seen = len(text)
|
|
397
|
+
if not follow:
|
|
398
|
+
break
|
|
399
|
+
inv = status(run_id)
|
|
400
|
+
if isinstance(inv, dict) and inv.get("Status") in _TERMINAL_STATUSES:
|
|
401
|
+
typer.secho(f"\n[run {run_id} finished: {inv['Status']}]",
|
|
402
|
+
fg=typer.colors.BRIGHT_BLACK)
|
|
403
|
+
break
|
|
404
|
+
time.sleep(poll_seconds)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _print_dispatch_banner(run_id: str, remote_env: EnvConfig) -> None:
|
|
408
|
+
typer.secho(
|
|
409
|
+
f"Dispatched to EC2 ({remote_env.ec2_instance_id} / {remote_env.name})",
|
|
410
|
+
fg=typer.colors.GREEN,
|
|
411
|
+
)
|
|
412
|
+
typer.echo(f" run id : {run_id}")
|
|
413
|
+
typer.echo(f" logs : g3dt jobs logs {run_id} --follow")
|
|
414
|
+
typer.echo(f" status : g3dt jobs status {run_id}")
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
__all__ = [
|
|
418
|
+
"Target",
|
|
419
|
+
"run_or_dispatch",
|
|
420
|
+
"dispatch_ssm",
|
|
421
|
+
"resolve_dispatch_envs",
|
|
422
|
+
"build_remote_command",
|
|
423
|
+
"new_run_id",
|
|
424
|
+
"status",
|
|
425
|
+
"status_label",
|
|
426
|
+
"stop",
|
|
427
|
+
"logs",
|
|
428
|
+
]
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""A tiny local registry of EC2-dispatched runs (``~/.g3dt/runs.json``).
|
|
2
|
+
|
|
3
|
+
Maps a human-friendly ``run_id`` to the underlying SSM command id so users
|
|
4
|
+
never have to copy long AWS identifiers — ``g3dt jobs status <run_id>`` and
|
|
5
|
+
``g3dt jobs logs <run_id>`` look everything up here.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Dict, List, Optional
|
|
13
|
+
|
|
14
|
+
REGISTRY_DIR = Path(os.path.expanduser("~")) / ".g3dt"
|
|
15
|
+
REGISTRY_FILE = REGISTRY_DIR / "runs.json"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _load() -> Dict[str, dict]:
|
|
19
|
+
if REGISTRY_FILE.exists():
|
|
20
|
+
try:
|
|
21
|
+
return json.loads(REGISTRY_FILE.read_text())
|
|
22
|
+
except (json.JSONDecodeError, OSError):
|
|
23
|
+
return {}
|
|
24
|
+
return {}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _save(data: Dict[str, dict]) -> None:
|
|
28
|
+
REGISTRY_DIR.mkdir(parents=True, exist_ok=True)
|
|
29
|
+
REGISTRY_FILE.write_text(json.dumps(data, indent=2, sort_keys=True))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def record(
|
|
33
|
+
run_id: str,
|
|
34
|
+
*,
|
|
35
|
+
command_id: Optional[str],
|
|
36
|
+
instance_id: Optional[str],
|
|
37
|
+
env: str,
|
|
38
|
+
argv: List[str],
|
|
39
|
+
mechanism: str,
|
|
40
|
+
s3_log_uri: Optional[str] = None,
|
|
41
|
+
cw_log_group: Optional[str] = None,
|
|
42
|
+
started_at: Optional[str] = None,
|
|
43
|
+
) -> None:
|
|
44
|
+
"""Persist a dispatched run."""
|
|
45
|
+
data = _load()
|
|
46
|
+
data[run_id] = {
|
|
47
|
+
"run_id": run_id,
|
|
48
|
+
"command_id": command_id,
|
|
49
|
+
"instance_id": instance_id,
|
|
50
|
+
"env": env,
|
|
51
|
+
"argv": list(argv),
|
|
52
|
+
"mechanism": mechanism,
|
|
53
|
+
"s3_log_uri": s3_log_uri,
|
|
54
|
+
"cw_log_group": cw_log_group,
|
|
55
|
+
"started_at": started_at,
|
|
56
|
+
}
|
|
57
|
+
_save(data)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def get(run_id: str) -> Optional[dict]:
|
|
61
|
+
return _load().get(run_id)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def all_runs() -> Dict[str, dict]:
|
|
65
|
+
return _load()
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Thin wrappers that turn config resolution errors into clean CLI exits."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import typer
|
|
5
|
+
|
|
6
|
+
from g3dt import config
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def env_of(env: str) -> config.EnvConfig:
|
|
10
|
+
try:
|
|
11
|
+
return config.resolve_env(env)
|
|
12
|
+
except config.ConfigError as exc:
|
|
13
|
+
typer.secho(str(exc), fg=typer.colors.RED, err=True)
|
|
14
|
+
raise typer.Exit(1)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def study_of(study: str, env: str) -> config.StudyConfig:
|
|
18
|
+
try:
|
|
19
|
+
return config.resolve_study(study, env)
|
|
20
|
+
except config.ConfigError as exc:
|
|
21
|
+
typer.secho(str(exc), fg=typer.colors.RED, err=True)
|
|
22
|
+
raise typer.Exit(1)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Run local subprocesses with live-streamed output and clean exit handling.
|
|
2
|
+
|
|
3
|
+
Commands shell out through :func:`run` so failures surface as ``typer.Exit``
|
|
4
|
+
with the child's exit code (preserving the ``set -e`` semantics of the wrapped
|
|
5
|
+
shell scripts). Tests patch :func:`run` to assert the exact argv built.
|
|
6
|
+
|
|
7
|
+
Service scripts ship *inside* the installed package (``g3dt/services/...``),
|
|
8
|
+
so the toolkit works from a bare ``pip install`` with no repository checkout —
|
|
9
|
+
:func:`package_path` resolves them from the package directory.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
from importlib import resources
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import List, Optional, Sequence
|
|
18
|
+
|
|
19
|
+
import typer
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def package_path(relpath: str) -> Path:
|
|
23
|
+
"""Resolve a path shipped inside the ``g3dt`` package (e.g. ``services/...``).
|
|
24
|
+
|
|
25
|
+
``relpath`` may use the historical ``services/...`` form; it is resolved
|
|
26
|
+
against the installed package directory, never a repo checkout.
|
|
27
|
+
"""
|
|
28
|
+
return Path(str(resources.files("g3dt"))) / relpath
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def python_script(relpath: str, *args: str) -> List[str]:
|
|
32
|
+
"""Build an argv that runs a packaged service script with this interpreter.
|
|
33
|
+
|
|
34
|
+
Using ``sys.executable`` guarantees the script runs inside the same
|
|
35
|
+
environment as the CLI itself.
|
|
36
|
+
"""
|
|
37
|
+
return [sys.executable, str(package_path(relpath)), *[str(a) for a in args]]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def bash_script(relpath: str, *args: str) -> List[str]:
|
|
41
|
+
"""Build an argv that runs a packaged shell script via bash."""
|
|
42
|
+
return ["bash", str(package_path(relpath)), *[str(a) for a in args]]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def run(
|
|
46
|
+
argv: Sequence[str],
|
|
47
|
+
*,
|
|
48
|
+
cwd: Optional[Path] = None,
|
|
49
|
+
env: Optional[dict] = None,
|
|
50
|
+
echo: bool = True,
|
|
51
|
+
) -> int:
|
|
52
|
+
"""Run ``argv``, streaming output live. Raise ``typer.Exit`` on failure.
|
|
53
|
+
|
|
54
|
+
Returns 0 on success. The working directory defaults to the caller's
|
|
55
|
+
current directory (there is no repo root to default to — the toolkit is
|
|
56
|
+
installable-only).
|
|
57
|
+
"""
|
|
58
|
+
argv = [str(a) for a in argv]
|
|
59
|
+
workdir = Path(cwd) if cwd else Path.cwd()
|
|
60
|
+
if echo:
|
|
61
|
+
typer.secho(f"$ {' '.join(argv)}", fg=typer.colors.BRIGHT_BLACK)
|
|
62
|
+
try:
|
|
63
|
+
completed = subprocess.run(argv, cwd=str(workdir), env=env, check=False)
|
|
64
|
+
except FileNotFoundError as exc:
|
|
65
|
+
typer.secho(
|
|
66
|
+
f"Command not found: {argv[0]} ({exc})", fg=typer.colors.RED, err=True
|
|
67
|
+
)
|
|
68
|
+
raise typer.Exit(127)
|
|
69
|
+
if completed.returncode != 0:
|
|
70
|
+
typer.secho(
|
|
71
|
+
f"Command failed (exit {completed.returncode}): {' '.join(argv)}",
|
|
72
|
+
fg=typer.colors.RED,
|
|
73
|
+
err=True,
|
|
74
|
+
)
|
|
75
|
+
raise typer.Exit(completed.returncode)
|
|
76
|
+
return 0
|