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/config.py
ADDED
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
"""Configuration resolution for the g3dt CLI and services.
|
|
2
|
+
|
|
3
|
+
The platform has exactly two kinds of configuration:
|
|
4
|
+
|
|
5
|
+
* **INPUTS** — human-authored values, committed as
|
|
6
|
+
``config/<projectId>.<env>.json`` in the CDK repo (gen3-aws-data-pipeline)
|
|
7
|
+
and read only by ``cdk deploy``. The toolkit never reads these files.
|
|
8
|
+
* **OUTPUTS** — every resource name the CDK creates, plus the mirrored Gen3
|
|
9
|
+
app facts, published to SSM Parameter Store under ``/{project}/{env}/...``
|
|
10
|
+
on deploy. The toolkit resolves everything from there at runtime
|
|
11
|
+
(see :mod:`g3dt.resolver`).
|
|
12
|
+
|
|
13
|
+
The only local configuration is a tiny bootstrap **marker** — ``g3dt.yaml`` —
|
|
14
|
+
that tells the CLI which project/region to resolve (plus optional per-env AWS
|
|
15
|
+
profile names and the study registry). Search order: ``./g3dt.yaml`` →
|
|
16
|
+
``~/.g3dt/g3dt.yaml`` → ``/etc/g3dt/g3dt.yaml`` (the EC2 job box's copy,
|
|
17
|
+
written by CDK user-data). Environment variables override the file:
|
|
18
|
+
``G3DT_PROJECT``, ``AWS_REGION``, ``G3DT_DEFAULT_ENV``; ``G3DT_MARKER`` points
|
|
19
|
+
at an explicit marker path.
|
|
20
|
+
|
|
21
|
+
Design notes
|
|
22
|
+
------------
|
|
23
|
+
* ``resolve_env`` returns the same frozen :class:`EnvConfig` the pre-2.0 CLI
|
|
24
|
+
used, so command groups and services are agnostic to where values came from.
|
|
25
|
+
* Studies are project data, not CDK-created names, so they stay in the marker
|
|
26
|
+
(``studies:`` block) for now. ``resolve_study`` keeps the safety-critical
|
|
27
|
+
``{study}_{env_base}`` derivation that keeps staging data out of prod.
|
|
28
|
+
"""
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import functools
|
|
32
|
+
import os
|
|
33
|
+
from dataclasses import dataclass
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
from typing import Dict, List, Optional, Tuple
|
|
36
|
+
|
|
37
|
+
import yaml
|
|
38
|
+
|
|
39
|
+
DEFAULT_REGION = "ap-southeast-2"
|
|
40
|
+
|
|
41
|
+
#: Marker locations, most specific first.
|
|
42
|
+
MARKER_PATHS = ("g3dt.yaml", "~/.g3dt/g3dt.yaml", "/etc/g3dt/g3dt.yaml")
|
|
43
|
+
|
|
44
|
+
#: Marker keys `g3dt config set` may write.
|
|
45
|
+
SETTABLE_MARKER_KEYS = ("project", "region", "default_env")
|
|
46
|
+
|
|
47
|
+
#: Gen3 app facts mirrored to SSM /{project}/{env}/app/* by the CDK.
|
|
48
|
+
REQUIRED_APP_KEYS = (
|
|
49
|
+
"dictionary_version",
|
|
50
|
+
"aws_secret_name",
|
|
51
|
+
"schema_s3_uri",
|
|
52
|
+
"domain",
|
|
53
|
+
"app_name",
|
|
54
|
+
"namespace",
|
|
55
|
+
"cluster_name",
|
|
56
|
+
"schema_repo",
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
#: Operational table conventions. Fixed names, exactly like the CDK's
|
|
60
|
+
#: `releases` table (lib/names.ts) — they live in the env's metadata Glue DB
|
|
61
|
+
#: and under the env's metadata bucket, both resolved from SSM.
|
|
62
|
+
METADATA_UPLOAD_TABLE = "metadata_upload_iceberg"
|
|
63
|
+
METADATA_UPLOAD_PREFIX = "metadata_upload/"
|
|
64
|
+
FILE_METADATA_TABLE = "file_metadata"
|
|
65
|
+
INDEXD_REGISTRY_TABLE = "indexd_registry"
|
|
66
|
+
INDEXD_PREFIX = "indexd/"
|
|
67
|
+
|
|
68
|
+
#: Where a project's study registry lives when it isn't in the local marker:
|
|
69
|
+
#: s3://<metadata-bucket>/<STUDIES_S3_KEY> (see resolve_study).
|
|
70
|
+
STUDIES_S3_KEY = "config/studies.yaml"
|
|
71
|
+
|
|
72
|
+
#: Suffixes used to strip the environment from a study key.
|
|
73
|
+
_STUDY_ENV_SUFFIXES = ("_staging", "_prod", "_test")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class ConfigError(KeyError):
|
|
77
|
+
"""Raised when an env/study cannot be resolved or required keys are missing.
|
|
78
|
+
|
|
79
|
+
Subclasses ``KeyError`` so existing ``except KeyError`` handlers still catch
|
|
80
|
+
it, but carries a human-readable message that doubles as CLI help.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
def __str__(self) -> str: # KeyError repr adds quotes; we want the raw text
|
|
84
|
+
return self.args[0] if self.args else ""
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# --------------------------------------------------------------------------- #
|
|
88
|
+
# The bootstrap marker (g3dt.yaml) #
|
|
89
|
+
# --------------------------------------------------------------------------- #
|
|
90
|
+
def marker_path() -> Optional[Path]:
|
|
91
|
+
"""Return the first marker file that exists, or ``None``.
|
|
92
|
+
|
|
93
|
+
``$G3DT_MARKER`` overrides the search entirely (useful in tests and CI).
|
|
94
|
+
"""
|
|
95
|
+
override = os.getenv("G3DT_MARKER")
|
|
96
|
+
if override:
|
|
97
|
+
return Path(override).expanduser()
|
|
98
|
+
for candidate in MARKER_PATHS:
|
|
99
|
+
p = Path(candidate).expanduser()
|
|
100
|
+
if p.is_file():
|
|
101
|
+
return p
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@functools.lru_cache(maxsize=None)
|
|
106
|
+
def _load_yaml_cached(path_str: str) -> dict:
|
|
107
|
+
with open(path_str, "r", encoding="utf-8") as f:
|
|
108
|
+
return yaml.safe_load(f) or {}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def load_marker() -> dict:
|
|
112
|
+
"""Read the g3dt.yaml marker; env vars override file values.
|
|
113
|
+
|
|
114
|
+
Returns a dict with at least ``project``/``region``/``default_env`` keys
|
|
115
|
+
(possibly ``None``) plus whatever else the file carries (``profiles``,
|
|
116
|
+
``studies``, ``ssh_key``, ``ssh_user``).
|
|
117
|
+
"""
|
|
118
|
+
path = marker_path()
|
|
119
|
+
data = dict(_load_yaml_cached(str(path))) if path and path.is_file() else {}
|
|
120
|
+
data["project"] = os.getenv("G3DT_PROJECT", data.get("project"))
|
|
121
|
+
data["region"] = os.getenv("AWS_REGION", data.get("region", DEFAULT_REGION))
|
|
122
|
+
data["default_env"] = os.getenv("G3DT_DEFAULT_ENV", data.get("default_env"))
|
|
123
|
+
return data
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def require_project(marker: Optional[dict] = None) -> str:
|
|
127
|
+
"""Return the project id or fail with setup instructions."""
|
|
128
|
+
m = marker if marker is not None else load_marker()
|
|
129
|
+
project = m.get("project")
|
|
130
|
+
if not project:
|
|
131
|
+
raise ConfigError(
|
|
132
|
+
"No project configured. Create a g3dt.yaml marker (searched: "
|
|
133
|
+
f"{', '.join(MARKER_PATHS)}) with at least:\n"
|
|
134
|
+
" project: <projectId>\n"
|
|
135
|
+
" region: ap-southeast-2\n"
|
|
136
|
+
"or set $G3DT_PROJECT. Run `g3dt config set project <id>` to write one."
|
|
137
|
+
)
|
|
138
|
+
return project
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def set_marker_value(key: str, value: str) -> Tuple[Optional[str], str, Path]:
|
|
142
|
+
"""Set one bootstrap key in the user's marker file and write it back.
|
|
143
|
+
|
|
144
|
+
Writes to the existing marker if one is found, else creates
|
|
145
|
+
``~/.g3dt/g3dt.yaml``. Returns ``(old_value, new_value, path)``.
|
|
146
|
+
"""
|
|
147
|
+
if key not in SETTABLE_MARKER_KEYS:
|
|
148
|
+
raise ConfigError(
|
|
149
|
+
f"Key '{key}' is not a settable bootstrap key. "
|
|
150
|
+
f"Settable keys: {', '.join(SETTABLE_MARKER_KEYS)}. "
|
|
151
|
+
f"Deployed settings (dictionary_version, domain, ...) are CDK INPUTS: "
|
|
152
|
+
f"edit config/<project>.<env>.json in gen3-aws-data-pipeline and "
|
|
153
|
+
f"redeploy — the values flow to SSM, not to this file."
|
|
154
|
+
)
|
|
155
|
+
path = marker_path()
|
|
156
|
+
if path is None:
|
|
157
|
+
path = Path("~/.g3dt/g3dt.yaml").expanduser()
|
|
158
|
+
if path.is_file():
|
|
159
|
+
data = dict(_load_yaml_cached(str(path)))
|
|
160
|
+
else:
|
|
161
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
162
|
+
data = {}
|
|
163
|
+
old = data.get(key)
|
|
164
|
+
data[key] = value
|
|
165
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
166
|
+
yaml.safe_dump(data, f, default_flow_style=False, sort_keys=False)
|
|
167
|
+
_load_yaml_cached.cache_clear()
|
|
168
|
+
return old, value, path
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# --------------------------------------------------------------------------- #
|
|
172
|
+
# Environments #
|
|
173
|
+
# --------------------------------------------------------------------------- #
|
|
174
|
+
def env_base(env: str) -> str:
|
|
175
|
+
"""Strip a trailing ``_ec2`` suffix: ``staging_ec2`` -> ``staging``."""
|
|
176
|
+
return env[:-4] if env.endswith("_ec2") else env
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def aws_profile_for(env: str, marker: Optional[dict] = None) -> Optional[str]:
|
|
180
|
+
"""Return the local AWS named profile for ``env``, or ``None`` (ambient).
|
|
181
|
+
|
|
182
|
+
Read from the marker's optional ``profiles:`` map, e.g.::
|
|
183
|
+
|
|
184
|
+
profiles:
|
|
185
|
+
test: etl_test
|
|
186
|
+
staging: etl_staging
|
|
187
|
+
|
|
188
|
+
On the EC2 box / CodeBuild there is no ``profiles`` map, so the default
|
|
189
|
+
credential chain (the instance/build role) is used — by design.
|
|
190
|
+
"""
|
|
191
|
+
m = marker if marker is not None else load_marker()
|
|
192
|
+
profiles = m.get("profiles") or {}
|
|
193
|
+
return profiles.get(env_base(env))
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@dataclass(frozen=True)
|
|
197
|
+
class EnvConfig:
|
|
198
|
+
"""Resolved settings for one environment (names from SSM, auth from marker)."""
|
|
199
|
+
|
|
200
|
+
name: str
|
|
201
|
+
is_ec2: bool
|
|
202
|
+
region: str
|
|
203
|
+
dictionary_version: str
|
|
204
|
+
aws_profile: Optional[str]
|
|
205
|
+
aws_secret_name: str
|
|
206
|
+
schema_s3_uri: str
|
|
207
|
+
domain: str
|
|
208
|
+
app_name: str
|
|
209
|
+
namespace: str
|
|
210
|
+
cluster_name: str
|
|
211
|
+
schema_repo: str
|
|
212
|
+
ec2_instance_id: Optional[str] = None
|
|
213
|
+
ssh_key: Optional[str] = None
|
|
214
|
+
ssh_user: Optional[str] = None
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def resolve_env(env: str, project: Optional[str] = None) -> EnvConfig:
|
|
218
|
+
"""Resolve one environment: app INPUT facts + CDK OUTPUT names, from SSM.
|
|
219
|
+
|
|
220
|
+
``env`` may carry a ``_ec2`` suffix (dispatch pseudo-env): SSM has one tree
|
|
221
|
+
per real env, so the suffix is stripped for resolution and recorded as
|
|
222
|
+
``is_ec2``. An ``*_ec2`` env authenticates with the ambient credential
|
|
223
|
+
chain (``aws_profile=None``) — on the box that is the instance profile.
|
|
224
|
+
"""
|
|
225
|
+
from g3dt import resolver # late import: resolver imports ConfigError from here
|
|
226
|
+
|
|
227
|
+
marker = load_marker()
|
|
228
|
+
project = project or require_project(marker)
|
|
229
|
+
base = env_base(env)
|
|
230
|
+
is_ec2 = env.endswith("_ec2")
|
|
231
|
+
profile = None if is_ec2 else aws_profile_for(base, marker)
|
|
232
|
+
|
|
233
|
+
rc = resolver.resolve(project, base, profile=profile)
|
|
234
|
+
|
|
235
|
+
missing = [k for k in REQUIRED_APP_KEYS if f"app/{k}" not in rc.params]
|
|
236
|
+
if missing:
|
|
237
|
+
raise ConfigError(
|
|
238
|
+
f"Environment '{env}' is missing required app fact(s) in SSM: "
|
|
239
|
+
f"{', '.join(missing)} (expected at /{project}/{base}/app/*). "
|
|
240
|
+
f"Re-run `cdk deploy` in gen3-aws-data-pipeline so the inputs are "
|
|
241
|
+
f"mirrored to SSM."
|
|
242
|
+
)
|
|
243
|
+
return EnvConfig(
|
|
244
|
+
name=env,
|
|
245
|
+
is_ec2=is_ec2,
|
|
246
|
+
region=rc.get("meta/region", marker["region"]),
|
|
247
|
+
dictionary_version=rc.app("dictionary_version"),
|
|
248
|
+
aws_profile=profile,
|
|
249
|
+
aws_secret_name=rc.app("aws_secret_name"),
|
|
250
|
+
schema_s3_uri=rc.app("schema_s3_uri"),
|
|
251
|
+
domain=rc.app("domain"),
|
|
252
|
+
app_name=rc.app("app_name"),
|
|
253
|
+
namespace=rc.app("namespace"),
|
|
254
|
+
cluster_name=rc.app("cluster_name"),
|
|
255
|
+
schema_repo=rc.app("schema_repo"),
|
|
256
|
+
ec2_instance_id=rc.get("ec2/instanceId"),
|
|
257
|
+
ssh_key=(marker.get("ssh_key") if is_ec2 else None),
|
|
258
|
+
ssh_user=(marker.get("ssh_user") if is_ec2 else None),
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def list_envs(project: Optional[str] = None) -> List[str]:
|
|
263
|
+
"""Return the environments that have a deployed SSM tree for the project."""
|
|
264
|
+
from g3dt import resolver
|
|
265
|
+
|
|
266
|
+
marker = load_marker()
|
|
267
|
+
project = project or require_project(marker)
|
|
268
|
+
# Listing spans envs, so authenticate with any configured profile (they all
|
|
269
|
+
# target the same account) or the ambient chain.
|
|
270
|
+
profiles = marker.get("profiles") or {}
|
|
271
|
+
profile = next(iter(profiles.values()), None)
|
|
272
|
+
return resolver.list_envs(project, profile=profile)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def script_env(e: EnvConfig) -> Dict[str, str]:
|
|
276
|
+
"""Environment variables for a wrapped service script.
|
|
277
|
+
|
|
278
|
+
The pre-2.0 shell scripts parsed the legacy YAML config themselves with
|
|
279
|
+
``yq``; now the Python caller resolves everything from SSM and hands it
|
|
280
|
+
over as ``G3DT_*`` variables.
|
|
281
|
+
"""
|
|
282
|
+
env = dict(os.environ)
|
|
283
|
+
values = {
|
|
284
|
+
"G3DT_ENV": e.name,
|
|
285
|
+
"G3DT_REGION": e.region,
|
|
286
|
+
"G3DT_AWS_PROFILE": e.aws_profile or "",
|
|
287
|
+
"G3DT_DICTIONARY_VERSION": e.dictionary_version,
|
|
288
|
+
"G3DT_AWS_SECRET_NAME": e.aws_secret_name,
|
|
289
|
+
"G3DT_SCHEMA_S3_URI": e.schema_s3_uri,
|
|
290
|
+
"G3DT_DOMAIN": e.domain,
|
|
291
|
+
"G3DT_APP_NAME": e.app_name,
|
|
292
|
+
"G3DT_NAMESPACE": e.namespace,
|
|
293
|
+
"G3DT_CLUSTER_NAME": e.cluster_name,
|
|
294
|
+
"G3DT_SCHEMA_REPO": e.schema_repo,
|
|
295
|
+
}
|
|
296
|
+
env.update({k: v for k, v in values.items() if v is not None})
|
|
297
|
+
return env
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
# --------------------------------------------------------------------------- #
|
|
301
|
+
# Studies (project data — marker-resident for now) #
|
|
302
|
+
# --------------------------------------------------------------------------- #
|
|
303
|
+
@dataclass(frozen=True)
|
|
304
|
+
class StudyConfig:
|
|
305
|
+
"""Resolved settings for one study (in a given environment)."""
|
|
306
|
+
|
|
307
|
+
key: str
|
|
308
|
+
project_id: str
|
|
309
|
+
program_id: str
|
|
310
|
+
s3_metadata_path: str
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def list_studies(marker: Optional[dict] = None, env: Optional[str] = None) -> List[str]:
|
|
314
|
+
"""Return unique bare study names (env suffix stripped) for friendly help."""
|
|
315
|
+
m = marker if marker is not None else load_marker()
|
|
316
|
+
studies = m.get("studies") or {}
|
|
317
|
+
if not studies and env:
|
|
318
|
+
studies = _studies_from_s3(env, m) or {}
|
|
319
|
+
prefixes = set()
|
|
320
|
+
for key in studies:
|
|
321
|
+
for suffix in _STUDY_ENV_SUFFIXES:
|
|
322
|
+
if key.endswith(suffix):
|
|
323
|
+
prefixes.add(key[: -len(suffix)])
|
|
324
|
+
break
|
|
325
|
+
else:
|
|
326
|
+
prefixes.add(key)
|
|
327
|
+
return sorted(prefixes)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
@functools.lru_cache(maxsize=None)
|
|
331
|
+
def _studies_from_s3_cached(project: str, base_env: str, profile: Optional[str]) -> dict:
|
|
332
|
+
"""Fetch the project's study registry from S3 (see STUDIES_S3_KEY)."""
|
|
333
|
+
import boto3
|
|
334
|
+
from botocore.exceptions import ClientError
|
|
335
|
+
|
|
336
|
+
from g3dt import resolver
|
|
337
|
+
|
|
338
|
+
rc = resolver.resolve(project, base_env, profile=profile)
|
|
339
|
+
session = boto3.Session(profile_name=profile) if profile else boto3.Session()
|
|
340
|
+
try:
|
|
341
|
+
body = session.client("s3").get_object(
|
|
342
|
+
Bucket=rc.metadata_bucket, Key=STUDIES_S3_KEY
|
|
343
|
+
)["Body"].read()
|
|
344
|
+
except ClientError:
|
|
345
|
+
return {}
|
|
346
|
+
data = yaml.safe_load(body) or {}
|
|
347
|
+
# Accept either a bare map of study keys or a {studies: {...}} wrapper.
|
|
348
|
+
return data.get("studies", data)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _studies_from_s3(env: str, marker: dict) -> dict:
|
|
352
|
+
"""Best-effort S3 fallback for the study registry (empty dict on any miss)."""
|
|
353
|
+
try:
|
|
354
|
+
project = require_project(marker)
|
|
355
|
+
base = env_base(env)
|
|
356
|
+
is_ec2 = env.endswith("_ec2")
|
|
357
|
+
profile = None if is_ec2 else aws_profile_for(base, marker)
|
|
358
|
+
return _studies_from_s3_cached(project, base, profile)
|
|
359
|
+
except Exception:
|
|
360
|
+
return {}
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def resolve_study(study: str, env: str, marker: Optional[dict] = None) -> StudyConfig:
|
|
364
|
+
"""Resolve a study against an environment.
|
|
365
|
+
|
|
366
|
+
The registry comes from the marker's ``studies:`` map if present, else from
|
|
367
|
+
``s3://<metadata-bucket>/config/studies.yaml`` (so the EC2 job box — whose
|
|
368
|
+
marker carries only the bootstrap — resolves the same registry the laptop
|
|
369
|
+
does; upload it once per env with ``aws s3 cp``).
|
|
370
|
+
|
|
371
|
+
Tries ``<study>_<env_base>`` first (so ``ausdiab`` + ``staging`` →
|
|
372
|
+
``ausdiab_staging``), then the literal ``study`` key for back-compat. This
|
|
373
|
+
derivation is the safety rule that keeps staging data out of prod.
|
|
374
|
+
"""
|
|
375
|
+
m = marker if marker is not None else load_marker()
|
|
376
|
+
studies = m.get("studies") or {}
|
|
377
|
+
if not studies:
|
|
378
|
+
studies = _studies_from_s3(env, m) or {}
|
|
379
|
+
candidates = [f"{study}_{env_base(env)}", study]
|
|
380
|
+
for key in candidates:
|
|
381
|
+
sc = studies.get(key)
|
|
382
|
+
if sc:
|
|
383
|
+
return StudyConfig(
|
|
384
|
+
key=key,
|
|
385
|
+
project_id=sc["project_id"],
|
|
386
|
+
program_id=sc["program_id"],
|
|
387
|
+
s3_metadata_path=sc["s3_metadata_path"],
|
|
388
|
+
)
|
|
389
|
+
valid = ", ".join(list_studies(m)) or "(none — add a studies: block to g3dt.yaml)"
|
|
390
|
+
raise ConfigError(
|
|
391
|
+
f"Study '{study}' (env '{env}') not found. Tried {candidates}. "
|
|
392
|
+
f"Valid studies: {valid}"
|
|
393
|
+
)
|
g3dt/indexd/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import uuid
|
|
3
|
+
import urllib.parse
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import List, Dict, Any, Optional
|
|
6
|
+
|
|
7
|
+
import boto3
|
|
8
|
+
import awswrangler as wr
|
|
9
|
+
import pandas as pd
|
|
10
|
+
import pytz
|
|
11
|
+
from gen3.index import Gen3Index
|
|
12
|
+
|
|
13
|
+
from g3dt.utils.athena_utils import write_iceberg_to_db
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# ---------- baseid helpers ----------
|
|
19
|
+
|
|
20
|
+
def filename_to_baseid(filename: str) -> str:
|
|
21
|
+
"""Generate a deterministic UUIDv5 baseid from a filename.
|
|
22
|
+
|
|
23
|
+
Uses uuid.NAMESPACE_DNS as the namespace so the same filename
|
|
24
|
+
always produces the same baseid, enabling idempotent indexd
|
|
25
|
+
registration and version tracking.
|
|
26
|
+
|
|
27
|
+
Parameters
|
|
28
|
+
----------
|
|
29
|
+
filename : str
|
|
30
|
+
The basename of the file (e.g. ``"data.csv"``).
|
|
31
|
+
|
|
32
|
+
Returns
|
|
33
|
+
-------
|
|
34
|
+
str
|
|
35
|
+
A UUID string derived from the filename.
|
|
36
|
+
"""
|
|
37
|
+
return str(uuid.uuid5(uuid.NAMESPACE_DNS, filename))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# ---------- S3 scanning ----------
|
|
41
|
+
|
|
42
|
+
def _parse_s3(uri: str):
|
|
43
|
+
"""Parse an S3 URI into (bucket, key)."""
|
|
44
|
+
p = urllib.parse.urlparse(uri)
|
|
45
|
+
return p.netloc, p.path.lstrip("/")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def scan_s3_files(
|
|
49
|
+
s3_paths: List[str],
|
|
50
|
+
boto3_session: Optional[boto3.Session] = None,
|
|
51
|
+
) -> pd.DataFrame:
|
|
52
|
+
"""Recursively list files under S3 prefixes and collect metadata.
|
|
53
|
+
|
|
54
|
+
For each object found, retrieves ``ETag`` (md5), ``ContentLength``
|
|
55
|
+
(file_size), and computes a deterministic ``baseid`` from the filename.
|
|
56
|
+
|
|
57
|
+
Parameters
|
|
58
|
+
----------
|
|
59
|
+
s3_paths : list[str]
|
|
60
|
+
One or more S3 URI prefixes to scan (e.g. ``["s3://bucket/data/"]``).
|
|
61
|
+
boto3_session : boto3.Session, optional
|
|
62
|
+
A boto3 session. Uses the default session when *None*.
|
|
63
|
+
|
|
64
|
+
Returns
|
|
65
|
+
-------
|
|
66
|
+
pd.DataFrame
|
|
67
|
+
Columns: ``file_name``, ``md5``, ``file_size``, ``s3_url``, ``baseid``.
|
|
68
|
+
"""
|
|
69
|
+
session = boto3_session or boto3.Session()
|
|
70
|
+
s3_client = session.client("s3")
|
|
71
|
+
|
|
72
|
+
records: List[Dict[str, Any]] = []
|
|
73
|
+
|
|
74
|
+
for prefix in s3_paths:
|
|
75
|
+
logger.info("Scanning S3 path: %s", prefix)
|
|
76
|
+
file_uris = wr.s3.list_objects(
|
|
77
|
+
prefix, boto3_session=session
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
total = len(file_uris)
|
|
81
|
+
for i, uri in enumerate(file_uris, 1):
|
|
82
|
+
bucket, key = _parse_s3(uri)
|
|
83
|
+
file_name = key.rsplit("/", 1)[-1] if "/" in key else key
|
|
84
|
+
logger.info(
|
|
85
|
+
"[S3 scan %d/%d] %s", i, total, file_name
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
head = s3_client.head_object(Bucket=bucket, Key=key)
|
|
90
|
+
except Exception as e:
|
|
91
|
+
logger.error("Failed to head object %s: %s", uri, e)
|
|
92
|
+
continue
|
|
93
|
+
|
|
94
|
+
md5 = (head.get("ETag") or "").strip('"')
|
|
95
|
+
file_size = head.get("ContentLength", 0)
|
|
96
|
+
|
|
97
|
+
records.append(
|
|
98
|
+
{
|
|
99
|
+
"file_name": file_name,
|
|
100
|
+
"md5": md5,
|
|
101
|
+
"file_size": file_size,
|
|
102
|
+
"s3_url": uri,
|
|
103
|
+
"baseid": filename_to_baseid(file_name),
|
|
104
|
+
}
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
if not records:
|
|
108
|
+
return pd.DataFrame(
|
|
109
|
+
columns=["file_name", "md5", "file_size",
|
|
110
|
+
"s3_url", "baseid"]
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
return pd.DataFrame(records)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# ---------- indexd registration ----------
|
|
117
|
+
|
|
118
|
+
def register_files_with_indexd(
|
|
119
|
+
index: Gen3Index,
|
|
120
|
+
df: pd.DataFrame,
|
|
121
|
+
authz: List[str],
|
|
122
|
+
) -> pd.DataFrame:
|
|
123
|
+
"""Register files with Gen3 indexd and return results.
|
|
124
|
+
|
|
125
|
+
For each row in *df*, calls ``Gen3Index.create_record`` with the
|
|
126
|
+
file's hashes, size, S3 URL, baseid, and authz. Re-submitting
|
|
127
|
+
the same baseid will create a new revision in indexd, making this
|
|
128
|
+
safe to re-run (idempotent at the API level).
|
|
129
|
+
|
|
130
|
+
Parameters
|
|
131
|
+
----------
|
|
132
|
+
index : Gen3Index
|
|
133
|
+
An authenticated Gen3Index client.
|
|
134
|
+
df : pd.DataFrame
|
|
135
|
+
Must contain columns: ``file_name``, ``md5``, ``file_size``,
|
|
136
|
+
``s3_url``, ``baseid``.
|
|
137
|
+
authz : list[str]
|
|
138
|
+
Authz paths for the files (e.g.
|
|
139
|
+
``["/programs/program1/projects/EDCAD-PMS"]``).
|
|
140
|
+
|
|
141
|
+
Returns
|
|
142
|
+
-------
|
|
143
|
+
pd.DataFrame
|
|
144
|
+
A copy of *df* with additional columns: ``did``, ``rev``,
|
|
145
|
+
``registered_at``. Rows that failed are excluded.
|
|
146
|
+
"""
|
|
147
|
+
results: List[Dict[str, Any]] = []
|
|
148
|
+
tz = pytz.timezone("Australia/Melbourne")
|
|
149
|
+
|
|
150
|
+
total = len(df)
|
|
151
|
+
for i, (_, row) in enumerate(df.iterrows(), 1):
|
|
152
|
+
baseid = row["baseid"]
|
|
153
|
+
file_name = row["file_name"]
|
|
154
|
+
logger.info(
|
|
155
|
+
"[indexd upload %d/%d] %s", i, total, file_name
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
try:
|
|
159
|
+
resp = index.create_record(
|
|
160
|
+
hashes={"md5": row["md5"]},
|
|
161
|
+
size=int(row["file_size"]),
|
|
162
|
+
urls=[row["s3_url"]],
|
|
163
|
+
urls_metadata={row["s3_url"]: {}},
|
|
164
|
+
file_name=file_name,
|
|
165
|
+
baseid=baseid,
|
|
166
|
+
authz=authz,
|
|
167
|
+
)
|
|
168
|
+
logger.info(
|
|
169
|
+
"Registered %s → did=%s baseid=%s",
|
|
170
|
+
file_name, resp.get("did"), resp.get("baseid"),
|
|
171
|
+
)
|
|
172
|
+
results.append(
|
|
173
|
+
{
|
|
174
|
+
**row.to_dict(),
|
|
175
|
+
"did": resp["did"],
|
|
176
|
+
"rev": resp["rev"],
|
|
177
|
+
"registered_at": datetime.now(tz).isoformat(),
|
|
178
|
+
}
|
|
179
|
+
)
|
|
180
|
+
except Exception as e:
|
|
181
|
+
logger.error("Failed to register %s: %s", file_name, e)
|
|
182
|
+
|
|
183
|
+
if not results:
|
|
184
|
+
cols = list(df.columns) + ["did", "rev", "registered_at"]
|
|
185
|
+
return pd.DataFrame(columns=cols)
|
|
186
|
+
|
|
187
|
+
return pd.DataFrame(results)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# ---------- Glue / Iceberg persistence ----------
|
|
191
|
+
|
|
192
|
+
def write_to_glue(
|
|
193
|
+
df: pd.DataFrame,
|
|
194
|
+
database: str,
|
|
195
|
+
table: str,
|
|
196
|
+
athena_s3_output: str,
|
|
197
|
+
table_location: str,
|
|
198
|
+
workgroup: str = "primary",
|
|
199
|
+
partition_cols: Optional[List[str]] = None,
|
|
200
|
+
merge_cols: Optional[List[str]] = None,
|
|
201
|
+
schema_evolution: bool = False,
|
|
202
|
+
boto3_session: Optional[boto3.Session] = None,
|
|
203
|
+
) -> None:
|
|
204
|
+
"""Write a DataFrame to a Glue Iceberg table.
|
|
205
|
+
|
|
206
|
+
Thin wrapper around :func:`write_iceberg_to_db` that provides a
|
|
207
|
+
consistent interface for the indexd module.
|
|
208
|
+
|
|
209
|
+
Parameters
|
|
210
|
+
----------
|
|
211
|
+
df : pd.DataFrame
|
|
212
|
+
Data to write.
|
|
213
|
+
database : str
|
|
214
|
+
Glue database name.
|
|
215
|
+
table : str
|
|
216
|
+
Iceberg table name.
|
|
217
|
+
athena_s3_output : str
|
|
218
|
+
S3 URI for Athena query results.
|
|
219
|
+
table_location : str
|
|
220
|
+
S3 path for Iceberg table data files.
|
|
221
|
+
workgroup : str
|
|
222
|
+
Athena workgroup. Defaults to ``"primary"``.
|
|
223
|
+
partition_cols : list[str], optional
|
|
224
|
+
Columns to partition the Iceberg table by.
|
|
225
|
+
merge_cols : list[str], optional
|
|
226
|
+
Columns to use for MERGE INTO (upsert) semantics.
|
|
227
|
+
schema_evolution : bool
|
|
228
|
+
If True, allow schema evolution for new columns.
|
|
229
|
+
boto3_session : boto3.Session, optional
|
|
230
|
+
A boto3 session.
|
|
231
|
+
"""
|
|
232
|
+
write_iceberg_to_db(
|
|
233
|
+
df=df,
|
|
234
|
+
database=database,
|
|
235
|
+
table=table,
|
|
236
|
+
athena_s3_output=athena_s3_output,
|
|
237
|
+
workgroup=workgroup,
|
|
238
|
+
table_location=table_location,
|
|
239
|
+
partition_cols=partition_cols,
|
|
240
|
+
merge_cols=merge_cols,
|
|
241
|
+
schema_evolution=schema_evolution,
|
|
242
|
+
boto3_session=boto3_session,
|
|
243
|
+
)
|
|
244
|
+
logger.info("Wrote %d rows to %s.%s", len(df), database, table)
|