dander-platform 0.1.0rc1__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.
Files changed (126) hide show
  1. dander/__init__.py +12 -0
  2. dander/bootstrap/__init__.py +35 -0
  3. dander/bootstrap/admin.py +276 -0
  4. dander/bootstrap/project.py +279 -0
  5. dander/bootstrap/terraform.py +356 -0
  6. dander/bootstrap/verify.py +986 -0
  7. dander/catalog/README.md +48 -0
  8. dander/catalog/__init__.py +42 -0
  9. dander/catalog/dataplex.py +210 -0
  10. dander/catalog/publisher.py +21 -0
  11. dander/catalog/registry.py +31 -0
  12. dander/catalog/spine.py +252 -0
  13. dander/catalog/store.py +213 -0
  14. dander/cli/__init__.py +1 -0
  15. dander/cli/main.py +1581 -0
  16. dander/concurrency.py +78 -0
  17. dander/core/__init__.py +1 -0
  18. dander/core/config.py +22 -0
  19. dander/core/interfaces.py +32 -0
  20. dander/dev/__init__.py +1 -0
  21. dander/dev/synthetic_vendor.py +242 -0
  22. dander/evidence/__init__.py +19 -0
  23. dander/evidence/models.py +139 -0
  24. dander/executor.py +289 -0
  25. dander/ingestion/README.md +30 -0
  26. dander/ingestion/__init__.py +44 -0
  27. dander/ingestion/config.py +43 -0
  28. dander/ingestion/dlt_backed.py +304 -0
  29. dander/ingestion/enterprise.py +196 -0
  30. dander/ingestion/pagination.py +157 -0
  31. dander/ingestion/source.py +362 -0
  32. dander/pipeline/README.md +491 -0
  33. dander/pipeline/__init__.py +102 -0
  34. dander/pipeline/compiler.py +519 -0
  35. dander/pipeline/errors.py +248 -0
  36. dander/pipeline/graph.py +1027 -0
  37. dander/pipeline/graph_ops.py +413 -0
  38. dander/pipeline/node_config.py +358 -0
  39. dander/pipeline/request_spec.py +301 -0
  40. dander/project/__init__.py +26 -0
  41. dander/project/config.py +235 -0
  42. dander/project/scaffold.py +93 -0
  43. dander/py.typed +0 -0
  44. dander/runtime.py +449 -0
  45. dander/sandbox.py +265 -0
  46. dander/schema.py +36 -0
  47. dander/security/__init__.py +35 -0
  48. dander/security/api_bearer.py +20 -0
  49. dander/security/api_key.py +27 -0
  50. dander/security/base.py +32 -0
  51. dander/security/no_auth.py +21 -0
  52. dander/security/oauth.py +157 -0
  53. dander/security/oauth1.py +104 -0
  54. dander/security/oauth_jwt.py +172 -0
  55. dander/security/secret_manager.py +136 -0
  56. dander/state/__init__.py +37 -0
  57. dander/state/lease.py +392 -0
  58. dander/state/run_history.py +471 -0
  59. dander/state/watermark.py +257 -0
  60. dander/templates/project/.dockerignore +7 -0
  61. dander/templates/project/.gitignore +6 -0
  62. dander/templates/project/Dockerfile +19 -0
  63. dander/templates/project/README.md +13 -0
  64. dander/templates/project/connectors/greenhouse_job_board.yaml +56 -0
  65. dander/templates/project/dander.yaml +25 -0
  66. dander/templates/project/infra/.terraform.lock.hcl +43 -0
  67. dander/templates/project/infra/README.md +131 -0
  68. dander/templates/project/infra/__init__.py +1 -0
  69. dander/templates/project/infra/bootstrap-admin/.terraform.lock.hcl +22 -0
  70. dander/templates/project/infra/bootstrap-admin/README.md +74 -0
  71. dander/templates/project/infra/bootstrap-admin/main.tf +176 -0
  72. dander/templates/project/infra/bootstrap-admin/outputs.tf +24 -0
  73. dander/templates/project/infra/bootstrap-admin/variables.tf +75 -0
  74. dander/templates/project/infra/bootstrap-admin/versions.tf +19 -0
  75. dander/templates/project/infra/functions/__init__.py +1 -0
  76. dander/templates/project/infra/functions/stop_billing/__init__.py +1 -0
  77. dander/templates/project/infra/functions/stop_billing/handler.py +66 -0
  78. dander/templates/project/infra/functions/stop_billing/main.py +37 -0
  79. dander/templates/project/infra/functions/stop_billing/requirements.txt +3 -0
  80. dander/templates/project/infra/main.tf +133 -0
  81. dander/templates/project/infra/modules/bigquery/main.tf +13 -0
  82. dander/templates/project/infra/modules/bigquery/outputs.tf +4 -0
  83. dander/templates/project/infra/modules/bigquery/variables.tf +16 -0
  84. dander/templates/project/infra/modules/cost-guard/README.md +12 -0
  85. dander/templates/project/infra/modules/cost-guard/main.tf +193 -0
  86. dander/templates/project/infra/modules/cost-guard/outputs.tf +14 -0
  87. dander/templates/project/infra/modules/cost-guard/variables.tf +45 -0
  88. dander/templates/project/infra/modules/cost-guard/versions.tf +17 -0
  89. dander/templates/project/infra/modules/github-wif/README.md +6 -0
  90. dander/templates/project/infra/modules/github-wif/main.tf +79 -0
  91. dander/templates/project/infra/modules/github-wif/outputs.tf +9 -0
  92. dander/templates/project/infra/modules/github-wif/variables.tf +30 -0
  93. dander/templates/project/infra/modules/github-wif/versions.tf +10 -0
  94. dander/templates/project/infra/modules/scheduled-job/README.md +37 -0
  95. dander/templates/project/infra/modules/scheduled-job/main.tf +374 -0
  96. dander/templates/project/infra/modules/scheduled-job/outputs.tf +48 -0
  97. dander/templates/project/infra/modules/scheduled-job/variables.tf +141 -0
  98. dander/templates/project/infra/modules/scheduled-job/versions.tf +10 -0
  99. dander/templates/project/infra/modules/secret-manager/README.md +5 -0
  100. dander/templates/project/infra/modules/secret-manager/main.tf +45 -0
  101. dander/templates/project/infra/modules/secret-manager/outputs.tf +4 -0
  102. dander/templates/project/infra/modules/secret-manager/variables.tf +25 -0
  103. dander/templates/project/infra/modules/secret-manager/versions.tf +10 -0
  104. dander/templates/project/infra/outputs.tf +37 -0
  105. dander/templates/project/infra/ownership-cutover.tf +11 -0
  106. dander/templates/project/infra/sandbox.auto.tfvars.example +35 -0
  107. dander/templates/project/infra/variables.tf +211 -0
  108. dander/templates/project/infra/versions.tf +40 -0
  109. dander/templates/project/models/staging/stg_greenhouse__jobs.sql +29 -0
  110. dander/templates/project/models/staging/stg_greenhouse__jobs.yml +50 -0
  111. dander/transform/README.md +26 -0
  112. dander/transform/__init__.py +35 -0
  113. dander/transform/config.py +179 -0
  114. dander/transform/model.py +67 -0
  115. dander/transform/project.py +184 -0
  116. dander/transform/runner.py +334 -0
  117. dander/writer/README.md +35 -0
  118. dander/writer/__init__.py +43 -0
  119. dander/writer/base.py +76 -0
  120. dander/writer/bigquery.py +913 -0
  121. dander/writer/storage_write.py +293 -0
  122. dander_platform-0.1.0rc1.dist-info/METADATA +616 -0
  123. dander_platform-0.1.0rc1.dist-info/RECORD +126 -0
  124. dander_platform-0.1.0rc1.dist-info/WHEEL +4 -0
  125. dander_platform-0.1.0rc1.dist-info/entry_points.txt +3 -0
  126. dander_platform-0.1.0rc1.dist-info/licenses/LICENSE +176 -0
dander/__init__.py ADDED
@@ -0,0 +1,12 @@
1
+ """Dander — an opinionated, GCP-native data platform (ingest + transform + catalog).
2
+
3
+ Keep this module import-light: importing ``dander`` must not require the heavy optional
4
+ dependencies. Import from the subpackages (``dander.ingestion``, ``dander.writer``, ...) directly.
5
+ """
6
+
7
+ from importlib.metadata import PackageNotFoundError, version
8
+
9
+ try:
10
+ __version__ = version("dander-platform")
11
+ except PackageNotFoundError: # pragma: no cover - only an unpackaged source tree
12
+ __version__ = "0+unknown"
@@ -0,0 +1,35 @@
1
+ """Infrastructure bootstrap adapters."""
2
+
3
+ from dander.bootstrap.admin import AdministrativeBootstrap, AdministrativeBootstrapError
4
+ from dander.bootstrap.project import (
5
+ ProjectBootstrapError,
6
+ RuntimeImagePublisher,
7
+ StateBucketBootstrap,
8
+ active_admin_member,
9
+ )
10
+ from dander.bootstrap.terraform import TerraformBootstrap, TerraformBootstrapError
11
+ from dander.bootstrap.verify import (
12
+ DeploymentSummary,
13
+ DeploymentVerificationError,
14
+ DeploymentVerifier,
15
+ VerificationCheck,
16
+ VerificationStatus,
17
+ write_summary,
18
+ )
19
+
20
+ __all__ = [
21
+ "AdministrativeBootstrap",
22
+ "AdministrativeBootstrapError",
23
+ "DeploymentSummary",
24
+ "DeploymentVerificationError",
25
+ "DeploymentVerifier",
26
+ "ProjectBootstrapError",
27
+ "RuntimeImagePublisher",
28
+ "StateBucketBootstrap",
29
+ "TerraformBootstrap",
30
+ "TerraformBootstrapError",
31
+ "VerificationCheck",
32
+ "VerificationStatus",
33
+ "write_summary",
34
+ "active_admin_member",
35
+ ]
@@ -0,0 +1,276 @@
1
+ """Stage-zero administrative Terraform bootstrap."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ import subprocess
8
+ from typing import TYPE_CHECKING
9
+
10
+ if TYPE_CHECKING:
11
+ from pathlib import Path
12
+
13
+ _PROJECT_ID = re.compile(r"^[a-z][a-z0-9-]{4,28}[a-z0-9]$")
14
+ _BUCKET_NAME = re.compile(r"^[a-z0-9][a-z0-9._-]{1,61}[a-z0-9]$")
15
+ _REGION = re.compile(r"^[a-z][a-z0-9-]{1,62}[a-z0-9]$")
16
+ _LOCATION = re.compile(r"^[A-Za-z][A-Za-z0-9-]{0,62}$")
17
+ _BILLING_ACCOUNT = re.compile(r"^[0-9A-F]{6}-[0-9A-F]{6}-[0-9A-F]{6}$")
18
+ _PRINCIPAL = re.compile(r"^(?:user|serviceAccount|group):[^\r\n]+$")
19
+ _SERVICE_ACCOUNT_ID = re.compile(r"^[a-z][a-z0-9-]{4,28}[a-z0-9]$")
20
+ _GITHUB_REPOSITORY = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
21
+ _GITHUB_REF = re.compile(r"^refs/(?:heads|tags)/[A-Za-z0-9._/-]+$")
22
+ _STAGE_ZERO_STATE_PREFIX = "dander/bootstrap-admin/state"
23
+
24
+
25
+ class AdministrativeBootstrapError(RuntimeError):
26
+ """Raised when stage-zero Terraform cannot safely complete."""
27
+
28
+
29
+ class AdministrativeBootstrap:
30
+ """Create only the state bucket and identity preconditions for platform Terraform."""
31
+
32
+ def __init__(self, infra_dir: Path, operator_artifact_dir: Path) -> None:
33
+ self._infra_dir = infra_dir.resolve()
34
+ self._repository_dir = self._infra_dir.parent.parent
35
+ self._operator_artifact_dir = operator_artifact_dir.expanduser().resolve()
36
+ if self._is_within(self._operator_artifact_dir, self._repository_dir):
37
+ raise AdministrativeBootstrapError(
38
+ "Operator artifact directory must be outside the repository checkout"
39
+ )
40
+ self._tf_data_dir = self._operator_artifact_dir / "terraform-data"
41
+ self._plan_path = self._operator_artifact_dir / "dander-admin-bootstrap.tfplan"
42
+
43
+ def execute(
44
+ self,
45
+ *,
46
+ project: str,
47
+ state_bucket: str,
48
+ admin_member: str,
49
+ apply: bool,
50
+ region: str = "us-central1",
51
+ state_location: str = "US",
52
+ bootstrap_service_account_id: str = "dander-bootstrap",
53
+ billing_account_id: str = "",
54
+ github_repository: str = "",
55
+ github_ref: str = "refs/heads/main",
56
+ adopt_state_bucket: bool = False,
57
+ ) -> Path:
58
+ """Plan stage zero and optionally apply that exact saved plan.
59
+
60
+ Args:
61
+ project: GCP project receiving the administrative resources.
62
+ state_bucket: Globally unique bucket name for platform Terraform state.
63
+ admin_member: Approved principal allowed to impersonate the bootstrap identity.
64
+ apply: Whether to apply the saved plan after planning.
65
+ region: Provider region.
66
+ state_location: GCS location for the state bucket.
67
+ bootstrap_service_account_id: Account id for the bootstrap service account.
68
+ billing_account_id: Optional billing account for budget-management access.
69
+ github_repository: Optional repository allowed to use proof-workflow WIF.
70
+ github_ref: Exact branch or tag ref allowed to use proof-workflow WIF.
71
+ adopt_state_bucket: Import a pre-created stage-zero backend bucket when absent from
72
+ state. Used only by the batteries-included ``dander init`` flow.
73
+
74
+ Returns:
75
+ Path to the saved stage-zero plan.
76
+ """
77
+ self._validate(
78
+ project=project,
79
+ state_bucket=state_bucket,
80
+ admin_member=admin_member,
81
+ region=region,
82
+ state_location=state_location,
83
+ bootstrap_service_account_id=bootstrap_service_account_id,
84
+ billing_account_id=billing_account_id,
85
+ github_repository=github_repository,
86
+ github_ref=github_ref,
87
+ )
88
+ self._prepare_operator_directories()
89
+ plan_path = self._plan_path
90
+ self._run(
91
+ "terraform",
92
+ "init",
93
+ "-reconfigure",
94
+ "-input=false",
95
+ f"-backend-config=bucket={state_bucket}",
96
+ f"-backend-config=prefix={_STAGE_ZERO_STATE_PREFIX}",
97
+ )
98
+ if adopt_state_bucket:
99
+ self._adopt_state_bucket(
100
+ project=project,
101
+ state_bucket=state_bucket,
102
+ admin_member=admin_member,
103
+ region=region,
104
+ state_location=state_location,
105
+ bootstrap_service_account_id=bootstrap_service_account_id,
106
+ billing_account_id=billing_account_id,
107
+ github_repository=github_repository,
108
+ github_ref=github_ref,
109
+ )
110
+ self._run(
111
+ "terraform",
112
+ "plan",
113
+ f"-var=project_id={project}",
114
+ f"-var=state_bucket={state_bucket}",
115
+ f"-var=admin_member={admin_member}",
116
+ f"-var=region={region}",
117
+ f"-var=state_location={state_location}",
118
+ f"-var=bootstrap_service_account_id={bootstrap_service_account_id}",
119
+ f"-var=billing_account_id={billing_account_id}",
120
+ f"-var=github_repository={github_repository}",
121
+ f"-var=github_ref={github_ref}",
122
+ f"-out={plan_path}",
123
+ )
124
+ try:
125
+ if not plan_path.is_file() or plan_path.is_symlink():
126
+ raise AdministrativeBootstrapError(
127
+ f"Terraform did not create a regular saved plan at {plan_path}"
128
+ )
129
+ plan_path.chmod(0o600)
130
+ except OSError as error:
131
+ raise AdministrativeBootstrapError(
132
+ f"Could not secure the saved Terraform plan at {plan_path}"
133
+ ) from error
134
+ if apply:
135
+ self._run("terraform", "apply", str(plan_path))
136
+ return plan_path
137
+
138
+ @staticmethod
139
+ def _is_within(candidate: Path, parent: Path) -> bool:
140
+ return candidate == parent or parent in candidate.parents
141
+
142
+ def _prepare_operator_directories(self) -> None:
143
+ try:
144
+ if self._tf_data_dir.is_symlink():
145
+ raise AdministrativeBootstrapError("terraform-data must not be a symlink")
146
+ if self._tf_data_dir.exists() and not self._tf_data_dir.is_dir():
147
+ raise AdministrativeBootstrapError("terraform-data must be a directory")
148
+ resolved_tf_data_dir = self._tf_data_dir.resolve()
149
+ if not self._is_within(resolved_tf_data_dir, self._operator_artifact_dir):
150
+ raise AdministrativeBootstrapError(
151
+ "Resolved terraform-data must remain inside the operator artifact directory"
152
+ )
153
+ if self._is_within(resolved_tf_data_dir, self._repository_dir):
154
+ raise AdministrativeBootstrapError(
155
+ "Resolved terraform-data must remain outside the repository checkout"
156
+ )
157
+ if self._plan_path.is_symlink():
158
+ raise AdministrativeBootstrapError("Saved Terraform plan must not be a symlink")
159
+ if self._plan_path.exists() and not self._plan_path.is_file():
160
+ raise AdministrativeBootstrapError("Saved Terraform plan must be a regular file")
161
+ self._operator_artifact_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
162
+ self._operator_artifact_dir.chmod(0o700)
163
+ self._tf_data_dir.mkdir(mode=0o700, exist_ok=True)
164
+ self._tf_data_dir.chmod(0o700)
165
+ if self._plan_path.exists():
166
+ self._plan_path.chmod(0o600)
167
+ self._plan_path.unlink()
168
+ except OSError as error:
169
+ raise AdministrativeBootstrapError(
170
+ "Could not create or secure the operator artifact directory"
171
+ ) from error
172
+
173
+ @staticmethod
174
+ def _validate(
175
+ *,
176
+ project: str,
177
+ state_bucket: str,
178
+ admin_member: str,
179
+ region: str,
180
+ state_location: str,
181
+ bootstrap_service_account_id: str,
182
+ billing_account_id: str,
183
+ github_repository: str,
184
+ github_ref: str,
185
+ ) -> None:
186
+ for label, value, pattern in (
187
+ ("project", project, _PROJECT_ID),
188
+ ("state bucket", state_bucket, _BUCKET_NAME),
189
+ ("admin member", admin_member, _PRINCIPAL),
190
+ ("region", region, _REGION),
191
+ ("state location", state_location, _LOCATION),
192
+ ("bootstrap service-account id", bootstrap_service_account_id, _SERVICE_ACCOUNT_ID),
193
+ ):
194
+ if not pattern.fullmatch(value):
195
+ raise AdministrativeBootstrapError(f"Invalid {label}: {value!r}")
196
+ if billing_account_id and not _BILLING_ACCOUNT.fullmatch(billing_account_id):
197
+ raise AdministrativeBootstrapError(
198
+ "Billing account must use XXXXXX-XXXXXX-XXXXXX format"
199
+ )
200
+ if github_repository and not _GITHUB_REPOSITORY.fullmatch(github_repository):
201
+ raise AdministrativeBootstrapError("Invalid GitHub repository")
202
+ if not _GITHUB_REF.fullmatch(github_ref):
203
+ raise AdministrativeBootstrapError("Invalid GitHub ref")
204
+
205
+ def _run(self, *args: str) -> None:
206
+ environment = os.environ.copy()
207
+ environment["TF_DATA_DIR"] = str(self._tf_data_dir)
208
+ try:
209
+ subprocess.run(
210
+ args,
211
+ cwd=self._infra_dir,
212
+ env=environment,
213
+ umask=0o077,
214
+ check=True,
215
+ )
216
+ except FileNotFoundError as error:
217
+ raise AdministrativeBootstrapError(
218
+ "Terraform is not installed or is not available on PATH"
219
+ ) from error
220
+ except subprocess.CalledProcessError as error:
221
+ raise AdministrativeBootstrapError(
222
+ f"Terraform command failed with exit code {error.returncode}"
223
+ ) from error
224
+
225
+ def _adopt_state_bucket(
226
+ self,
227
+ *,
228
+ project: str,
229
+ state_bucket: str,
230
+ admin_member: str,
231
+ region: str,
232
+ state_location: str,
233
+ bootstrap_service_account_id: str,
234
+ billing_account_id: str,
235
+ github_repository: str,
236
+ github_ref: str,
237
+ ) -> None:
238
+ if (
239
+ self._run_status("terraform", "state", "show", "google_storage_bucket.terraform_state")
240
+ == 0
241
+ ):
242
+ return
243
+ self._run(
244
+ "terraform",
245
+ "import",
246
+ f"-var=project_id={project}",
247
+ f"-var=state_bucket={state_bucket}",
248
+ f"-var=admin_member={admin_member}",
249
+ f"-var=region={region}",
250
+ f"-var=state_location={state_location}",
251
+ f"-var=bootstrap_service_account_id={bootstrap_service_account_id}",
252
+ f"-var=billing_account_id={billing_account_id}",
253
+ f"-var=github_repository={github_repository}",
254
+ f"-var=github_ref={github_ref}",
255
+ "google_storage_bucket.terraform_state",
256
+ state_bucket,
257
+ )
258
+
259
+ def _run_status(self, *args: str) -> int:
260
+ environment = os.environ.copy()
261
+ environment["TF_DATA_DIR"] = str(self._tf_data_dir)
262
+ try:
263
+ completed = subprocess.run(
264
+ args,
265
+ cwd=self._infra_dir,
266
+ env=environment,
267
+ umask=0o077,
268
+ check=False,
269
+ stdout=subprocess.DEVNULL,
270
+ stderr=subprocess.DEVNULL,
271
+ )
272
+ except FileNotFoundError as error:
273
+ raise AdministrativeBootstrapError(
274
+ "Terraform is not installed or is not available on PATH"
275
+ ) from error
276
+ return completed.returncode
@@ -0,0 +1,279 @@
1
+ """Batteries-included project bootstrap helpers used by ``dander init``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import re
7
+ import subprocess
8
+ from typing import TYPE_CHECKING, Protocol
9
+
10
+ if TYPE_CHECKING:
11
+ from pathlib import Path
12
+
13
+ _PROJECT_ID = re.compile(r"^[a-z][a-z0-9-]{4,28}[a-z0-9]$")
14
+ _BUCKET_NAME = re.compile(r"^[a-z0-9][a-z0-9._-]{1,61}[a-z0-9]$")
15
+ _REGION = re.compile(r"^[a-z][a-z0-9-]{1,62}[a-z0-9]$")
16
+ _ACCOUNT = re.compile(r"^[^\s@]+@[^\s@]+$")
17
+ _DIGEST = re.compile(r"^sha256:[0-9a-f]{64}$")
18
+
19
+
20
+ class ProjectBootstrapError(RuntimeError):
21
+ """Raised when the automatic state/image bootstrap cannot complete safely."""
22
+
23
+
24
+ class _Runner(Protocol):
25
+ def __call__(
26
+ self,
27
+ args: tuple[str, ...],
28
+ *,
29
+ cwd: Path,
30
+ check: bool,
31
+ capture_output: bool = False,
32
+ text: bool = False,
33
+ ) -> subprocess.CompletedProcess[str]:
34
+ """Execute one argument-vector command."""
35
+
36
+
37
+ def _subprocess_runner(
38
+ args: tuple[str, ...],
39
+ *,
40
+ cwd: Path,
41
+ check: bool,
42
+ capture_output: bool = False,
43
+ text: bool = False,
44
+ ) -> subprocess.CompletedProcess[str]:
45
+ return subprocess.run(
46
+ args,
47
+ cwd=cwd,
48
+ check=check,
49
+ capture_output=capture_output,
50
+ text=text,
51
+ )
52
+
53
+
54
+ class StateBucketBootstrap:
55
+ """Create the one stage-zero GCS backend that Terraform cannot create inside itself."""
56
+
57
+ def __init__(self, *, cwd: Path, runner: _Runner | None = None) -> None:
58
+ self._cwd = cwd.resolve()
59
+ self._runner = runner or _subprocess_runner
60
+
61
+ def ensure(
62
+ self,
63
+ *,
64
+ project: str,
65
+ bucket: str,
66
+ location: str,
67
+ apply: bool,
68
+ ) -> bool:
69
+ """Ensure a hardened, versioned backend exists; return whether it was created."""
70
+ if not _PROJECT_ID.fullmatch(project):
71
+ raise ProjectBootstrapError("Invalid GCP project id")
72
+ if not _BUCKET_NAME.fullmatch(bucket):
73
+ raise ProjectBootstrapError("Invalid Terraform state bucket")
74
+ described = self._runner(
75
+ (
76
+ "gcloud",
77
+ "storage",
78
+ "buckets",
79
+ "describe",
80
+ f"gs://{bucket}",
81
+ f"--project={project}",
82
+ ),
83
+ cwd=self._cwd,
84
+ check=False,
85
+ capture_output=True,
86
+ text=True,
87
+ )
88
+ if described.returncode == 0:
89
+ return False
90
+ if not apply:
91
+ raise ProjectBootstrapError(
92
+ "The Terraform state bucket does not exist; rerun dander init with --apply"
93
+ )
94
+ try:
95
+ self._runner(
96
+ (
97
+ "gcloud",
98
+ "services",
99
+ "enable",
100
+ "serviceusage.googleapis.com",
101
+ "storage.googleapis.com",
102
+ f"--project={project}",
103
+ "--quiet",
104
+ ),
105
+ cwd=self._cwd,
106
+ check=True,
107
+ )
108
+ self._runner(
109
+ (
110
+ "gcloud",
111
+ "storage",
112
+ "buckets",
113
+ "create",
114
+ f"gs://{bucket}",
115
+ f"--project={project}",
116
+ f"--location={location}",
117
+ "--uniform-bucket-level-access",
118
+ "--public-access-prevention",
119
+ "--quiet",
120
+ ),
121
+ cwd=self._cwd,
122
+ check=True,
123
+ )
124
+ self._runner(
125
+ (
126
+ "gcloud",
127
+ "storage",
128
+ "buckets",
129
+ "update",
130
+ f"gs://{bucket}",
131
+ "--versioning",
132
+ "--update-labels=managed-by=dander,purpose=terraform-state",
133
+ "--quiet",
134
+ ),
135
+ cwd=self._cwd,
136
+ check=True,
137
+ )
138
+ except (FileNotFoundError, subprocess.CalledProcessError) as error:
139
+ raise ProjectBootstrapError("Could not create the Terraform state bucket") from error
140
+ return True
141
+
142
+
143
+ class RuntimeImagePublisher:
144
+ """Build the current Dander project and return its immutable Artifact Registry reference."""
145
+
146
+ def __init__(self, repository_dir: Path, *, runner: _Runner | None = None) -> None:
147
+ self._repository_dir = repository_dir.resolve()
148
+ self._runner = runner or _subprocess_runner
149
+
150
+ def publish(self, *, project: str, region: str) -> str:
151
+ if not _PROJECT_ID.fullmatch(project) or not _REGION.fullmatch(region):
152
+ raise ProjectBootstrapError("Invalid runtime image project or region")
153
+ host = f"{region}-docker.pkg.dev"
154
+ repository = f"{host}/{project}/dander/dander"
155
+ tag = f"init-{self._content_digest()[:12]}"
156
+ tagged_image = f"{repository}:{tag}"
157
+ try:
158
+ self._runner(
159
+ ("gcloud", "auth", "configure-docker", host, "--quiet"),
160
+ cwd=self._repository_dir,
161
+ check=True,
162
+ )
163
+ self._runner(
164
+ (
165
+ "docker",
166
+ "buildx",
167
+ "build",
168
+ "--platform",
169
+ "linux/amd64",
170
+ "--push",
171
+ "-t",
172
+ tagged_image,
173
+ ".",
174
+ ),
175
+ cwd=self._repository_dir,
176
+ check=True,
177
+ )
178
+ described = self._runner(
179
+ (
180
+ "gcloud",
181
+ "artifacts",
182
+ "docker",
183
+ "images",
184
+ "describe",
185
+ tagged_image,
186
+ "--format=value(image_summary.digest)",
187
+ ),
188
+ cwd=self._repository_dir,
189
+ check=True,
190
+ capture_output=True,
191
+ text=True,
192
+ )
193
+ except (FileNotFoundError, subprocess.CalledProcessError) as error:
194
+ raise ProjectBootstrapError("Could not publish the Dander runtime image") from error
195
+ digest = described.stdout.strip()
196
+ if not _DIGEST.fullmatch(digest):
197
+ raise ProjectBootstrapError("Artifact Registry returned an invalid image digest")
198
+ return f"{repository}@{digest}"
199
+
200
+ def _content_digest(self) -> str:
201
+ hasher = hashlib.sha256()
202
+ required = (
203
+ self._repository_dir / "Dockerfile",
204
+ self._repository_dir / "dander.yaml",
205
+ self._repository_dir / "connectors",
206
+ self._repository_dir / "models",
207
+ )
208
+ if any(not path.exists() for path in required):
209
+ raise ProjectBootstrapError("Runtime build context is incomplete")
210
+ roots = tuple(
211
+ path
212
+ for path in (
213
+ self._repository_dir / "src",
214
+ self._repository_dir / "connectors",
215
+ self._repository_dir / "models",
216
+ self._repository_dir / "infra",
217
+ )
218
+ if path.is_dir()
219
+ )
220
+ files = [
221
+ self._repository_dir / name
222
+ for name in (
223
+ ".dockerignore",
224
+ "Dockerfile",
225
+ "README.md",
226
+ "dander.yaml",
227
+ "pyproject.toml",
228
+ "uv.lock",
229
+ )
230
+ if (self._repository_dir / name).is_file()
231
+ ]
232
+ for root in roots:
233
+ files.extend(
234
+ path for path in root.rglob("*") if path.is_file() and _is_build_context_file(path)
235
+ )
236
+ try:
237
+ for path in sorted(files):
238
+ hasher.update(str(path.relative_to(self._repository_dir)).encode())
239
+ hasher.update(path.read_bytes())
240
+ except OSError as error:
241
+ raise ProjectBootstrapError("Could not hash the runtime build context") from error
242
+ return hasher.hexdigest()
243
+
244
+
245
+ def _is_build_context_file(path: Path) -> bool:
246
+ if any(part in {".terraform", "__pycache__"} for part in path.parts):
247
+ return False
248
+ name = path.name
249
+ return not (
250
+ name == ".DS_Store"
251
+ or name.endswith(".tfplan")
252
+ or ".tfstate" in name
253
+ or (name.endswith(".tfvars") and not name.endswith(".tfvars.example"))
254
+ )
255
+
256
+
257
+ def active_admin_member(*, cwd: Path, runner: _Runner | None = None) -> str:
258
+ """Resolve the active gcloud user as the stage-zero impersonation administrator."""
259
+ command_runner = runner or _subprocess_runner
260
+ try:
261
+ result = command_runner(
262
+ (
263
+ "gcloud",
264
+ "auth",
265
+ "list",
266
+ "--filter=status:ACTIVE",
267
+ "--format=value(account)",
268
+ ),
269
+ cwd=cwd.resolve(),
270
+ check=True,
271
+ capture_output=True,
272
+ text=True,
273
+ )
274
+ except (FileNotFoundError, subprocess.CalledProcessError) as error:
275
+ raise ProjectBootstrapError("Could not resolve the active gcloud account") from error
276
+ account = result.stdout.strip()
277
+ if not _ACCOUNT.fullmatch(account):
278
+ raise ProjectBootstrapError("No active gcloud user account was found")
279
+ return f"user:{account}"