datacommons-admin 0.0.1__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.
- datacommons_admin/__init__.py +1 -0
- datacommons_admin/admin_cli.py +553 -0
- datacommons_admin/infra_templates.py +75 -0
- datacommons_admin/ingest_cli.py +142 -0
- datacommons_admin/ingestion_helper_client.py +108 -0
- datacommons_admin/ingestion_job_client.py +145 -0
- datacommons_admin/tf_utils.py +134 -0
- datacommons_admin/version.py +15 -0
- datacommons_admin-0.0.1.dist-info/METADATA +15 -0
- datacommons_admin-0.0.1.dist-info/RECORD +12 -0
- datacommons_admin-0.0.1.dist-info/WHEEL +5 -0
- datacommons_admin-0.0.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CLI package for administering Data Commons instances in GCP."""
|
|
@@ -0,0 +1,553 @@
|
|
|
1
|
+
# Copyright 2026 Google LLC.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
import re
|
|
17
|
+
import sys
|
|
18
|
+
import urllib.request
|
|
19
|
+
from typing import Any, Tuple
|
|
20
|
+
|
|
21
|
+
import click
|
|
22
|
+
from google.api_core import exceptions
|
|
23
|
+
from google.cloud import storage
|
|
24
|
+
|
|
25
|
+
from datacommons_admin.infra_templates import (
|
|
26
|
+
BACKEND_TF_TEMPLATE,
|
|
27
|
+
README_TEMPLATE,
|
|
28
|
+
REMOTE_STATE_TEMPLATE,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
DEFAULT_BUCKET_LOCATION = "US"
|
|
33
|
+
GITHUB_RAW_BASE_URL = "https://raw.githubusercontent.com/datacommonsorg/datacommons"
|
|
34
|
+
GITHUB_REPO_URL = "https://github.com/datacommonsorg/datacommons.git"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _get_default_bucket_name(namespace: str, project_id: str) -> str:
|
|
38
|
+
"""Returns the default Google Cloud Storage bucket name for Terraform state."""
|
|
39
|
+
return f"tf-state-{namespace}-{project_id}"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _get_default_state_prefix(namespace: str) -> str:
|
|
43
|
+
"""Returns the default Google Cloud Storage object prefix for Terraform state."""
|
|
44
|
+
return f"terraform/state/{namespace}"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _log_resolved_value(label: str, value: str, is_default: bool, indent: int = 2):
|
|
48
|
+
"""Logs a value with a bullet if default, or a green checkmark if from flag."""
|
|
49
|
+
prefix = " " * indent
|
|
50
|
+
padded_label = label.ljust(12)
|
|
51
|
+
if is_default:
|
|
52
|
+
click.echo(f"{prefix}- {padded_label}: {value} (Default)")
|
|
53
|
+
else:
|
|
54
|
+
click.secho(f"{prefix}✔", fg="green", nl=False)
|
|
55
|
+
click.echo(f" {padded_label}: {value} (from flag)")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _prompt(text: str, indent: int = 2, **kwargs):
|
|
59
|
+
"""Prints the cyan [?] prompt symbol and calls click.prompt."""
|
|
60
|
+
click.secho(" " * indent + "[?]", fg="cyan", bold=True, nl=False)
|
|
61
|
+
prompt_text = text if text.startswith(" ") else f" {text}"
|
|
62
|
+
return click.prompt(prompt_text, **kwargs)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _confirm(text: str, indent: int = 2, **kwargs):
|
|
66
|
+
"""Prints the cyan [?] prompt symbol and calls click.confirm."""
|
|
67
|
+
click.secho(" " * indent + "[?]", fg="cyan", bold=True, nl=False)
|
|
68
|
+
prompt_text = text if text.startswith(" ") else f" {text}"
|
|
69
|
+
return click.confirm(prompt_text, **kwargs)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _create_and_configure_bucket(
|
|
73
|
+
storage_client,
|
|
74
|
+
bucket_name: str,
|
|
75
|
+
project_id: str,
|
|
76
|
+
location: str = DEFAULT_BUCKET_LOCATION,
|
|
77
|
+
) -> bool:
|
|
78
|
+
"""Prompts and creates a Google Cloud Storage bucket, enables versioning, and sets IAM policy.
|
|
79
|
+
|
|
80
|
+
Returns True if created, False if cancelled.
|
|
81
|
+
"""
|
|
82
|
+
click.echo(f" - {'Status'.ljust(12)}: Not found")
|
|
83
|
+
click.echo(f" - {'Project'.ljust(12)}: {project_id}")
|
|
84
|
+
_log_resolved_value("Location", location, location == DEFAULT_BUCKET_LOCATION)
|
|
85
|
+
|
|
86
|
+
if not _confirm("Create this bucket?", default=True):
|
|
87
|
+
return False
|
|
88
|
+
|
|
89
|
+
click.secho(
|
|
90
|
+
f" Creating bucket gs://{bucket_name} in project {project_id} with location {location}...",
|
|
91
|
+
fg="bright_black",
|
|
92
|
+
)
|
|
93
|
+
new_bucket = storage_client.create_bucket(bucket_name, location=location)
|
|
94
|
+
new_bucket.iam_configuration.uniform_bucket_level_access_enabled = True
|
|
95
|
+
new_bucket.versioning_enabled = True
|
|
96
|
+
new_bucket.patch()
|
|
97
|
+
click.secho(f" Enabling versioning...", fg="bright_black")
|
|
98
|
+
click.secho(" Configuring bucket IAM policy...", fg="bright_black")
|
|
99
|
+
policy = new_bucket.get_iam_policy(requested_policy_version=3)
|
|
100
|
+
policy["roles/storage.objectAdmin"].add(f"projectEditor:{project_id}")
|
|
101
|
+
policy["roles/storage.objectAdmin"].add(f"projectOwner:{project_id}")
|
|
102
|
+
new_bucket.set_iam_policy(policy)
|
|
103
|
+
return True
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _abort_bucket_setup(is_default: bool):
|
|
107
|
+
click.secho(
|
|
108
|
+
" No bucket available for remote Terraform state storage. Cancelling setup.",
|
|
109
|
+
fg="red",
|
|
110
|
+
)
|
|
111
|
+
if is_default:
|
|
112
|
+
click.secho(
|
|
113
|
+
" Hint: Use --no-tf-remote-state to keep Terraform state local, or --tf-state-bucket to override the bucket name. See --help for more.",
|
|
114
|
+
fg="bright_black",
|
|
115
|
+
)
|
|
116
|
+
# Use sys.exit(1) instead of click.Abort() to avoid being caught by broad
|
|
117
|
+
# except Exception blocks in the caller and to avoid Click's "Aborted!" message.
|
|
118
|
+
sys.exit(1)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _ensure_bucket_ready(
|
|
122
|
+
storage_client,
|
|
123
|
+
bucket_name: str,
|
|
124
|
+
project_id: str,
|
|
125
|
+
location: str = DEFAULT_BUCKET_LOCATION,
|
|
126
|
+
is_default: bool = False,
|
|
127
|
+
) -> bool:
|
|
128
|
+
"""Checks if bucket exists and is ready to use, or creates it if missing.
|
|
129
|
+
|
|
130
|
+
Returns True if the bucket is ready to use, False if the user wants to try another name.
|
|
131
|
+
"""
|
|
132
|
+
try:
|
|
133
|
+
bucket = storage_client.get_bucket(bucket_name)
|
|
134
|
+
click.echo(f" - {'Status'.ljust(12)}: Found")
|
|
135
|
+
# Only prompt to reuse if it was the default bucket
|
|
136
|
+
if is_default:
|
|
137
|
+
if not _confirm("Use this bucket?", default=True):
|
|
138
|
+
_abort_bucket_setup(is_default)
|
|
139
|
+
else:
|
|
140
|
+
click.echo(" Proceeding...")
|
|
141
|
+
return True
|
|
142
|
+
except exceptions.NotFound:
|
|
143
|
+
if _create_and_configure_bucket(
|
|
144
|
+
storage_client, bucket_name, project_id, location
|
|
145
|
+
):
|
|
146
|
+
return True
|
|
147
|
+
else:
|
|
148
|
+
_abort_bucket_setup(is_default)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _configure_remote_state(
|
|
152
|
+
project_id: str,
|
|
153
|
+
namespace: str,
|
|
154
|
+
bucket_name: str = "",
|
|
155
|
+
location: str = DEFAULT_BUCKET_LOCATION,
|
|
156
|
+
) -> str:
|
|
157
|
+
"""Handles Google Cloud Storage state bucket verification, creation, and IAM setup."""
|
|
158
|
+
try:
|
|
159
|
+
storage_client = storage.Client(project=project_id)
|
|
160
|
+
except Exception as e:
|
|
161
|
+
raise click.ClickException(
|
|
162
|
+
f"Failed to initialize Google Cloud Storage client for project '{project_id}': {e}. "
|
|
163
|
+
"Ensure you are authenticated via 'gcloud auth application-default login'."
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
is_default = False
|
|
167
|
+
if not bucket_name:
|
|
168
|
+
bucket_name = _get_default_bucket_name(namespace, project_id)
|
|
169
|
+
is_default = True
|
|
170
|
+
|
|
171
|
+
click.echo(
|
|
172
|
+
"Setting up Google Cloud Storage bucket for storing terraform state remotely:"
|
|
173
|
+
)
|
|
174
|
+
_log_resolved_value("Name", bucket_name, is_default)
|
|
175
|
+
|
|
176
|
+
try:
|
|
177
|
+
ready = _ensure_bucket_ready(
|
|
178
|
+
storage_client, bucket_name, project_id, location, is_default
|
|
179
|
+
)
|
|
180
|
+
except exceptions.Unauthorized as e:
|
|
181
|
+
raise click.ClickException(
|
|
182
|
+
f"Authentication failed: {e}\n"
|
|
183
|
+
"Please ensure you are authenticated. Run 'gcloud auth application-default login' and try again."
|
|
184
|
+
)
|
|
185
|
+
except exceptions.Forbidden as e:
|
|
186
|
+
raise click.ClickException(
|
|
187
|
+
f"Permission denied: {e}\n"
|
|
188
|
+
f"Please ensure your account has 'Storage Admin' or 'Project Editor' permissions in project '{project_id}'."
|
|
189
|
+
)
|
|
190
|
+
except click.Abort:
|
|
191
|
+
click.echo("")
|
|
192
|
+
sys.exit(1)
|
|
193
|
+
except Exception as e:
|
|
194
|
+
click.secho(
|
|
195
|
+
f" Error: Failed to access or create bucket gs://{bucket_name}.",
|
|
196
|
+
fg="red",
|
|
197
|
+
bold=True,
|
|
198
|
+
)
|
|
199
|
+
click.secho(f" {e}", fg="red")
|
|
200
|
+
raise click.ClickException("Setup cancelled.")
|
|
201
|
+
|
|
202
|
+
if not ready:
|
|
203
|
+
raise click.ClickException("Setup cancelled.")
|
|
204
|
+
return bucket_name
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _get_github_templates(ref: str) -> tuple[str, str, str, str]:
|
|
208
|
+
"""Fetches variables.tf, main.tf, outputs.tf, and terraform.tfvars.template from GitHub for the given ref."""
|
|
209
|
+
base_url = f"{GITHUB_RAW_BASE_URL}/{ref}/infra/dcp"
|
|
210
|
+
|
|
211
|
+
def fetch(filename: str) -> str:
|
|
212
|
+
url = f"{base_url}/{filename}"
|
|
213
|
+
|
|
214
|
+
req = urllib.request.Request(url, headers={"User-Agent": "DataCommons-CLI"})
|
|
215
|
+
with urllib.request.urlopen(req, timeout=10) as response:
|
|
216
|
+
return response.read().decode("utf-8")
|
|
217
|
+
|
|
218
|
+
return (
|
|
219
|
+
fetch("variables.tf"),
|
|
220
|
+
fetch("main.tf"),
|
|
221
|
+
fetch("outputs.tf"),
|
|
222
|
+
fetch("terraform.tfvars.template"),
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
@click.group()
|
|
227
|
+
def admin() -> None:
|
|
228
|
+
"""Manage a Data Commons Platform instance in Google Cloud"""
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _resolve_project_config(
|
|
232
|
+
project_id: str, namespace: str, force: bool
|
|
233
|
+
) -> Tuple[str, str, Path]:
|
|
234
|
+
"""Resolves project ID and namespace, and determines target directory."""
|
|
235
|
+
if project_id:
|
|
236
|
+
_log_resolved_value("Project ID", project_id, is_default=False)
|
|
237
|
+
if namespace:
|
|
238
|
+
_log_resolved_value("Namespace", namespace, is_default=False)
|
|
239
|
+
|
|
240
|
+
resolved_project_id = project_id.strip()
|
|
241
|
+
if not resolved_project_id:
|
|
242
|
+
resolved_project_id = _prompt(
|
|
243
|
+
"Google Cloud Platform project ID", type=str
|
|
244
|
+
).strip()
|
|
245
|
+
if not resolved_project_id:
|
|
246
|
+
raise click.ClickException("GCP project ID must not be empty.")
|
|
247
|
+
|
|
248
|
+
resolved_namespace = namespace.strip()
|
|
249
|
+
while True:
|
|
250
|
+
if not resolved_namespace:
|
|
251
|
+
resolved_namespace = _prompt("Namespace", type=str).strip()
|
|
252
|
+
if not resolved_namespace:
|
|
253
|
+
click.secho("Error: Namespace must not be empty.", fg="red")
|
|
254
|
+
continue
|
|
255
|
+
|
|
256
|
+
target_dir = Path.cwd() / resolved_namespace
|
|
257
|
+
if target_dir.exists() and not force:
|
|
258
|
+
click.secho(
|
|
259
|
+
f"Error: Folder '{resolved_namespace}' already exists locally. "
|
|
260
|
+
"Please specify a different namespace, or use --force to overwrite.",
|
|
261
|
+
fg="yellow",
|
|
262
|
+
)
|
|
263
|
+
resolved_namespace = ""
|
|
264
|
+
continue
|
|
265
|
+
|
|
266
|
+
break
|
|
267
|
+
|
|
268
|
+
return resolved_project_id, resolved_namespace, target_dir
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _check_existing_files(target_dir: Path, use_remote_state: bool, force: bool):
|
|
272
|
+
"""Checks if target files already exist and raises error if they do (and not force)."""
|
|
273
|
+
main_tf_path = target_dir / "main.tf"
|
|
274
|
+
tfvars_path = target_dir / "terraform.tfvars"
|
|
275
|
+
readme_path = target_dir / "README.md"
|
|
276
|
+
backend_tf_path = target_dir / "backend.tf"
|
|
277
|
+
|
|
278
|
+
paths_to_check = [main_tf_path, tfvars_path, readme_path]
|
|
279
|
+
if use_remote_state:
|
|
280
|
+
paths_to_check.append(backend_tf_path)
|
|
281
|
+
|
|
282
|
+
existing_paths = [path for path in paths_to_check if path.exists()]
|
|
283
|
+
if existing_paths and not force:
|
|
284
|
+
existing_labels = ", ".join(str(path) for path in existing_paths)
|
|
285
|
+
raise click.ClickException(
|
|
286
|
+
f"Refusing to overwrite existing file(s): {existing_labels}. "
|
|
287
|
+
"Use --force to overwrite."
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _setup_dcp_config_dir(
|
|
292
|
+
target_dir: Path,
|
|
293
|
+
project_id: str,
|
|
294
|
+
namespace: str,
|
|
295
|
+
bucket_name: str,
|
|
296
|
+
tf_state_prefix: str,
|
|
297
|
+
dc_api_key: str,
|
|
298
|
+
ref: str,
|
|
299
|
+
use_remote_state: bool,
|
|
300
|
+
):
|
|
301
|
+
"""Downloads and populates Terraform templates."""
|
|
302
|
+
|
|
303
|
+
api_key = dc_api_key.strip()
|
|
304
|
+
if not api_key:
|
|
305
|
+
api_key = _prompt(
|
|
306
|
+
"Data Commons API key (from apikeys.datacommons.org)",
|
|
307
|
+
type=str,
|
|
308
|
+
default="",
|
|
309
|
+
show_default=False,
|
|
310
|
+
).strip()
|
|
311
|
+
|
|
312
|
+
if not api_key:
|
|
313
|
+
click.secho(
|
|
314
|
+
" [!] Warning: Data Commons API key was skipped. You must add it to terraform.tfvars before running terraform apply.",
|
|
315
|
+
fg="yellow",
|
|
316
|
+
bold=True,
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
320
|
+
click.secho(f"Creating directory: {target_dir}", fg="bright_black")
|
|
321
|
+
|
|
322
|
+
try:
|
|
323
|
+
variables_content, main_content, outputs_content, tfvars_example = (
|
|
324
|
+
_get_github_templates(ref)
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
# Update the stack module source to point to GitHub
|
|
328
|
+
resolved_source = f"git::{GITHUB_REPO_URL}//infra/dcp/modules/stack?ref={ref}"
|
|
329
|
+
main_content = re.sub(
|
|
330
|
+
r'source\s*=\s*["\']\./modules/stack["\']',
|
|
331
|
+
f'source = "{resolved_source}"',
|
|
332
|
+
main_content,
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
# Write the files
|
|
336
|
+
(target_dir / "variables.tf").write_text(variables_content, encoding="utf-8")
|
|
337
|
+
(target_dir / "main.tf").write_text(main_content, encoding="utf-8")
|
|
338
|
+
(target_dir / "outputs.tf").write_text(outputs_content, encoding="utf-8")
|
|
339
|
+
|
|
340
|
+
# Modify tfvars_example with actual values
|
|
341
|
+
tfvars_content = tfvars_example
|
|
342
|
+
tfvars_content = tfvars_content.replace('"$$PROJECT_ID$$"', f'"{project_id}"')
|
|
343
|
+
tfvars_content = tfvars_content.replace('"$$NAMESPACE$$"', f'"{namespace}"')
|
|
344
|
+
if api_key:
|
|
345
|
+
tfvars_content = tfvars_content.replace('"$$DC_API_KEY$$"', f'"{api_key}"')
|
|
346
|
+
|
|
347
|
+
(target_dir / "terraform.tfvars").write_text(tfvars_content, encoding="utf-8")
|
|
348
|
+
|
|
349
|
+
except Exception as e:
|
|
350
|
+
raise click.ClickException(f"Failed to initialize Terraform templates: {e}")
|
|
351
|
+
|
|
352
|
+
remote_state_info = ""
|
|
353
|
+
if use_remote_state and bucket_name:
|
|
354
|
+
remote_state_info = REMOTE_STATE_TEMPLATE.format(
|
|
355
|
+
bucket_name=bucket_name, prefix=tf_state_prefix
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
(target_dir / "README.md").write_text(
|
|
359
|
+
README_TEMPLATE.format(remote_state_section=remote_state_info), encoding="utf-8"
|
|
360
|
+
)
|
|
361
|
+
if use_remote_state and bucket_name:
|
|
362
|
+
(target_dir / "backend.tf").write_text(
|
|
363
|
+
BACKEND_TF_TEMPLATE.format(
|
|
364
|
+
bucket_name=bucket_name,
|
|
365
|
+
prefix=tf_state_prefix,
|
|
366
|
+
),
|
|
367
|
+
encoding="utf-8",
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
click.secho("Downloaded and populated Terraform templates.", fg="bright_black")
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
@admin.command()
|
|
374
|
+
@click.option(
|
|
375
|
+
"--project-id",
|
|
376
|
+
default="",
|
|
377
|
+
help="Google Cloud Platform project ID used for all resources related to your Data Commons instance.",
|
|
378
|
+
)
|
|
379
|
+
@click.option(
|
|
380
|
+
"--namespace", default="", help="Namespace prefix for provisioned resources."
|
|
381
|
+
)
|
|
382
|
+
@click.option("--dc-api-key", default="", help="Data Commons API key.")
|
|
383
|
+
@click.option(
|
|
384
|
+
"--ref", default="main", show_default=True, help="Git ref for module source."
|
|
385
|
+
)
|
|
386
|
+
@click.option(
|
|
387
|
+
"--force", is_flag=True, help="Overwrite existing generated files if present."
|
|
388
|
+
)
|
|
389
|
+
@click.option(
|
|
390
|
+
"--tf-remote-state/--no-tf-remote-state",
|
|
391
|
+
default=True,
|
|
392
|
+
help="Enable or disable Terraform remote state management in Google Cloud Storage. Disabling ignores other --tf-state-* flags.",
|
|
393
|
+
)
|
|
394
|
+
@click.option(
|
|
395
|
+
"--tf-state-bucket",
|
|
396
|
+
default="",
|
|
397
|
+
help="Google Cloud Storage bucket for Terraform remote state. Generates a default name if omitted. Prompts to create the bucket if it is missing.",
|
|
398
|
+
)
|
|
399
|
+
@click.option(
|
|
400
|
+
"--tf-state-bucket-location",
|
|
401
|
+
default=DEFAULT_BUCKET_LOCATION,
|
|
402
|
+
show_default=True,
|
|
403
|
+
help="Google Cloud Storage bucket location if a new bucket needs to be created.",
|
|
404
|
+
)
|
|
405
|
+
@click.option(
|
|
406
|
+
"--tf-state-prefix",
|
|
407
|
+
default="",
|
|
408
|
+
help="Google Cloud Storage object prefix for Terraform state file (default: terraform/state/{namespace}).",
|
|
409
|
+
)
|
|
410
|
+
def init(
|
|
411
|
+
project_id: str,
|
|
412
|
+
namespace: str,
|
|
413
|
+
dc_api_key: str,
|
|
414
|
+
ref: str,
|
|
415
|
+
force: bool,
|
|
416
|
+
tf_remote_state: bool,
|
|
417
|
+
tf_state_bucket: str,
|
|
418
|
+
tf_state_bucket_location: str,
|
|
419
|
+
tf_state_prefix: str,
|
|
420
|
+
) -> None:
|
|
421
|
+
"""Initialize Terraform scaffolding for Data Commons administration/infrastructure."""
|
|
422
|
+
click.secho("Data Commons Admin Init", fg="cyan", bold=True)
|
|
423
|
+
|
|
424
|
+
# 1. Project Configs
|
|
425
|
+
click.secho("\n[Project Configuration]", fg="cyan", bold=True)
|
|
426
|
+
click.secho("Configuring project settings...", fg="bright_black")
|
|
427
|
+
resolved_project_id, resolved_namespace, target_dir = _resolve_project_config(
|
|
428
|
+
project_id, namespace, force
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
# 2. Terraform Setup
|
|
432
|
+
click.secho("\n[Terraform Backend Setup]", fg="cyan", bold=True)
|
|
433
|
+
click.secho("Configuring backend for Terraform state...", fg="bright_black")
|
|
434
|
+
if not tf_remote_state:
|
|
435
|
+
click.echo(" Using local backend for Terraform state.")
|
|
436
|
+
|
|
437
|
+
# Refuse to overwrite existing files unless --force is specified
|
|
438
|
+
_check_existing_files(target_dir, tf_remote_state, force)
|
|
439
|
+
|
|
440
|
+
resolved_bucket_name = (
|
|
441
|
+
_configure_remote_state(
|
|
442
|
+
resolved_project_id,
|
|
443
|
+
resolved_namespace,
|
|
444
|
+
tf_state_bucket,
|
|
445
|
+
tf_state_bucket_location,
|
|
446
|
+
)
|
|
447
|
+
if tf_remote_state
|
|
448
|
+
else ""
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
resolved_tf_state_prefix = tf_state_prefix.strip() or _get_default_state_prefix(
|
|
452
|
+
resolved_namespace
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
# 3. DCP config dir setup
|
|
456
|
+
click.secho("\n[DCP Configuration]", fg="cyan", bold=True)
|
|
457
|
+
click.secho("Setting up configuration files...", fg="bright_black")
|
|
458
|
+
_setup_dcp_config_dir(
|
|
459
|
+
target_dir,
|
|
460
|
+
resolved_project_id,
|
|
461
|
+
resolved_namespace,
|
|
462
|
+
resolved_bucket_name,
|
|
463
|
+
resolved_tf_state_prefix,
|
|
464
|
+
dc_api_key,
|
|
465
|
+
ref,
|
|
466
|
+
tf_remote_state,
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
click.secho(
|
|
470
|
+
f"Customize variables in {resolved_namespace}/terraform.tfvars as needed.",
|
|
471
|
+
fg="green",
|
|
472
|
+
)
|
|
473
|
+
click.secho(
|
|
474
|
+
f"Refer to {resolved_namespace}/README.md for more info and next steps.",
|
|
475
|
+
fg="green",
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _setup_ingestion_client() -> Tuple[Any, str, str]:
|
|
480
|
+
click.secho(
|
|
481
|
+
"Fetching Ingestion Helper URI, Orchestrator Service Account, and Spanner details from Terraform outputs...",
|
|
482
|
+
fg="bright_black",
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
from datacommons_admin.tf_utils import (
|
|
486
|
+
get_dcp_ingestion_helper_uri,
|
|
487
|
+
get_dcp_orchestrator_service_account_email,
|
|
488
|
+
get_dcp_spanner_instance_id,
|
|
489
|
+
get_dcp_spanner_database_id,
|
|
490
|
+
)
|
|
491
|
+
from datacommons_admin.ingestion_helper_client import IngestionHelperClient
|
|
492
|
+
|
|
493
|
+
uri = get_dcp_ingestion_helper_uri()
|
|
494
|
+
sa_email = get_dcp_orchestrator_service_account_email()
|
|
495
|
+
instance_id = get_dcp_spanner_instance_id()
|
|
496
|
+
database_id = get_dcp_spanner_database_id()
|
|
497
|
+
|
|
498
|
+
click.secho(f"Found Ingestion Helper URI: {uri}", fg="green")
|
|
499
|
+
click.secho(f"Found Orchestrator Service Account: {sa_email}", fg="green")
|
|
500
|
+
click.secho(
|
|
501
|
+
f"Found Spanner Database Instance: {instance_id} / Database ID: {database_id}",
|
|
502
|
+
fg="green",
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
client = IngestionHelperClient(uri, service_account_email=sa_email)
|
|
506
|
+
return client, instance_id, database_id
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _run_seed_db(client: Any, instance_id: str, database_id: str) -> None:
|
|
510
|
+
click.secho(
|
|
511
|
+
f"Seeding Spanner database '{instance_id}/{database_id}' via the Ingestion Helper service (this may take a few moments)...",
|
|
512
|
+
fg="bright_black",
|
|
513
|
+
)
|
|
514
|
+
result = client.seed_database()
|
|
515
|
+
click.secho("Successfully seeded Spanner database!", fg="green", bold=True)
|
|
516
|
+
if "message" in result:
|
|
517
|
+
click.secho(f"Details: {result['message']}", fg="bright_black")
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
@admin.command(name="init-db")
|
|
521
|
+
@click.option(
|
|
522
|
+
"--init-only", is_flag=True, help="Only initialize the database without seeding."
|
|
523
|
+
)
|
|
524
|
+
def init_db(init_only: bool) -> None:
|
|
525
|
+
"""Initialize (and by default seed) the Spanner database via the DCP Ingestion Helper service."""
|
|
526
|
+
click.secho("Datacommons Admin Init-DB", fg="cyan", bold=True)
|
|
527
|
+
client, instance_id, database_id = _setup_ingestion_client()
|
|
528
|
+
|
|
529
|
+
click.secho(
|
|
530
|
+
f"Initializing Spanner database '{instance_id}/{database_id}' via the Ingestion Helper service (this may take a few moments)...",
|
|
531
|
+
fg="bright_black",
|
|
532
|
+
)
|
|
533
|
+
result = client.initialize_database()
|
|
534
|
+
|
|
535
|
+
click.secho("Successfully initialized Spanner database!", fg="green", bold=True)
|
|
536
|
+
if "message" in result:
|
|
537
|
+
click.secho(f"Details: {result['message']}", fg="bright_black")
|
|
538
|
+
|
|
539
|
+
if not init_only:
|
|
540
|
+
_run_seed_db(client, instance_id, database_id)
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
@admin.command(name="seed-db")
|
|
544
|
+
def seed_db() -> None:
|
|
545
|
+
"""Seed the Spanner database via the DCP Ingestion Helper service."""
|
|
546
|
+
click.secho("Datacommons Admin Seed-DB", fg="cyan", bold=True)
|
|
547
|
+
client, instance_id, database_id = _setup_ingestion_client()
|
|
548
|
+
_run_seed_db(client, instance_id, database_id)
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
from datacommons_admin.ingest_cli import ingest
|
|
552
|
+
|
|
553
|
+
admin.add_command(ingest)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# Copyright 2026 Google LLC.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
REMOTE_STATE_TEMPLATE = """
|
|
17
|
+
## Remote State Management
|
|
18
|
+
|
|
19
|
+
Terraform state is configured to be stored remotely in Google Cloud Storage:
|
|
20
|
+
- **Bucket**: `gs://{bucket_name}`
|
|
21
|
+
- **Prefix**: `{prefix}`
|
|
22
|
+
|
|
23
|
+
This enables team collaboration, state locking, and automated pipeline deployments. The bucket is configured with Uniform Bucket-Level Access and object versioning.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
README_TEMPLATE = """# Data Commons Platform Terraform Setup
|
|
27
|
+
|
|
28
|
+
This directory contains Terraform configuration for a new Data Commons Platform instance.
|
|
29
|
+
|
|
30
|
+
Data Commons is an open knowledge graph for integrating and querying structured data across domains.
|
|
31
|
+
The Data Commons Platform is the deployable infrastructure stack that runs Data Commons services in your GCP project.
|
|
32
|
+
{remote_state_section}
|
|
33
|
+
## What This Terraform Creates
|
|
34
|
+
|
|
35
|
+
This setup deploys core Data Commons Platform infrastructure on GCP using the `infra/dcp` module, including Cloud Run services, Cloud Spanner resources, IAM bindings, and supporting service configuration.
|
|
36
|
+
|
|
37
|
+
## Configure Variables
|
|
38
|
+
|
|
39
|
+
Set environment-specific values in `terraform.tfvars` (for example `project_id`, `namespace`, and `dc_api_key`), and update module arguments in `main.tf` if you want to enable or tune additional features.
|
|
40
|
+
|
|
41
|
+
## Learn More About Variables
|
|
42
|
+
|
|
43
|
+
For the full list of supported module inputs and defaults, see:
|
|
44
|
+
- https://github.com/datacommonsorg/datacommons/blob/main/infra/dcp/variables.tf
|
|
45
|
+
- https://github.com/datacommonsorg/datacommons/blob/main/infra/dcp/outputs.tf
|
|
46
|
+
- https://github.com/datacommonsorg/datacommons/blob/main/infra/dcp/terraform.tfvars.template
|
|
47
|
+
- https://github.com/datacommonsorg/datacommons/blob/main/infra/dcp/README.md
|
|
48
|
+
|
|
49
|
+
## Next Steps
|
|
50
|
+
|
|
51
|
+
1. Review `terraform.tfvars` and update values if needed.
|
|
52
|
+
2. Initialize Terraform:
|
|
53
|
+
```bash
|
|
54
|
+
terraform init
|
|
55
|
+
```
|
|
56
|
+
3. Preview infrastructure changes:
|
|
57
|
+
```bash
|
|
58
|
+
terraform plan
|
|
59
|
+
```
|
|
60
|
+
4. Deploy infrastructure:
|
|
61
|
+
```bash
|
|
62
|
+
terraform apply
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Generated using the Data Commons CLI tool:
|
|
66
|
+
https://github.com/datacommonsorg/datacommons
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
BACKEND_TF_TEMPLATE = """terraform {{
|
|
70
|
+
backend "gcs" {{
|
|
71
|
+
bucket = "{bucket_name}"
|
|
72
|
+
prefix = "{prefix}"
|
|
73
|
+
}}
|
|
74
|
+
}}
|
|
75
|
+
"""
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# Copyright 2026 Google LLC.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
import click
|
|
16
|
+
import re
|
|
17
|
+
|
|
18
|
+
from datacommons_admin.ingestion_job_client import IngestionJobClient
|
|
19
|
+
from datacommons_admin.tf_utils import (
|
|
20
|
+
get_cdc_data_job_name,
|
|
21
|
+
get_dcp_orchestrator_service_account_email,
|
|
22
|
+
get_dcp_project_id,
|
|
23
|
+
get_dcp_region,
|
|
24
|
+
get_dcp_workflow_name,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@click.group(name="ingest")
|
|
29
|
+
def ingest() -> None:
|
|
30
|
+
"""Manage data ingestion jobs."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@ingest.command(name="start")
|
|
34
|
+
def start() -> None:
|
|
35
|
+
"""Start a data ingestion job execution."""
|
|
36
|
+
click.secho("Datacommons Admin Ingest Start", fg="cyan", bold=True)
|
|
37
|
+
click.secho(
|
|
38
|
+
"Fetching Data Job Name and Orchestrator Service Account from Terraform outputs...",
|
|
39
|
+
fg="bright_black",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
job_name = get_cdc_data_job_name()
|
|
43
|
+
sa_email = get_dcp_orchestrator_service_account_email()
|
|
44
|
+
project_id = get_dcp_project_id()
|
|
45
|
+
region = get_dcp_region()
|
|
46
|
+
workflow_name = get_dcp_workflow_name()
|
|
47
|
+
|
|
48
|
+
click.secho(f"Found Data Job Name: {job_name}", fg="green")
|
|
49
|
+
click.secho(f"Found Orchestrator Service Account: {sa_email}", fg="green")
|
|
50
|
+
click.secho(f"Found GCP Project ID: {project_id}", fg="green")
|
|
51
|
+
click.secho(f"Found GCP Region: {region}", fg="green")
|
|
52
|
+
click.secho(
|
|
53
|
+
f"Starting Cloud Run job '{job_name}' via Admin API (this may take a few moments)...",
|
|
54
|
+
fg="bright_black",
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
client = IngestionJobClient(
|
|
58
|
+
job_name,
|
|
59
|
+
service_account_email=sa_email,
|
|
60
|
+
project_id=project_id,
|
|
61
|
+
location=region,
|
|
62
|
+
)
|
|
63
|
+
result = client.start_job()
|
|
64
|
+
|
|
65
|
+
click.secho("Successfully started ingestion job!", fg="green", bold=True)
|
|
66
|
+
res_name = result.get("name") or result.get("metadata", {}).get("name")
|
|
67
|
+
|
|
68
|
+
if res_name:
|
|
69
|
+
op_pattern = r"projects/([^/]+)/locations/([^/]+)/operations/([^/]+)"
|
|
70
|
+
op_match = re.match(op_pattern, res_name)
|
|
71
|
+
|
|
72
|
+
if op_match:
|
|
73
|
+
click.secho(f"Operation details: {res_name}", fg="bright_black")
|
|
74
|
+
resp_project_id, location, operation_id = op_match.groups()
|
|
75
|
+
|
|
76
|
+
short_job_name = job_name.split("/")[-1] if "/" in job_name else job_name
|
|
77
|
+
job_url = f"https://console.cloud.google.com/run/jobs/details/{location}/{short_job_name}/executions?project={resp_project_id}"
|
|
78
|
+
|
|
79
|
+
click.secho("Operation ID: ", fg="cyan", bold=True, nl=False)
|
|
80
|
+
click.secho(operation_id, fg="green")
|
|
81
|
+
click.secho("Job Console Link: ", fg="cyan", bold=True, nl=False)
|
|
82
|
+
click.secho(job_url, fg="blue", underline=True)
|
|
83
|
+
else:
|
|
84
|
+
click.secho(f"Resource details: {res_name}", fg="bright_black")
|
|
85
|
+
|
|
86
|
+
click.secho("\n[!] Note on Ingestion Completion", fg="yellow", bold=True)
|
|
87
|
+
click.secho(
|
|
88
|
+
"This job triggers a Cloud Workflow that runs in the background.\n"
|
|
89
|
+
"Check the Workflows console below to verify full completion.",
|
|
90
|
+
fg="yellow",
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
workflow_url = f"https://console.cloud.google.com/workflows/workflow/{region}/{workflow_name}/executions?project={project_id}"
|
|
94
|
+
click.secho("Workflow Console Link: ", fg="cyan", bold=True, nl=False)
|
|
95
|
+
click.secho(workflow_url, fg="blue", underline=True)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@ingest.command(name="show-config")
|
|
99
|
+
def show_config() -> None:
|
|
100
|
+
"""Print the current ingestion job configuration (environment variables)."""
|
|
101
|
+
click.secho("Datacommons Admin Ingest Show-Config", fg="cyan", bold=True)
|
|
102
|
+
click.secho(
|
|
103
|
+
"Fetching Data Job Name and Orchestrator Service Account from Terraform outputs...",
|
|
104
|
+
fg="bright_black",
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
job_name = get_cdc_data_job_name()
|
|
108
|
+
sa_email = get_dcp_orchestrator_service_account_email()
|
|
109
|
+
project_id = get_dcp_project_id()
|
|
110
|
+
region = get_dcp_region()
|
|
111
|
+
|
|
112
|
+
click.secho(f"Found Data Job Name: {job_name}", fg="green")
|
|
113
|
+
click.secho(f"Found Orchestrator Service Account: {sa_email}", fg="green")
|
|
114
|
+
click.secho(f"Found GCP Project ID: {project_id}", fg="green")
|
|
115
|
+
click.secho(f"Found GCP Region: {region}", fg="green")
|
|
116
|
+
click.secho(
|
|
117
|
+
f"Fetching configuration for Cloud Run job '{job_name}'...",
|
|
118
|
+
fg="bright_black",
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
client = IngestionJobClient(
|
|
122
|
+
job_name,
|
|
123
|
+
service_account_email=sa_email,
|
|
124
|
+
project_id=project_id,
|
|
125
|
+
location=region,
|
|
126
|
+
)
|
|
127
|
+
env_vars = client.get_config()
|
|
128
|
+
|
|
129
|
+
click.secho("\nCurrent Ingestion Job Configuration:", fg="cyan", bold=True)
|
|
130
|
+
if not env_vars:
|
|
131
|
+
click.secho("No environment variables configured.", fg="yellow")
|
|
132
|
+
else:
|
|
133
|
+
for env in env_vars:
|
|
134
|
+
name = env.get("name", "UNKNOWN")
|
|
135
|
+
if "value" in env:
|
|
136
|
+
val = env["value"]
|
|
137
|
+
elif "valueSource" in env:
|
|
138
|
+
val = f"[SECRET: {env['valueSource']}]"
|
|
139
|
+
else:
|
|
140
|
+
val = "[UNSET]"
|
|
141
|
+
click.secho(f" {name}: ", fg="bright_black", nl=False)
|
|
142
|
+
click.secho(str(val), fg="green")
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# Copyright 2026 Google LLC.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
import click
|
|
16
|
+
import google.auth
|
|
17
|
+
from google.auth.transport.requests import AuthorizedSession, Request
|
|
18
|
+
from google.oauth2 import id_token
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
ACTION_INITIALIZE_DATABASE = "initialize_database"
|
|
22
|
+
ACTION_SEED_DATABASE = "seed_database"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class IngestionHelperClient:
|
|
26
|
+
"""Client for interacting with the DCP Ingestion Helper Cloud Run service."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, base_url: str, service_account_email: str = None) -> None:
|
|
29
|
+
self.base_url = base_url.rstrip("/")
|
|
30
|
+
self.auth_req = Request()
|
|
31
|
+
self.service_account_email = service_account_email
|
|
32
|
+
|
|
33
|
+
base_credentials, _ = google.auth.default()
|
|
34
|
+
|
|
35
|
+
if service_account_email:
|
|
36
|
+
from google.auth import impersonated_credentials
|
|
37
|
+
|
|
38
|
+
target_credentials = impersonated_credentials.Credentials(
|
|
39
|
+
source_credentials=base_credentials,
|
|
40
|
+
target_principal=service_account_email,
|
|
41
|
+
target_scopes=["https://www.googleapis.com/auth/cloud-platform"],
|
|
42
|
+
)
|
|
43
|
+
creds = impersonated_credentials.IDTokenCredentials(
|
|
44
|
+
target_credentials=target_credentials,
|
|
45
|
+
target_audience=self.base_url,
|
|
46
|
+
include_email=True,
|
|
47
|
+
)
|
|
48
|
+
else:
|
|
49
|
+
try:
|
|
50
|
+
token = id_token.fetch_id_token(self.auth_req, self.base_url)
|
|
51
|
+
from google.oauth2.credentials import Credentials
|
|
52
|
+
|
|
53
|
+
creds = Credentials(token)
|
|
54
|
+
except Exception as e:
|
|
55
|
+
raise click.ClickException(
|
|
56
|
+
f"Failed to fetch ID token for {self.base_url}: {e}\n"
|
|
57
|
+
"Please ensure you are authenticated or provide a service account to impersonate."
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
self.session = AuthorizedSession(creds)
|
|
61
|
+
|
|
62
|
+
def _call_endpoint(self, action_type: str) -> dict:
|
|
63
|
+
url = self.base_url
|
|
64
|
+
payload = {"actionType": action_type}
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
response = self.session.post(url, json=payload, timeout=300)
|
|
68
|
+
except Exception as e:
|
|
69
|
+
msg = f"Network or authentication error connecting to Ingestion Helper service at {url}: {e}"
|
|
70
|
+
if self.service_account_email:
|
|
71
|
+
msg += f"\nFailed to impersonate {self.service_account_email}. Please ensure your GCP user account has the 'Service Account Token Creator' (roles/iam.serviceAccountTokenCreator) IAM role."
|
|
72
|
+
raise click.ClickException(msg)
|
|
73
|
+
|
|
74
|
+
if response.status_code == 401:
|
|
75
|
+
raise click.ClickException(
|
|
76
|
+
f"HTTP 401 Unauthorized when calling Ingestion Helper at {url}.\n"
|
|
77
|
+
"Your GCP credentials were rejected. Please verify that the service account has the 'Cloud Run Invoker' (roles/run.invoker) IAM role for this service.\n"
|
|
78
|
+
"To re-authenticate, run:\n"
|
|
79
|
+
" gcloud auth application-default login"
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
if not response.ok:
|
|
83
|
+
try:
|
|
84
|
+
error_data = response.json()
|
|
85
|
+
error_msg = (
|
|
86
|
+
error_data.get("message")
|
|
87
|
+
or error_data.get("error")
|
|
88
|
+
or response.text
|
|
89
|
+
)
|
|
90
|
+
except Exception:
|
|
91
|
+
error_msg = response.text
|
|
92
|
+
|
|
93
|
+
raise click.ClickException(
|
|
94
|
+
f"Ingestion Helper returned HTTP {response.status_code}: {error_msg}"
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
return response.json()
|
|
99
|
+
except Exception:
|
|
100
|
+
return {"status": "success", "message": response.text}
|
|
101
|
+
|
|
102
|
+
def initialize_database(self) -> dict:
|
|
103
|
+
"""Calls the initialize_database endpoint on the ingestion helper service."""
|
|
104
|
+
return self._call_endpoint(ACTION_INITIALIZE_DATABASE)
|
|
105
|
+
|
|
106
|
+
def seed_database(self) -> dict:
|
|
107
|
+
"""Calls the seed_database endpoint on the ingestion helper service."""
|
|
108
|
+
return self._call_endpoint(ACTION_SEED_DATABASE)
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# Copyright 2026 Google LLC.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
import click
|
|
16
|
+
import google.auth
|
|
17
|
+
from google.auth.transport.requests import AuthorizedSession
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class IngestionJobClient:
|
|
21
|
+
"""Client for interacting with Cloud Run Admin API to manage CDC data ingestion jobs."""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
job_name: str,
|
|
26
|
+
service_account_email: str = None,
|
|
27
|
+
project_id: str = None,
|
|
28
|
+
location: str = None,
|
|
29
|
+
) -> None:
|
|
30
|
+
self.service_account_email = service_account_email
|
|
31
|
+
base_credentials, _ = google.auth.default()
|
|
32
|
+
|
|
33
|
+
if not job_name.startswith("projects/"):
|
|
34
|
+
if not project_id:
|
|
35
|
+
raise click.ClickException(
|
|
36
|
+
"Project ID must be provided via Terraform outputs or as an argument when job name is not a full resource name."
|
|
37
|
+
)
|
|
38
|
+
if not location:
|
|
39
|
+
raise click.ClickException(
|
|
40
|
+
"Location must be provided via Terraform outputs or as an argument when job name is not a full resource name."
|
|
41
|
+
)
|
|
42
|
+
self.full_job_name = (
|
|
43
|
+
f"projects/{project_id}/locations/{location}/jobs/{job_name}"
|
|
44
|
+
)
|
|
45
|
+
else:
|
|
46
|
+
self.full_job_name = job_name
|
|
47
|
+
|
|
48
|
+
if service_account_email:
|
|
49
|
+
from google.auth import impersonated_credentials
|
|
50
|
+
|
|
51
|
+
creds = impersonated_credentials.Credentials(
|
|
52
|
+
source_credentials=base_credentials,
|
|
53
|
+
target_principal=service_account_email,
|
|
54
|
+
target_scopes=["https://www.googleapis.com/auth/cloud-platform"],
|
|
55
|
+
)
|
|
56
|
+
else:
|
|
57
|
+
creds = base_credentials
|
|
58
|
+
|
|
59
|
+
self.session = AuthorizedSession(creds)
|
|
60
|
+
|
|
61
|
+
def start_job(self) -> dict:
|
|
62
|
+
"""Starts an execution of the Cloud Run job."""
|
|
63
|
+
url = f"https://run.googleapis.com/v2/{self.full_job_name}:run"
|
|
64
|
+
try:
|
|
65
|
+
response = self.session.post(url, json={}, timeout=300)
|
|
66
|
+
except Exception as e:
|
|
67
|
+
msg = f"Network or authentication error connecting to Cloud Run Admin API at {url}: {e}"
|
|
68
|
+
if self.service_account_email:
|
|
69
|
+
msg += f"\nFailed to impersonate {self.service_account_email}. Please ensure your GCP user account has the 'Service Account Token Creator' (roles/iam.serviceAccountTokenCreator) IAM role."
|
|
70
|
+
raise click.ClickException(msg)
|
|
71
|
+
|
|
72
|
+
if response.status_code == 401:
|
|
73
|
+
raise click.ClickException(
|
|
74
|
+
f"HTTP 401 Unauthorized when calling Cloud Run Admin API at {url}.\n"
|
|
75
|
+
"Your GCP credentials were rejected. Please verify your authentication.\n"
|
|
76
|
+
"To re-authenticate, run:\n"
|
|
77
|
+
" gcloud auth application-default login"
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
if not response.ok:
|
|
81
|
+
try:
|
|
82
|
+
error_data = response.json()
|
|
83
|
+
error_msg = (
|
|
84
|
+
error_data.get("message")
|
|
85
|
+
or error_data.get("error", {}).get("message")
|
|
86
|
+
or response.text
|
|
87
|
+
)
|
|
88
|
+
except Exception:
|
|
89
|
+
error_msg = response.text
|
|
90
|
+
|
|
91
|
+
raise click.ClickException(
|
|
92
|
+
f"Cloud Run Admin API returned HTTP {response.status_code}: {error_msg}"
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
return response.json()
|
|
97
|
+
except Exception:
|
|
98
|
+
return {"status": "success", "message": response.text}
|
|
99
|
+
|
|
100
|
+
def get_config(self) -> list:
|
|
101
|
+
"""Retrieves the environment variables configuration of the Cloud Run job."""
|
|
102
|
+
url = f"https://run.googleapis.com/v2/{self.full_job_name}"
|
|
103
|
+
try:
|
|
104
|
+
response = self.session.get(url, timeout=300)
|
|
105
|
+
except Exception as e:
|
|
106
|
+
msg = f"Network or authentication error connecting to Cloud Run Admin API at {url}: {e}"
|
|
107
|
+
if self.service_account_email:
|
|
108
|
+
msg += f"\nFailed to impersonate {self.service_account_email}. Please ensure your GCP user account has the 'Service Account Token Creator' (roles/iam.serviceAccountTokenCreator) IAM role."
|
|
109
|
+
raise click.ClickException(msg)
|
|
110
|
+
|
|
111
|
+
if response.status_code == 401:
|
|
112
|
+
raise click.ClickException(
|
|
113
|
+
f"HTTP 401 Unauthorized when calling Cloud Run Admin API at {url}.\n"
|
|
114
|
+
"Your GCP credentials were rejected. Please verify your authentication.\n"
|
|
115
|
+
"To re-authenticate, run:\n"
|
|
116
|
+
" gcloud auth application-default login"
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
if not response.ok:
|
|
120
|
+
try:
|
|
121
|
+
error_data = response.json()
|
|
122
|
+
error_msg = (
|
|
123
|
+
error_data.get("message")
|
|
124
|
+
or error_data.get("error", {}).get("message")
|
|
125
|
+
or response.text
|
|
126
|
+
)
|
|
127
|
+
except Exception:
|
|
128
|
+
error_msg = response.text
|
|
129
|
+
|
|
130
|
+
raise click.ClickException(
|
|
131
|
+
f"Cloud Run Admin API returned HTTP {response.status_code}: {error_msg}"
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
job_data = response.json()
|
|
136
|
+
except Exception as e:
|
|
137
|
+
raise click.ClickException(f"Failed to parse Cloud Run job response: {e}")
|
|
138
|
+
|
|
139
|
+
containers = (
|
|
140
|
+
job_data.get("template", {}).get("template", {}).get("containers", [])
|
|
141
|
+
)
|
|
142
|
+
if not containers:
|
|
143
|
+
return []
|
|
144
|
+
|
|
145
|
+
return containers[0].get("env", [])
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# Copyright 2026 Google LLC.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import shutil
|
|
17
|
+
import subprocess
|
|
18
|
+
|
|
19
|
+
import click
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
TF_OUTPUT_INGESTION_HELPER_URI = "dcp_ingestion_helper_uri"
|
|
23
|
+
TF_OUTPUT_ORCHESTRATOR_SERVICE_ACCOUNT_EMAIL = "dcp_orchestrator_service_account_email"
|
|
24
|
+
TF_OUTPUT_SPANNER_INSTANCE_ID = "dcp_spanner_instance_id"
|
|
25
|
+
TF_OUTPUT_SPANNER_DATABASE_ID = "dcp_spanner_database_id"
|
|
26
|
+
TF_OUTPUT_CDC_DATA_JOB_NAME = "cdc_data_job_name"
|
|
27
|
+
TF_OUTPUT_PROJECT_ID = "project_id"
|
|
28
|
+
TF_OUTPUT_REGION = "region"
|
|
29
|
+
TF_OUTPUT_WORKFLOW_NAME = "workflow_name"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def get_terraform_output(key: str) -> str:
|
|
33
|
+
"""Fetches a specific key from `terraform output -json` with graceful error handling."""
|
|
34
|
+
if not shutil.which("terraform"):
|
|
35
|
+
raise click.ClickException(
|
|
36
|
+
"Terraform CLI not found. Please ensure Terraform is installed and available in your PATH."
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
result = subprocess.run(
|
|
41
|
+
["terraform", "output", "-json"],
|
|
42
|
+
capture_output=True,
|
|
43
|
+
text=True,
|
|
44
|
+
check=True,
|
|
45
|
+
)
|
|
46
|
+
except subprocess.CalledProcessError as e:
|
|
47
|
+
raise click.ClickException(
|
|
48
|
+
f"Failed to run 'terraform output'. Are you in an initialized Terraform deployment directory?\n"
|
|
49
|
+
f"Error details: {e.stderr.strip() or e.stdout.strip()}"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
outputs = json.loads(result.stdout)
|
|
54
|
+
except json.JSONDecodeError:
|
|
55
|
+
raise click.ClickException(
|
|
56
|
+
"Failed to parse 'terraform output -json'. The output was not valid JSON."
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
if not outputs:
|
|
60
|
+
from pathlib import Path
|
|
61
|
+
|
|
62
|
+
cwd = Path.cwd()
|
|
63
|
+
has_tf_files = (
|
|
64
|
+
(cwd / ".terraform").exists()
|
|
65
|
+
or (cwd / "terraform.tfstate").exists()
|
|
66
|
+
or (cwd / "main.tf").exists()
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
if not has_tf_files:
|
|
70
|
+
raise click.ClickException(
|
|
71
|
+
f"No Terraform outputs found in '{cwd}'.\n"
|
|
72
|
+
"Please navigate to your initialized DCP Terraform directory (e.g., 'cd my-namespace') and ensure 'terraform apply' has been run."
|
|
73
|
+
)
|
|
74
|
+
else:
|
|
75
|
+
raise click.ClickException(
|
|
76
|
+
f"No Terraform outputs found in '{cwd}'.\n"
|
|
77
|
+
"Please ensure you have successfully run 'terraform apply' to generate the deployment state."
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
if key not in outputs:
|
|
81
|
+
from pathlib import Path
|
|
82
|
+
|
|
83
|
+
raise click.ClickException(
|
|
84
|
+
f"Terraform output key '{key}' not found in '{Path.cwd()}'.\n"
|
|
85
|
+
"Please verify that your Terraform configuration exports this output and that 'terraform apply' was fully completed."
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
value = outputs[key].get("value")
|
|
89
|
+
if not value:
|
|
90
|
+
raise click.ClickException(
|
|
91
|
+
f"Terraform output '{key}' is empty or null. Please verify your deployment state."
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
return str(value)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def get_dcp_ingestion_helper_uri() -> str:
|
|
98
|
+
"""Convenience wrapper to fetch the dcp_ingestion_helper_uri Terraform output."""
|
|
99
|
+
return get_terraform_output(TF_OUTPUT_INGESTION_HELPER_URI)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def get_dcp_orchestrator_service_account_email() -> str:
|
|
103
|
+
"""Convenience wrapper to fetch the dcp_orchestrator_service_account_email Terraform output."""
|
|
104
|
+
return get_terraform_output(TF_OUTPUT_ORCHESTRATOR_SERVICE_ACCOUNT_EMAIL)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def get_dcp_spanner_instance_id() -> str:
|
|
108
|
+
"""Convenience wrapper to fetch the dcp_spanner_instance_id Terraform output."""
|
|
109
|
+
return get_terraform_output(TF_OUTPUT_SPANNER_INSTANCE_ID)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def get_dcp_spanner_database_id() -> str:
|
|
113
|
+
"""Convenience wrapper to fetch the dcp_spanner_database_id Terraform output."""
|
|
114
|
+
return get_terraform_output(TF_OUTPUT_SPANNER_DATABASE_ID)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def get_cdc_data_job_name() -> str:
|
|
118
|
+
"""Convenience wrapper to fetch the cdc_data_job_name Terraform output."""
|
|
119
|
+
return get_terraform_output(TF_OUTPUT_CDC_DATA_JOB_NAME)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def get_dcp_project_id() -> str:
|
|
123
|
+
"""Convenience wrapper to fetch the project_id Terraform output."""
|
|
124
|
+
return get_terraform_output(TF_OUTPUT_PROJECT_ID)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def get_dcp_region() -> str:
|
|
128
|
+
"""Convenience wrapper to fetch the region Terraform output."""
|
|
129
|
+
return get_terraform_output(TF_OUTPUT_REGION)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def get_dcp_workflow_name() -> str:
|
|
133
|
+
"""Convenience wrapper to fetch the workflow_name Terraform output."""
|
|
134
|
+
return get_terraform_output(TF_OUTPUT_WORKFLOW_NAME)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Copyright 2026 Google LLC.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
__version__ = "0.0.1"
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: datacommons-admin
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Data Commons Admin CLI
|
|
5
|
+
License-Expression: Apache-2.0
|
|
6
|
+
Project-URL: Homepage, https://github.com/datacommonsorg/datacommons
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: click>=8.1.7
|
|
10
|
+
Requires-Dist: google-cloud-storage>=2.13.0
|
|
11
|
+
Requires-Dist: pyopenssl>=24.0.0
|
|
12
|
+
|
|
13
|
+
# datacommons-admin
|
|
14
|
+
|
|
15
|
+
This package provides the `admin` command group consumed by the top-level `datacommons` CLI for initializing and managing Data Commons platform instances in GCP.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
datacommons_admin/__init__.py,sha256=KFwbMH40mpcWY0d5sfAty6tGgGP6p_1ISu-_iKmCC70,67
|
|
2
|
+
datacommons_admin/admin_cli.py,sha256=LxgiMuheQkUycsdkI24u7Qecg265_hn-HA0Eo68JzJk,19614
|
|
3
|
+
datacommons_admin/infra_templates.py,sha256=ClStSytsSXgt-TkYfJU7y2bx1zCu5hsHL2QBawmzkUs,2717
|
|
4
|
+
datacommons_admin/ingest_cli.py,sha256=KtV2GTeTq1ELmW4AS1WyEybaHWP8xaH9Y5FXGNxbxOQ,5399
|
|
5
|
+
datacommons_admin/ingestion_helper_client.py,sha256=nGardf_n36GdQX0DPyIgu5rnaicAIIiAhYLFiMzBoNA,4415
|
|
6
|
+
datacommons_admin/ingestion_job_client.py,sha256=gF1KvbphgzK7eiawM_kDxwvUJdxl0jQDCEwWCxvkEoM,5851
|
|
7
|
+
datacommons_admin/tf_utils.py,sha256=vG8koG1kPZe8XjlSNGgGDMbPRFIcuhYhDljteUElaTk,4840
|
|
8
|
+
datacommons_admin/version.py,sha256=oSC2MsGURWbdZseFaniLJiHDa6kjClfg_2shID77ZBA,598
|
|
9
|
+
datacommons_admin-0.0.1.dist-info/METADATA,sha256=aHzjG8KinwUzYD8fM3sJT8M-ehyqCPEYF_EmO9I2yy4,546
|
|
10
|
+
datacommons_admin-0.0.1.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
11
|
+
datacommons_admin-0.0.1.dist-info/top_level.txt,sha256=fRm2N5tIvdOXEPJ3aA7zLXOqNb9T4wOrewI2ayx2Qro,18
|
|
12
|
+
datacommons_admin-0.0.1.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
datacommons_admin
|