deployml-core 0.0.58__tar.gz → 0.0.59__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. {deployml_core-0.0.58 → deployml_core-0.0.59}/PKG-INFO +1 -1
  2. {deployml_core-0.0.58 → deployml_core-0.0.59}/pyproject.toml +1 -1
  3. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/cli/cli.py +153 -6
  4. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/docker/fastapi/Dockerfile +3 -2
  5. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/docker/fastapi/main.py +3 -3
  6. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/docker/mlflow/Dockerfile +3 -2
  7. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/templates/gcp/cloud_run/mlflow_main.tf.j2 +31 -38
  8. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/terraform/modules/bigquery/cloud/gcp/main.tf +4 -0
  9. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/terraform/modules/cloud_sql_postgres/main.tf +3 -4
  10. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/terraform/modules/mlflow/cloud/gcp/cloud_run/main.tf +8 -1
  11. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/utils/helpers.py +49 -87
  12. {deployml_core-0.0.58 → deployml_core-0.0.59}/LICENSE +0 -0
  13. {deployml_core-0.0.58 → deployml_core-0.0.59}/README.md +0 -0
  14. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/__init__.py +0 -0
  15. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/api.py +0 -0
  16. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/cli/__init__.py +0 -0
  17. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/diagnostics/__init__.py +0 -0
  18. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/diagnostics/doctor.py +0 -0
  19. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/docker/fastapi/.dockerignore +0 -0
  20. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/docker/fastapi/requirements.txt +0 -0
  21. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/docker/grafana-container/Dockerfile +0 -0
  22. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/docker/grafana-container/README.md +0 -0
  23. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/docker/grafana-container/entrypoint.sh +0 -0
  24. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/docker/mlflow/requirements.txt +0 -0
  25. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/enum/__init__.py +0 -0
  26. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/enum/cloud_provider.py +0 -0
  27. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/enum/deployment_type.py +0 -0
  28. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/notebook/__init__.py +0 -0
  29. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/notebook/deployment.py +0 -0
  30. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/notebook/display.py +0 -0
  31. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/notebook/docker.py +0 -0
  32. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/notebook/stack.py +0 -0
  33. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/notebook/urls.py +0 -0
  34. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/notebook.py +0 -0
  35. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/templates/gcp/cloud_run/base_main.tf.j2 +0 -0
  36. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/templates/gcp/cloud_run/main.tf.j2 +0 -0
  37. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/templates/gcp/cloud_run/terraform.tfvars.j2 +0 -0
  38. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/templates/gcp/cloud_run/variables.tf.j2 +0 -0
  39. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/templates/gcp/cloud_run/wandb_main.tf.j2 +0 -0
  40. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/templates/gcp/cloud_vm/main.tf.j2 +0 -0
  41. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/templates/gcp/cloud_vm/terraform.tfvars.j2 +0 -0
  42. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/templates/gcp/cloud_vm/variables.tf.j2 +0 -0
  43. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/templates/kubernetes_local/deployment.yaml.j2 +0 -0
  44. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/templates/kubernetes_local/mlflow-deployment.yaml.j2 +0 -0
  45. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/templates/kubernetes_local/mlflow-service.yaml.j2 +0 -0
  46. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/templates/kubernetes_local/service.yaml.j2 +0 -0
  47. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/terraform/modules/bigquery/cloud/gcp/outputs.tf +0 -0
  48. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/terraform/modules/bigquery/cloud/gcp/schemas/drift_metrics.json +0 -0
  49. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/terraform/modules/bigquery/cloud/gcp/schemas/ground_truth.json +0 -0
  50. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/terraform/modules/bigquery/cloud/gcp/schemas/offline_features.json +0 -0
  51. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/terraform/modules/bigquery/cloud/gcp/schemas/predictions.json +0 -0
  52. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/terraform/modules/bigquery/cloud/gcp/variables.tf +0 -0
  53. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/terraform/modules/cloud_sql_postgres/outputs.tf +0 -0
  54. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/terraform/modules/cloud_sql_postgres/variables.tf +0 -0
  55. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/terraform/modules/cron/cloud/gcp/cloud_run/main.tf +0 -0
  56. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/terraform/modules/cron/cloud/gcp/cloud_run/outputs.tf +0 -0
  57. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/terraform/modules/cron/cloud/gcp/cloud_run/variables.tf +0 -0
  58. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/terraform/modules/explainability_monitoring/cloud/gcp/cloud_run/main.tf +0 -0
  59. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/terraform/modules/explainability_monitoring/cloud/gcp/cloud_run/outputs.tf +0 -0
  60. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/terraform/modules/explainability_monitoring/cloud/gcp/cloud_run/variables.tf +0 -0
  61. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/terraform/modules/fairness_monitoring/cloud/gcp/cloud_run/main.tf +0 -0
  62. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/terraform/modules/fairness_monitoring/cloud/gcp/cloud_run/outputs.tf +0 -0
  63. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/terraform/modules/fairness_monitoring/cloud/gcp/cloud_run/variables.tf +0 -0
  64. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/terraform/modules/fastapi/cloud/gcp/cloud_run/main.tf +0 -0
  65. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/terraform/modules/fastapi/cloud/gcp/cloud_run/outputs.tf +0 -0
  66. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/terraform/modules/fastapi/cloud/gcp/cloud_run/variables.tf +0 -0
  67. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/terraform/modules/feast/cloud/gcp/cloud_run/main.tf +0 -0
  68. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/terraform/modules/feast/cloud/gcp/cloud_run/outputs.tf +0 -0
  69. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/terraform/modules/feast/cloud/gcp/cloud_run/variables.tf +0 -0
  70. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/terraform/modules/feast/cloud/gcp/cloud_vm/feast_env.tpl +0 -0
  71. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/terraform/modules/feast/cloud/gcp/cloud_vm/main.tf +0 -0
  72. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/terraform/modules/feast/cloud/gcp/cloud_vm/outputs.tf +0 -0
  73. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/terraform/modules/feast/cloud/gcp/cloud_vm/variables.tf +0 -0
  74. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/terraform/modules/grafana/cloud/gcp/cloud_run/main.tf +0 -0
  75. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/terraform/modules/grafana/cloud/gcp/cloud_run/outputs.tf +0 -0
  76. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/terraform/modules/grafana/cloud/gcp/cloud_run/variables.tf +0 -0
  77. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/terraform/modules/mlflow/cloud/gcp/cloud_run/outputs.tf +0 -0
  78. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/terraform/modules/mlflow/cloud/gcp/cloud_run/variables.tf +0 -0
  79. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/terraform/modules/mlflow/cloud/gcp/cloud_vm/main.tf +0 -0
  80. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/terraform/modules/mlflow/cloud/gcp/cloud_vm/variables.tf +0 -0
  81. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/terraform/modules/offline_scoring/cloud/gcp/cloud_run/main.tf +0 -0
  82. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/terraform/modules/offline_scoring/cloud/gcp/cloud_run/outputs.tf +0 -0
  83. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/terraform/modules/offline_scoring/cloud/gcp/cloud_run/variables.tf +0 -0
  84. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/terraform/modules/teardown/cloud/gcp/cloud_function/main.py +0 -0
  85. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/terraform/modules/teardown/cloud/gcp/cloud_function/requirements.txt +0 -0
  86. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/terraform/modules/teardown/cloud/gcp/cloudbuild.yaml +0 -0
  87. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/terraform/modules/teardown/cloud/gcp/main.tf +0 -0
  88. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/terraform/modules/teardown/cloud/gcp/outputs.tf +0 -0
  89. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/terraform/modules/teardown/cloud/gcp/teardown_script.sh +0 -0
  90. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/terraform/modules/teardown/cloud/gcp/variables.tf +0 -0
  91. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/terraform/modules/wandb/cloud/gcp/cloud_run/main.tf +0 -0
  92. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/terraform/modules/wandb/cloud/gcp/cloud_run/outputs.tf +0 -0
  93. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/terraform/modules/wandb/cloud/gcp/cloud_run/variables.tf +0 -0
  94. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/terraform/modules/wandb/cloud/gcp/cloud_vm/main.tf +0 -0
  95. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/terraform/modules/wandb/cloud/gcp/cloud_vm/variables.tf +0 -0
  96. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/utils/__init__.py +0 -0
  97. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/utils/banner.py +0 -0
  98. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/utils/constants.py +0 -0
  99. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/utils/infracost.py +0 -0
  100. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/utils/kubernetes_gke.py +0 -0
  101. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/utils/kubernetes_local.py +0 -0
  102. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/utils/menu.py +0 -0
  103. {deployml_core-0.0.58 → deployml_core-0.0.59}/src/deployml/utils/teardown.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: deployml-core
3
- Version: 0.0.58
3
+ Version: 0.0.59
4
4
  Summary: Infra for academia
5
5
  License-File: LICENSE
6
6
  Author: Drew Hoang
@@ -1,7 +1,7 @@
1
1
 
2
2
  [project]
3
3
  name = "deployml-core"
4
- version = "0.0.58"
4
+ version = "0.0.59"
5
5
  description = "Infra for academia"
6
6
  authors = [
7
7
  {name = "Drew Hoang", email = "codentell@gmail.com"},
@@ -788,6 +788,9 @@ def deploy(
788
788
  generate_only: bool = typer.Option(
789
789
  False, "--generate-only", "-g", help="Only generate manifests, do not apply (for GKE deployments)"
790
790
  ),
791
+ verbose: bool = typer.Option(
792
+ False, "--verbose", "-v", help="Stream Terraform logs to stdout instead of showing progress bar"
793
+ ),
791
794
  ):
792
795
  """
793
796
  Deploy infrastructure based on a YAML configuration file.
@@ -1027,6 +1030,34 @@ def deploy(
1027
1030
 
1028
1031
  typer.echo(f" Unified bucket creation: {create_artifact_bucket}")
1029
1032
 
1033
+ # Auto-resolve image URIs: if a service image is not specified in the config (or still
1034
+ # points to a legacy gcr.io path), derive it from the Artifact Registry path that
1035
+ # `deployml build-images` produces: {region}-docker.pkg.dev/{project}/mlops-images/{name}
1036
+ # This keeps the YAML config free of hardcoded image URIs — project/region are enough.
1037
+ _TOOL_IMAGE_NAMES = {
1038
+ "mlflow": "mlflow",
1039
+ "feast": "feast",
1040
+ "fastapi": "fastapi",
1041
+ "grafana": "grafana-container",
1042
+ "wandb": "wandb",
1043
+ }
1044
+ _ar_base = f"{region}-docker.pkg.dev/{project_id}/mlops-images"
1045
+ for stage in stack:
1046
+ for stage_name, tool in stage.items():
1047
+ tool_name = tool.get("name", "")
1048
+ params = tool.setdefault("params", {})
1049
+ existing_image = params.get("image", "")
1050
+ if not existing_image or existing_image.startswith("gcr.io/"):
1051
+ image_name = _TOOL_IMAGE_NAMES.get(tool_name)
1052
+ if image_name:
1053
+ params["image"] = f"{_ar_base}/{image_name}:latest"
1054
+ # Cron job images are per-job and must be set explicitly — skip here
1055
+ if stage_name == "workflow_orchestration" and tool_name == "cron":
1056
+ for job in params.get("jobs", []):
1057
+ if not job.get("image") or job.get("image", "").startswith("gcr.io/"):
1058
+ job_name = job.get("service_name", "")
1059
+ job["image"] = f"{_ar_base}/{job_name}:latest"
1060
+
1030
1061
  env = Environment(loader=FileSystemLoader(TEMPLATE_DIR))
1031
1062
  # PATCH: Use wandb_main.tf.j2 or mlflow_main.tf.j2 for cloud_run if present
1032
1063
  if deployment_type == "cloud_run":
@@ -1253,6 +1284,7 @@ def deploy(
1253
1284
  ["terraform", "apply", "-auto-approve"],
1254
1285
  DEPLOYML_TERRAFORM_DIR,
1255
1286
  minutes,
1287
+ verbose=verbose,
1256
1288
  )
1257
1289
  if result_code == 0:
1258
1290
  typer.echo(" Deployment complete!")
@@ -1421,6 +1453,84 @@ def deploy(
1421
1453
  typer.echo(" Deployment cancelled")
1422
1454
 
1423
1455
 
1456
+ @cli.command()
1457
+ def get_urls(
1458
+ config_path: Path = typer.Option(
1459
+ ..., "--config-path", "-c", help="Path to YAML config file"
1460
+ ),
1461
+ env_path: Path = typer.Option(
1462
+ Path(".env"), "--env-path", help="Path to write .env file"
1463
+ ),
1464
+ ):
1465
+ """
1466
+ Print service URLs from the last deployment and write them to a .env file.
1467
+ """
1468
+ if not config_path.exists():
1469
+ typer.echo(f" Config file not found: {config_path}")
1470
+ raise typer.Exit(code=1)
1471
+
1472
+ config = yaml.safe_load(config_path.read_text())
1473
+ workspace_name = config.get("name") or "development"
1474
+ terraform_dir = Path.cwd() / ".deployml" / workspace_name / "terraform"
1475
+
1476
+ if not terraform_dir.exists():
1477
+ typer.echo(f" No deployment found at {terraform_dir}. Run 'deployml deploy' first.")
1478
+ raise typer.Exit(code=1)
1479
+
1480
+ output_proc = subprocess.run(
1481
+ ["terraform", "output", "-json"],
1482
+ cwd=terraform_dir,
1483
+ capture_output=True,
1484
+ text=True,
1485
+ )
1486
+ if output_proc.returncode != 0:
1487
+ typer.echo(" Could not retrieve Terraform outputs. Is the stack deployed?")
1488
+ raise typer.Exit(code=1)
1489
+
1490
+ try:
1491
+ outputs = json.loads(output_proc.stdout)
1492
+ except Exception:
1493
+ typer.echo(" Failed to parse Terraform outputs.")
1494
+ raise typer.Exit(code=1)
1495
+
1496
+ if not outputs:
1497
+ typer.echo("No outputs found. Is the stack deployed?")
1498
+ raise typer.Exit(code=1)
1499
+
1500
+ typer.echo("\n DeployML Outputs:")
1501
+ env_lines = []
1502
+ for key, value in outputs.items():
1503
+ is_sensitive = value.get("sensitive", False)
1504
+ output_val = value.get("value")
1505
+ env_key = key.upper()
1506
+ if is_sensitive:
1507
+ typer.secho(f" {key}: [SENSITIVE] (value hidden)", fg=typer.colors.YELLOW)
1508
+ elif isinstance(output_val, str):
1509
+ if output_val.startswith("http://") or output_val.startswith("https://"):
1510
+ typer.secho(f" {key}: {output_val}", fg=typer.colors.BRIGHT_BLUE, bold=True)
1511
+ env_lines.append(f"{env_key}={output_val}")
1512
+ elif output_val == "":
1513
+ typer.secho(f" {key}: [No value]", fg=typer.colors.YELLOW)
1514
+ else:
1515
+ typer.echo(f" {key}: {output_val}")
1516
+ env_lines.append(f"{env_key}={output_val}")
1517
+ elif isinstance(output_val, dict):
1518
+ typer.echo(f" {key}:")
1519
+ for subkey, subval in output_val.items():
1520
+ sub_env_key = f"{env_key}_{subkey.upper()}"
1521
+ if isinstance(subval, str) and (subval.startswith("http://") or subval.startswith("https://")):
1522
+ typer.secho(f" {subkey}: {subval}", fg=typer.colors.BRIGHT_BLUE, bold=True)
1523
+ env_lines.append(f"{sub_env_key}={subval}")
1524
+ elif isinstance(subval, str) and subval:
1525
+ typer.echo(f" {subkey}: {subval}")
1526
+ env_lines.append(f"{sub_env_key}={subval}")
1527
+ else:
1528
+ typer.echo(f" {key}: {output_val}")
1529
+
1530
+ env_path.write_text("\n".join(env_lines) + "\n")
1531
+ typer.echo(f"\n .env written to {env_path.resolve()}")
1532
+
1533
+
1424
1534
  @cli.command()
1425
1535
  def destroy(
1426
1536
  config_path: Path = typer.Option(
@@ -1487,16 +1597,53 @@ def destroy(
1487
1597
  cwd=DEPLOYML_TERRAFORM_DIR,
1488
1598
  )
1489
1599
 
1490
- # Check if we have Cloud SQL resources and clean them up first
1491
- plan_result = subprocess.run(
1492
- ["terraform", "plan", "-destroy"],
1600
+ # Shut down Cloud Run services first to close any open DB connections
1601
+ # before attempting to destroy Cloud SQL — otherwise active connections
1602
+ # prevent database/user deletion and the destroy fails.
1603
+ region = config.get("provider", {}).get("region", "us-central1")
1604
+ cr_result = subprocess.run(
1605
+ ["gcloud", "run", "services", "list",
1606
+ "--project", project_id,
1607
+ "--region", region,
1608
+ "--format", "value(metadata.name)"],
1609
+ capture_output=True, text=True
1610
+ )
1611
+ if cr_result.returncode == 0:
1612
+ services = [s.strip() for s in cr_result.stdout.splitlines() if s.strip()]
1613
+ for service in services:
1614
+ typer.echo(f" Deleting Cloud Run service: {service}")
1615
+ subprocess.run(
1616
+ ["gcloud", "run", "services", "delete", service,
1617
+ "--project", project_id,
1618
+ "--region", region,
1619
+ "--quiet"],
1620
+ capture_output=True,
1621
+ )
1622
+
1623
+ # Remove Cloud SQL databases and user from Terraform state so Terraform
1624
+ # doesn't try to delete them individually — the instance deletion handles
1625
+ # that automatically, avoiding active-connection errors on destroy.
1626
+ state_result = subprocess.run(
1627
+ ["terraform", "state", "list"],
1493
1628
  cwd=DEPLOYML_TERRAFORM_DIR,
1494
1629
  capture_output=True,
1495
1630
  text=True,
1496
1631
  )
1497
-
1498
- if "google_sql_database_instance" in plan_result.stdout:
1499
- cleanup_cloud_sql_resources(DEPLOYML_TERRAFORM_DIR, project_id)
1632
+ if state_result.returncode == 0:
1633
+ resources_to_remove = [
1634
+ r.strip() for r in state_result.stdout.splitlines()
1635
+ if any(x in r for x in [
1636
+ "google_sql_database.",
1637
+ "google_sql_user.",
1638
+ ])
1639
+ ]
1640
+ for resource in resources_to_remove:
1641
+ typer.echo(f" Removing from state: {resource}")
1642
+ subprocess.run(
1643
+ ["terraform", "state", "rm", resource],
1644
+ cwd=DEPLOYML_TERRAFORM_DIR,
1645
+ capture_output=True,
1646
+ )
1500
1647
 
1501
1648
  # Build destroy command
1502
1649
  cmd = ["terraform", "destroy", "--auto-approve"]
@@ -1,5 +1,5 @@
1
1
  # Build for Linux/amd64 platform (not Apple Silicon)
2
- FROM --platform=linux/amd64 python:3.9-slim
2
+ FROM python:3.9-slim
3
3
 
4
4
  # Set working directory
5
5
  WORKDIR /app
@@ -34,4 +34,5 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
34
34
  CMD curl -f http://localhost:8000/health || exit 1
35
35
 
36
36
  # Run the application
37
- CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
37
+ # Uses PORT env var so it works on Cloud Run (PORT=8080) and locally (PORT=8000)
38
+ CMD uvicorn main:app --host 0.0.0.0 --port ${PORT:-8000}
@@ -14,9 +14,9 @@ app = FastAPI(
14
14
  version="1.0.0"
15
15
  )
16
16
 
17
- # Hardcoded MLflow configuration
18
- MLFLOW_TRACKING_URI = "http://mlflow-service:5000"
19
- MODEL_NAME = "HousingPriceModel"
17
+ # MLflow configuration — override via MLFLOW_TRACKING_URI env var
18
+ MLFLOW_TRACKING_URI = os.getenv("MLFLOW_TRACKING_URI", "http://mlflow-service:5000")
19
+ MODEL_NAME = os.getenv("MODEL_NAME", "HousingPriceModel")
20
20
 
21
21
  # Global model variable
22
22
  model = None
@@ -1,5 +1,5 @@
1
1
  # Build for Linux/amd64 platform (not Apple Silicon)
2
- FROM --platform=linux/amd64 python:3.11-slim
2
+ FROM python:3.11-slim
3
3
 
4
4
  # Set working directory
5
5
  WORKDIR /app
@@ -35,5 +35,6 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
35
35
  CMD curl -f http://localhost:5000/health || exit 1
36
36
 
37
37
  # Default command - MLflow server
38
- CMD ["mlflow", "server", "--host", "0.0.0.0", "--port", "5000"]
38
+ # Uses PORT env var so it works on Cloud Run (PORT=8080) and locally (PORT=5000)
39
+ CMD mlflow server --host 0.0.0.0 --port ${PORT:-5000} --dev
39
40
 
@@ -1,6 +1,6 @@
1
1
  {% extends "gcp/cloud_run/base_main.tf.j2" %}
2
2
 
3
- {% set flags = namespace(needs_postgres=false) %}
3
+ {% set flags = namespace(needs_postgres=false, has_grafana=false) %}
4
4
  {% for stage in stack %}
5
5
  {% for stage_name, tool in stage.items() %}
6
6
  {% if tool.name == "mlflow" and tool.params.get("backend_store_uri", "") == "postgresql" %}
@@ -9,6 +9,9 @@
9
9
  {% if tool.name == "feast" and tool.params.get("backend_store_uri", "") == "postgresql" %}
10
10
  {% set flags.needs_postgres = true %}
11
11
  {% endif %}
12
+ {% if stage_name == "model_monitoring" and tool.name == "grafana" %}
13
+ {% set flags.has_grafana = true %}
14
+ {% endif %}
12
15
  {% endfor %}
13
16
  {% endfor %}
14
17
 
@@ -44,6 +47,8 @@ module "cloud_sql_postgres" {
44
47
  db_instance_name = "mlflow-postgres-{{ project_id }}"
45
48
  db_name = "mlflow"
46
49
  db_user = "mlflow"
50
+ # Create the metrics DB when Grafana is in the stack so it has a backend to connect to
51
+ create_metrics_db = {{ "true" if flags.has_grafana else "false" }}
47
52
  }
48
53
 
49
54
 
@@ -288,69 +293,57 @@ module "{{ stage_name }}_{{ tool.name }}" {
288
293
  {% endif %}
289
294
  {% endfor %}
290
295
  {% endfor %}
296
+ module "bigquery" {
297
+ source = "./modules/bigquery/cloud/gcp"
298
+ project_id = var.project_id
299
+ region = var.region
300
+ dataset_id = "mlops"
301
+ }
291
302
  {% endblock %}
292
303
 
293
304
  {% block outputs %}
305
+ # MLflow — single URL and bucket regardless of how many stack stages use it
306
+ {% set mlflow_stage = namespace(found=false, name="") %}
294
307
  {% for stage in stack %}
295
308
  {% for stage_name, tool in stage.items() %}
296
- {% if tool.name == "mlflow" %}
297
- output "{{ stage_name }}_mlflow_url" {
298
- value = var.enable_{{ stage_name }}_mlflow && length(module.{{ stage_name }}_mlflow) > 0 ? module.{{ stage_name }}_mlflow[0].service_url : ""
299
- }
300
- output "{{ stage_name }}_mlflow_bucket" {
301
- value = var.enable_{{ stage_name }}_mlflow && length(module.{{ stage_name }}_mlflow) > 0 ? module.{{ stage_name }}_mlflow[0].bucket_name : ""
302
- }
309
+ {% if tool.name == "mlflow" and not mlflow_stage.found %}
310
+ {% set mlflow_stage.found = true %}
311
+ {% set mlflow_stage.name = stage_name %}
303
312
  {% endif %}
304
313
  {% endfor %}
305
314
  {% endfor %}
306
- {% if create_artifact_bucket %}
307
- {% for stage in stack %}
308
- {% for stage_name, tool in stage.items() %}
309
- {% if tool.name == "mlflow" and tool.params.get("artifact_bucket") %}
310
- output "{{ stage_name }}_mlflow_artifact_bucket" {
311
- value = google_storage_bucket.{{ stage_name }}_mlflow_artifact.name
315
+ {% if mlflow_stage.found %}
316
+ output "mlflow_url" {
317
+ value = var.enable_{{ mlflow_stage.name }}_mlflow && length(module.{{ mlflow_stage.name }}_mlflow) > 0 ? module.{{ mlflow_stage.name }}_mlflow[0].service_url : ""
318
+ }
319
+ output "artifact_bucket" {
320
+ value = var.enable_{{ mlflow_stage.name }}_mlflow && length(module.{{ mlflow_stage.name }}_mlflow) > 0 ? module.{{ mlflow_stage.name }}_mlflow[0].bucket_name : ""
312
321
  }
313
- {% endif %}
314
- {% endfor %}
315
- {% endfor %}
316
322
  {% endif %}
317
323
  {% if flags.needs_postgres %}
318
- output "instance_connection_name" {
319
- value = module.cloud_sql_postgres.instance_connection_name
320
- }
321
324
  output "postgresql_credentials" {
322
325
  value = module.cloud_sql_postgres.postgresql_credentials
323
326
  sensitive = true
324
327
  }
325
328
  {% endif %}
329
+ output "bigquery_dataset" {
330
+ value = module.bigquery.dataset_id
331
+ }
326
332
 
327
- # Output for Grafana if present
328
333
  {% for stage in stack %}
329
334
  {% for stage_name, tool in stage.items() %}
330
- {% if stage_name == "model_monitoring" and tool.name == "grafana" %}
331
- output "{{ stage_name }}_{{ tool.name }}_url" {
335
+ {% if stage_name == "model_serving" and tool.name == "fastapi" %}
336
+ output "fastapi_url" {
332
337
  value = length(module.{{ stage_name }}_{{ tool.name }}) > 0 ? module.{{ stage_name }}_{{ tool.name }}[0].service_url : ""
333
338
  }
334
339
  {% endif %}
335
- {% endfor %}
336
- {% endfor %}
337
-
338
- # Output for FastAPI model serving if present
339
- {% for stage in stack %}
340
- {% for stage_name, tool in stage.items() %}
341
- {% if stage_name == "model_serving" and tool.name == "fastapi" %}
342
- output "{{ stage_name }}_{{ tool.name }}_url" {
340
+ {% if stage_name == "model_monitoring" and tool.name == "grafana" %}
341
+ output "grafana_url" {
343
342
  value = length(module.{{ stage_name }}_{{ tool.name }}) > 0 ? module.{{ stage_name }}_{{ tool.name }}[0].service_url : ""
344
343
  }
345
344
  {% endif %}
346
- {% endfor %}
347
- {% endfor %}
348
-
349
- # Output for Feast feature store if present
350
- {% for stage in stack %}
351
- {% for stage_name, tool in stage.items() %}
352
345
  {% if stage_name == "feature_store" and tool.name == "feast" %}
353
- output "{{ stage_name }}_{{ tool.name }}_url" {
346
+ output "feast_url" {
354
347
  value = length(module.{{ stage_name }}_{{ tool.name }}) > 0 ? module.{{ stage_name }}_{{ tool.name }}[0].service_url : ""
355
348
  }
356
349
  {% endif %}
@@ -10,6 +10,7 @@ resource "google_bigquery_dataset" "mlops" {
10
10
  project = var.project_id
11
11
 
12
12
  schema = file("${path.module}/schemas/drift_metrics.json")
13
+ deletion_protection = false
13
14
  }
14
15
 
15
16
  resource "google_bigquery_table" "ground_truth" {
@@ -18,6 +19,7 @@ resource "google_bigquery_table" "ground_truth" {
18
19
  project = var.project_id
19
20
 
20
21
  schema = file("${path.module}/schemas/ground_truth.json")
22
+ deletion_protection = false
21
23
  }
22
24
 
23
25
  resource "google_bigquery_table" "offline_features" {
@@ -26,6 +28,7 @@ resource "google_bigquery_table" "offline_features" {
26
28
  project = var.project_id
27
29
 
28
30
  schema = file("${path.module}/schemas/offline_features.json")
31
+ deletion_protection = false
29
32
  }
30
33
 
31
34
  resource "google_bigquery_table" "predictions" {
@@ -34,5 +37,6 @@ resource "google_bigquery_table" "predictions" {
34
37
  project = var.project_id
35
38
 
36
39
  schema = file("${path.module}/schemas/predictions.json")
40
+ deletion_protection = false
37
41
  }
38
42
 
@@ -90,7 +90,7 @@ resource "google_sql_database" "feast_db" {
90
90
  instance = google_sql_database_instance.postgres.name
91
91
  project = var.project_id
92
92
  depends_on = [null_resource.verify_instance_running]
93
-
93
+
94
94
  lifecycle {
95
95
  ignore_changes = [name]
96
96
  }
@@ -102,7 +102,7 @@ resource "google_sql_database" "metrics_db" {
102
102
  instance = google_sql_database_instance.postgres.name
103
103
  project = var.project_id
104
104
  depends_on = [null_resource.verify_instance_running]
105
-
105
+
106
106
  lifecycle {
107
107
  ignore_changes = [name]
108
108
  }
@@ -114,9 +114,8 @@ resource "google_sql_user" "users" {
114
114
  password = random_password.db_password.result
115
115
  project = var.project_id
116
116
  depends_on = [null_resource.verify_instance_running]
117
-
117
+
118
118
  lifecycle {
119
- # Retry on failure in case instance is still starting
120
119
  create_before_destroy = true
121
120
  }
122
121
  }
@@ -38,11 +38,18 @@ resource "google_cloud_run_service" "mlflow" {
38
38
  name = "MLFLOW_SERVER_HOST"
39
39
  value = "0.0.0.0"
40
40
  }
41
-
41
+
42
42
  env {
43
43
  name = "MLFLOW_SERVER_PORT"
44
44
  value = "8080"
45
45
  }
46
+
47
+ # Allow all host headers — required for Cloud Run since the Host header
48
+ # is the dynamic *.run.app URL which MLflow's DNS rebinding check rejects by default
49
+ env {
50
+ name = "MLFLOW_SERVER_ALLOWED_HOSTS"
51
+ value = "*"
52
+ }
46
53
 
47
54
  # Backend store URI
48
55
  dynamic "env" {
@@ -99,9 +99,19 @@ def copy_modules_to_workspace(
99
99
  if teardown_enabled:
100
100
  used_modules.add("teardown")
101
101
 
102
+ # BigQuery is always included — provides the mlops dataset and tables
103
+ used_modules.add("bigquery")
104
+
102
105
  # Only copy the modules that are being used, and only the specific deployment type
103
106
  for module_path in MODULE_TEMPLATES_DIR.iterdir():
104
107
  if module_path.is_dir() and module_path.name in used_modules:
108
+ # Special case: always copy full bigquery module
109
+ if module_path.name == "bigquery":
110
+ dest_module_path = modules_dir / module_path.name
111
+ if dest_module_path.exists():
112
+ shutil.rmtree(dest_module_path)
113
+ shutil.copytree(module_path, dest_module_path)
114
+ continue
105
115
  # Special case: always copy full cloud_sql_postgres module
106
116
  if module_path.name == "cloud_sql_postgres":
107
117
  dest_module_path = modules_dir / module_path.name
@@ -257,97 +267,38 @@ def estimate_terraform_time(plan_output: str, operation: str = "apply") -> str:
257
267
 
258
268
  def cleanup_cloud_sql_resources(terraform_dir: Path, project_id: str):
259
269
  """
260
- Clean up Cloud SQL database and user before destroying the instance.
270
+ Terminate all Cloud SQL connections before destroy so Terraform can cleanly
271
+ delete databases and users. We just restart the instance — that kills all
272
+ active connections — and let Terraform handle the actual resource deletion.
261
273
  """
262
274
  import subprocess
263
275
  import time as _time
264
276
 
265
277
  try:
266
- # Get the instance name from terraform state
267
278
  result = subprocess.run(
268
279
  ["terraform", "output", "-raw", "instance_connection_name"],
269
280
  cwd=terraform_dir,
270
281
  capture_output=True,
271
282
  text=True,
272
283
  )
273
- if result.returncode == 0:
274
- instance_connection_name = result.stdout.strip()
275
- # Extract instance name from connection name (format: project:region:instance)
276
- parts = instance_connection_name.split(":")
277
- instance_name = parts[2] if len(parts) == 3 else instance_connection_name
278
-
279
- print("🗄️ Cleaning up Cloud SQL resources (terminate connections, drop DBs, drop users)...")
280
-
281
- # Restart instance to terminate all connections (most reliable non-interactive method)
282
- restart_cmd = [
283
- "gcloud",
284
- "sql",
285
- "instances",
286
- "restart",
287
- instance_name,
288
- "--project",
289
- project_id,
290
- "--quiet",
291
- ]
292
- subprocess.run(restart_cmd, capture_output=True, text=True)
293
- _time.sleep(5)
294
-
295
- # List existing databases to avoid failing on non-existent ones
296
- list_db_cmd = [
297
- "gcloud",
298
- "sql",
299
- "databases",
300
- "list",
301
- "--instance",
302
- instance_name,
303
- "--project",
304
- project_id,
305
- "--format=value(name)",
306
- ]
307
- list_proc = subprocess.run(list_db_cmd, capture_output=True, text=True)
308
- existing_dbs = set(list_proc.stdout.strip().splitlines()) if list_proc.returncode == 0 else set()
309
-
310
- # Attempt to delete known course DBs if present
311
- target_dbs = ["mlflow", "feast", "metrics"]
312
- for db_name in target_dbs:
313
- if db_name in existing_dbs:
314
- delete_db_cmd = [
315
- "gcloud",
316
- "sql",
317
- "databases",
318
- "delete",
319
- db_name,
320
- "--instance",
321
- instance_name,
322
- "--project",
323
- project_id,
324
- "--quiet",
325
- ]
326
- # Retry a few times in case connections are still draining
327
- for attempt in range(3):
328
- proc = subprocess.run(delete_db_cmd, capture_output=True, text=True)
329
- if proc.returncode == 0:
330
- break
331
- _time.sleep(5)
332
-
333
- # Drop common users after DBs are removed
334
- target_users = ["mlflow", "feast", "metrics"]
335
- for user in target_users:
336
- drop_user_cmd = [
337
- "gcloud",
338
- "sql",
339
- "users",
340
- "delete",
341
- user,
342
- "--instance",
343
- instance_name,
344
- "--project",
345
- project_id,
346
- "--quiet",
347
- ]
348
- subprocess.run(drop_user_cmd, capture_output=True, text=True)
349
-
350
- print("✅ Cloud SQL cleanup completed (best-effort)")
284
+ if result.returncode != 0:
285
+ return
286
+
287
+ instance_connection_name = result.stdout.strip()
288
+ parts = instance_connection_name.split(":")
289
+ instance_name = parts[2] if len(parts) == 3 else instance_connection_name
290
+
291
+ print(f"🗄️ Restarting Cloud SQL instance to close active connections: {instance_name}")
292
+ subprocess.run(
293
+ ["gcloud", "sql", "instances", "restart", instance_name,
294
+ "--project", project_id, "--quiet"],
295
+ capture_output=True,
296
+ text=True,
297
+ )
298
+ # Give the instance time to fully restart and drop all connections
299
+ _time.sleep(30)
300
+ print("✅ Cloud SQL connections cleared")
301
+
351
302
  except Exception as e:
352
303
  print(f"⚠️ Cloud SQL cleanup failed (continuing with destroy): {e}")
353
304
 
@@ -377,7 +328,7 @@ def cleanup_terraform_files(terraform_dir: Path):
377
328
  print("✅ Cleanup completed")
378
329
 
379
330
 
380
- def run_terraform_with_loading_bar(cmd, cwd, estimated_minutes, stack=None):
331
+ def run_terraform_with_loading_bar(cmd, cwd, estimated_minutes, stack=None, verbose=False):
381
332
  """
382
333
  Run a subprocess command with a loading bar using rich.progress.
383
334
  Progress messages are based on the stack/resources from the YAML config if provided.
@@ -413,7 +364,18 @@ def run_terraform_with_loading_bar(cmd, cwd, estimated_minutes, stack=None):
413
364
 
414
365
  # Log output to file for debugging
415
366
  log_file = cwd / "terraform_apply.log"
416
-
367
+
368
+ if verbose:
369
+ with open(log_file, "w") as f:
370
+ process = subprocess.Popen(
371
+ cmd, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True
372
+ )
373
+ for line in iter(process.stdout.readline, ""):
374
+ print(line, end="", flush=True)
375
+ f.write(line)
376
+ returncode = process.wait()
377
+ return returncode
378
+
417
379
  with Progress(
418
380
  SpinnerColumn(),
419
381
  TextColumn("[progress.description]{task.description}"),
@@ -422,7 +384,7 @@ def run_terraform_with_loading_bar(cmd, cwd, estimated_minutes, stack=None):
422
384
  TimeElapsedColumn(),
423
385
  ) as progress:
424
386
  task = progress.add_task(resource_msgs[0], total=100)
425
-
387
+
426
388
  # Open log file and keep it open until process completes
427
389
  f = open(log_file, "w")
428
390
  try:
@@ -441,7 +403,7 @@ def run_terraform_with_loading_bar(cmd, cwd, estimated_minutes, stack=None):
441
403
  # If we exceed estimated time, slowly approach 95%
442
404
  excess_time = elapsed - estimated_seconds
443
405
  progress_percent = min(95, 85 + int(excess_time / 30)) # +1% per 30 seconds
444
-
406
+
445
407
  # Choose message based on progress
446
408
  msg_idx = min(
447
409
  int(progress_percent / (100 / (n_msgs - 1))), n_msgs - 2
@@ -451,17 +413,17 @@ def run_terraform_with_loading_bar(cmd, cwd, estimated_minutes, stack=None):
451
413
  task, completed=progress_percent, description=message
452
414
  )
453
415
  time.sleep(1)
454
-
416
+
455
417
  # Wait for process to fully complete and flush all output
456
418
  returncode = process.wait()
457
419
  f.flush() # Ensure all output is written
458
-
420
+
459
421
  # Only show 100% if returncode is 0 (success)
460
422
  if returncode == 0:
461
423
  progress.update(task, completed=100, description=resource_msgs[-1])
462
424
  else:
463
425
  progress.update(task, completed=progress_percent, description=f"⚠️ Terraform apply returned code {returncode}")
464
-
426
+
465
427
  return returncode
466
428
  finally:
467
429
  f.close() # Always close the file
File without changes
File without changes