Skip to content

Commit 9127ba1

Browse files
authored
feat(deploy): --property-graph mode for the Cloud Run deploy + image scripts (issue #286, PR 3) (#290)
* feat(deploy): --property-graph mode for the Cloud Run deploy + image scripts (issue #286, PR 3) Adds schema-derived mode to deploy_cloud_run_job.sh and build_image.sh, on the #288/#289 runtime. One artifact contract: --property-graph PATH points at a CREATE PROPERTY GRAPH .sql, and table_ddl.sql is staged from the same directory (codelab bundles ship both together). deploy_cloud_run_job.sh: - New --property-graph PATH. Validates at the deploy boundary (before any gcloud): rejects --extraction-mode=compiled-only (no reference extractors are staged in derived mode -> would empty_extract), and requires a sibling table_ddl.sql (derive mode reads INFORMATION_SCHEMA, so the tables must be bootstrappable). Stages only property_graph.sql + table_ddl.sql (no ontology/binding/reference_extractor), and sets BQAA_PROPERTY_GRAPH= property_graph.sql so the runtime derives the spec. Explicit ontology+binding (migration-v5 / compiled-extractor) path unchanged when --property-graph is omitted. build_image.sh: mirror --property-graph staging (graph + sibling table DDL vs the explicit ontology/binding/extractor set). Tests: shell tests invoke the deploy script and assert the two rejection paths (compiled-only, missing sibling table_ddl) exit non-zero with actionable messages before any gcloud; static assertions pin the staging + BQAA_PROPERTY_GRAPH contract for both scripts. 5 tests pass; bash -n clean. Next (PR 4): Terraform property-graph mode; (PR 5) deploy docs. * fix(deploy): enforce the placeholder contract in property-graph mode (review P1) The scripts validated that property_graph.sql + sibling table_ddl.sql exist, but not that they are placeholdered. That left the original #286 failure mode open: a hardcoded graph DDL (e.g. the migration-v5 snapshot pointing at the canonical demo dataset) could be staged and derive against the wrong dataset. Both deploy_cloud_run_job.sh and build_image.sh now reject property-graph mode unless BOTH artifacts contain ${PROJECT_ID} and ${DATASET} (grep -qF), with an actionable error pointing at the offending file. Caught at the deploy/build boundary, before any gcloud or image bake -- so Terraform-built images can't bake wrong artifacts either. Tests: hardcoded property_graph.sql and hardcoded table_ddl.sql each rejected with the placeholder message; static assertions pin the grep checks in both scripts. 7 tests pass; bash -n clean.
1 parent becadba commit 9127ba1

3 files changed

Lines changed: 284 additions & 23 deletions

File tree

examples/migration_v5/periodic_materialization/build_image.sh

Lines changed: 49 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,11 @@ Optional:
5656
--tag TAG Image tag (default: short git SHA of HEAD, or
5757
a timestamp if HEAD isn't a git ref).
5858
--create-repo Create the AR repo if it doesn't exist.
59+
--property-graph PATH Schema-derived mode (#286): stage this CREATE
60+
PROPERTY GRAPH .sql plus a sibling table_ddl.sql
61+
instead of ontology.yaml/binding.yaml/
62+
reference_extractor.py. The runtime derives the
63+
ontology + binding from them.
5964
-h | --help Show this help.
6065
6166
Outputs (last line of stdout):
@@ -67,16 +72,18 @@ EOF
6772
}
6873

6974
CREATE_REPO=false
75+
PROPERTY_GRAPH=""
7076
while [[ $# -gt 0 ]]; do
7177
case "$1" in
72-
--project) PROJECT="$2"; shift 2 ;;
73-
--repo) REPO="$2"; shift 2 ;;
74-
--region) REGION="$2"; shift 2 ;;
75-
--image) IMAGE="$2"; shift 2 ;;
76-
--tag) TAG="$2"; shift 2 ;;
77-
--create-repo) CREATE_REPO=true; shift ;;
78-
-h|--help) usage 0 ;;
79-
*) echo "Unknown arg: $1" >&2; usage 1 ;;
78+
--project) PROJECT="$2"; shift 2 ;;
79+
--repo) REPO="$2"; shift 2 ;;
80+
--region) REGION="$2"; shift 2 ;;
81+
--image) IMAGE="$2"; shift 2 ;;
82+
--tag) TAG="$2"; shift 2 ;;
83+
--create-repo) CREATE_REPO=true; shift ;;
84+
--property-graph) PROPERTY_GRAPH="$2"; shift 2 ;;
85+
-h|--help) usage 0 ;;
86+
*) echo "Unknown arg: $1" >&2; usage 1 ;;
8087
esac
8188
done
8289

@@ -85,6 +92,29 @@ if [[ -z "$PROJECT" || -z "$REPO" ]]; then
8592
usage 1
8693
fi
8794

95+
TABLE_DDL_SRC=""
96+
if [[ -n "$PROPERTY_GRAPH" ]]; then
97+
if [[ ! -f "$PROPERTY_GRAPH" ]]; then
98+
echo "Error: --property-graph file not found: $PROPERTY_GRAPH" >&2
99+
usage 1
100+
fi
101+
TABLE_DDL_SRC="$(dirname "$PROPERTY_GRAPH")/table_ddl.sql"
102+
if [[ ! -f "$TABLE_DDL_SRC" ]]; then
103+
echo "Error: schema-derived mode needs a 'table_ddl.sql' next to the property graph; not found: $TABLE_DDL_SRC" >&2
104+
usage 1
105+
fi
106+
# Placeholder contract (#286): refuse to bake a hardcoded graph DDL into the
107+
# image. Both artifacts must use \${PROJECT_ID} / \${DATASET} so the runtime
108+
# retargets them to the customer's project + graph dataset.
109+
for _pg_artifact in "$PROPERTY_GRAPH" "$TABLE_DDL_SRC"; do
110+
if ! grep -qF '${PROJECT_ID}' "$_pg_artifact" \
111+
|| ! grep -qF '${DATASET}' "$_pg_artifact"; then
112+
echo "Error: schema-derived mode requires placeholdered artifacts: $_pg_artifact must contain \${PROJECT_ID} and \${DATASET}. Hardcoded graph DDL would derive against the wrong dataset." >&2
113+
usage 1
114+
fi
115+
done
116+
fi
117+
88118
if [[ -z "$TAG" ]]; then
89119
# Default tag: short git SHA if HEAD is reachable, timestamp
90120
# otherwise. Either keeps tags monotonically increasing so a
@@ -131,10 +161,17 @@ echo "==> staging build context at $STAGING" >&2
131161
# pins these copies to lock the contract.
132162

133163
cp "${SCRIPT_DIR}/run_job.py" "$STAGING/"
134-
cp "${ARTIFACTS_DIR}/ontology.yaml" "$STAGING/"
135-
cp "${ARTIFACTS_DIR}/binding.yaml" "$STAGING/"
136-
cp "${ARTIFACTS_DIR}/table_ddl.sql" "$STAGING/"
137-
cp "${ARTIFACTS_DIR}/reference_extractor.py" "$STAGING/"
164+
if [[ -n "$PROPERTY_GRAPH" ]]; then
165+
# Schema-derived mode: stage the property graph + its companion table DDL.
166+
cp "$PROPERTY_GRAPH" "$STAGING/property_graph.sql"
167+
cp "$TABLE_DDL_SRC" "$STAGING/table_ddl.sql"
168+
else
169+
# Explicit ontology + binding (migration-v5 / compiled-extractor path).
170+
cp "${ARTIFACTS_DIR}/ontology.yaml" "$STAGING/"
171+
cp "${ARTIFACTS_DIR}/binding.yaml" "$STAGING/"
172+
cp "${ARTIFACTS_DIR}/table_ddl.sql" "$STAGING/"
173+
cp "${ARTIFACTS_DIR}/reference_extractor.py" "$STAGING/"
174+
fi
138175

139176
mkdir -p "$STAGING/sdk_src/src"
140177
cp -r "$REPO_ROOT/src/bigquery_agent_analytics" "$STAGING/sdk_src/src/"

examples/migration_v5/periodic_materialization/deploy_cloud_run_job.sh

Lines changed: 74 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ MAX_SESSIONS=""
101101
JOB_NAME="bqaa-periodic-materialization"
102102
SMOKE=false
103103
EXTRACTION_MODE="ai-fallback"
104+
PROPERTY_GRAPH=""
104105
MAX_SESSION_AGE_HOURS=""
105106
# Production posture by default: split runtime + scheduler-caller
106107
# SAs (issue #182). ``--single-sa`` is the escape hatch for
@@ -148,6 +149,14 @@ Optional:
148149
roles/aiplatform.user grant and idempotently
149150
removes any pre-existing grant from a prior
150151
ai-fallback deploy of the same SA.
152+
--property-graph PATH Schema-derived mode (#286): derive the
153+
ontology + binding from this CREATE PROPERTY
154+
GRAPH .sql file plus the table schemas, instead
155+
of staging ontology.yaml/binding.yaml. A
156+
placeholdered (\${PROJECT_ID}/\${DATASET})
157+
table_ddl.sql must sit next to it. Use for
158+
rename-free graphs; not compatible with
159+
--extraction-mode=compiled-only.
151160
--max-session-age-hours N Enable the orphan-session watchdog (issue
152161
#180). When set, each cron pass additionally
153162
scans for sessions whose first event is
@@ -214,6 +223,7 @@ while [[ $# -gt 0 ]]; do
214223
--job-name) require_arg "$1" "${2-}"; JOB_NAME="$2"; shift 2 ;;
215224
--smoke) SMOKE=true; shift ;;
216225
--extraction-mode) require_arg "$1" "${2-}"; EXTRACTION_MODE="$2"; shift 2 ;;
226+
--property-graph) require_arg "$1" "${2-}"; PROPERTY_GRAPH="$2"; shift 2 ;;
217227
--max-session-age-hours)
218228
require_arg "$1" "${2-}"; MAX_SESSION_AGE_HOURS="$2"; shift 2 ;;
219229
--single-sa) SINGLE_SA=true; shift ;;
@@ -253,6 +263,45 @@ case "$EXTRACTION_MODE" in
253263
;;
254264
esac
255265

266+
# Spec input mode (#286). ``--property-graph`` selects schema-derived
267+
# mode: the ontology + binding are derived from the property graph + the
268+
# table schemas, so no ``ontology.yaml`` / ``binding.yaml`` is staged.
269+
# Unset = the explicit migration-v5 ontology+binding (compiled-extractor)
270+
# path. Exactly one mode is in effect.
271+
TABLE_DDL_SRC=""
272+
if [[ -n "$PROPERTY_GRAPH" ]]; then
273+
# Derived mode has no reference extractors staged, so compiled-only
274+
# would empty_extract at runtime — reject it at the deploy boundary.
275+
if [[ "$EXTRACTION_MODE" == "compiled-only" ]]; then
276+
echo "Error: --property-graph (schema-derived mode) does not support --extraction-mode=compiled-only: no reference extractors are staged in derived mode. Use 'ai-fallback', or deploy the explicit ontology/binding path." >&2
277+
exit 1
278+
fi
279+
if [[ ! -f "$PROPERTY_GRAPH" ]]; then
280+
echo "Error: --property-graph file not found: $PROPERTY_GRAPH" >&2
281+
exit 1
282+
fi
283+
# The derived path also needs the graph tables to exist before the first
284+
# run (it reads INFORMATION_SCHEMA), so a placeholdered table_ddl.sql must
285+
# sit next to the property graph.
286+
TABLE_DDL_SRC="$(dirname "$PROPERTY_GRAPH")/table_ddl.sql"
287+
if [[ ! -f "$TABLE_DDL_SRC" ]]; then
288+
echo "Error: schema-derived mode also needs a 'table_ddl.sql' next to the property graph (so the graph tables can be bootstrapped); not found: $TABLE_DDL_SRC" >&2
289+
exit 1
290+
fi
291+
# Enforce the placeholder contract (#286). Both artifacts must use
292+
# \${PROJECT_ID} / \${DATASET} so the runtime retargets them to the
293+
# customer's project + graph dataset. A hardcoded graph DDL (e.g. the
294+
# migration-v5 snapshot pointing at a canonical demo dataset) would derive
295+
# against the wrong dataset -- reject it here, not after deploy.
296+
for _pg_artifact in "$PROPERTY_GRAPH" "$TABLE_DDL_SRC"; do
297+
if ! grep -qF '${PROJECT_ID}' "$_pg_artifact" \
298+
|| ! grep -qF '${DATASET}' "$_pg_artifact"; then
299+
echo "Error: schema-derived mode requires placeholdered artifacts: $_pg_artifact must contain \${PROJECT_ID} and \${DATASET} so it can be retargeted to your project/graph dataset. Hardcoded graph DDL would derive against the wrong dataset. Use placeholdered, rename-free artifacts, or deploy the explicit --ontology/--binding path." >&2
300+
exit 1
301+
fi
302+
done
303+
fi
304+
256305
# Validate ``--max-retries`` at the boundary (issue #183). A typo
257306
# like ``--max-retries=-1`` would otherwise be forwarded to
258307
# ``gcloud run jobs deploy`` which rejects it with a less obvious
@@ -586,17 +635,26 @@ STAGING="$(mktemp -d -t bqaa-cloud-run-job-XXXXXXXX)"
586635

587636
echo "==> staging at $STAGING"
588637
cp "${SCRIPT_DIR}/run_job.py" "$STAGING/"
589-
cp "${ARTIFACTS_DIR}/ontology.yaml" "$STAGING/"
590-
cp "${ARTIFACTS_DIR}/binding.yaml" "$STAGING/"
591-
cp "${ARTIFACTS_DIR}/table_ddl.sql" "$STAGING/"
592-
# Stage the reference extractor module next to ``run_job.py`` so
593-
# Python can import it via ``BQAA_REFERENCE_EXTRACTORS_MODULE=
594-
# reference_extractor`` (Buildpacks sets the container working
595-
# directory to the staging dir's contents, which puts the
596-
# extractor on ``sys.path``). Shipped in both modes so an
597-
# operator who flips an existing deploy from ai-fallback to
598-
# compiled-only doesn't need to also re-stage extractor code.
599-
cp "${ARTIFACTS_DIR}/reference_extractor.py" "$STAGING/"
638+
if [[ -n "$PROPERTY_GRAPH" ]]; then
639+
# Schema-derived mode: stage only the property graph + its companion
640+
# table DDL. The runtime derives ontology + binding from them, so no
641+
# ontology.yaml / binding.yaml / reference_extractor.py is needed.
642+
cp "$PROPERTY_GRAPH" "$STAGING/property_graph.sql"
643+
cp "$TABLE_DDL_SRC" "$STAGING/table_ddl.sql"
644+
else
645+
# Explicit ontology + binding (migration-v5 / compiled-extractor path).
646+
cp "${ARTIFACTS_DIR}/ontology.yaml" "$STAGING/"
647+
cp "${ARTIFACTS_DIR}/binding.yaml" "$STAGING/"
648+
cp "${ARTIFACTS_DIR}/table_ddl.sql" "$STAGING/"
649+
# Stage the reference extractor module next to ``run_job.py`` so
650+
# Python can import it via ``BQAA_REFERENCE_EXTRACTORS_MODULE=
651+
# reference_extractor`` (Buildpacks sets the container working
652+
# directory to the staging dir's contents, which puts the
653+
# extractor on ``sys.path``). Shipped in both modes so an
654+
# operator who flips an existing deploy from ai-fallback to
655+
# compiled-only doesn't need to also re-stage extractor code.
656+
cp "${ARTIFACTS_DIR}/reference_extractor.py" "$STAGING/"
657+
fi
600658

601659
# Vendor the local SDK source.
602660
mkdir -p "$STAGING/sdk_src/src"
@@ -667,6 +725,11 @@ fi
667725
if [[ "$EXTRACTION_MODE" == "compiled-only" ]]; then
668726
ENV_VARS+=("BQAA_REFERENCE_EXTRACTORS_MODULE=reference_extractor")
669727
fi
728+
# Schema-derived mode: tell the runtime to derive the spec from the staged
729+
# property graph instead of the explicit ontology/binding pair (#286).
730+
if [[ -n "$PROPERTY_GRAPH" ]]; then
731+
ENV_VARS+=("BQAA_PROPERTY_GRAPH=property_graph.sql")
732+
fi
670733
# Orphan-session watchdog (issue #180). Only wired when the
671734
# operator explicitly opts in via ``--max-session-age-hours``;
672735
# default deploys ship without the watchdog so they don't pay
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
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+
"""Deploy-script property-graph mode: boundary validation + staging contract.
16+
17+
Static + shell tests for the Cloud Run deploy/build scripts (issue #286, PR 3).
18+
The validation runs before any gcloud call, so the rejection paths can be
19+
exercised by invoking the script directly; the staging contract is pinned by
20+
asserting on the script text (no live gcloud / Docker needed).
21+
"""
22+
23+
from __future__ import annotations
24+
25+
from pathlib import Path
26+
import shutil
27+
import subprocess
28+
29+
import pytest
30+
31+
_DEPLOY_DIR = (
32+
Path(__file__).resolve().parents[1]
33+
/ "examples"
34+
/ "migration_v5"
35+
/ "periodic_materialization"
36+
)
37+
_DEPLOY = _DEPLOY_DIR / "deploy_cloud_run_job.sh"
38+
_BUILD = _DEPLOY_DIR / "build_image.sh"
39+
40+
_REQUIRED = [
41+
"--project",
42+
"p",
43+
"--region",
44+
"us-central1",
45+
"--events-dataset",
46+
"e",
47+
"--graph-dataset",
48+
"g",
49+
"--schedule",
50+
"0 */6 * * *",
51+
]
52+
53+
54+
def _run_deploy(args):
55+
return subprocess.run(
56+
["bash", str(_DEPLOY), *_REQUIRED, *args],
57+
capture_output=True,
58+
text=True,
59+
timeout=60,
60+
)
61+
62+
63+
@pytest.mark.skipif(shutil.which("bash") is None, reason="bash not available")
64+
def test_rejects_property_graph_with_compiled_only(tmp_path) -> None:
65+
(tmp_path / "property_graph.sql").write_text("x")
66+
(tmp_path / "table_ddl.sql").write_text("x")
67+
result = _run_deploy(
68+
[
69+
"--property-graph",
70+
str(tmp_path / "property_graph.sql"),
71+
"--extraction-mode",
72+
"compiled-only",
73+
]
74+
)
75+
assert result.returncode != 0
76+
assert "compiled-only" in result.stderr
77+
78+
79+
@pytest.mark.skipif(shutil.which("bash") is None, reason="bash not available")
80+
def test_rejects_property_graph_without_sibling_table_ddl(tmp_path) -> None:
81+
(tmp_path / "property_graph.sql").write_text("x") # no table_ddl.sql sibling
82+
result = _run_deploy(
83+
["--property-graph", str(tmp_path / "property_graph.sql")]
84+
)
85+
assert result.returncode != 0
86+
assert "table_ddl.sql" in result.stderr
87+
88+
89+
@pytest.mark.skipif(shutil.which("bash") is None, reason="bash not available")
90+
def test_rejects_missing_property_graph_file(tmp_path) -> None:
91+
result = _run_deploy(
92+
["--property-graph", str(tmp_path / "does_not_exist.sql")]
93+
)
94+
assert result.returncode != 0
95+
assert "not found" in result.stderr
96+
97+
98+
@pytest.mark.skipif(shutil.which("bash") is None, reason="bash not available")
99+
def test_rejects_hardcoded_property_graph_without_placeholders(
100+
tmp_path,
101+
) -> None:
102+
# The original #286 failure mode: a hardcoded graph DDL (no
103+
# ${PROJECT_ID}/${DATASET}) would derive against the wrong dataset. Both
104+
# files exist and mode is ai-fallback, so this must be caught by the
105+
# placeholder check, not the earlier existence checks.
106+
(tmp_path / "property_graph.sql").write_text(
107+
"CREATE PROPERTY GRAPH `proj.ds.g` NODE TABLES ()"
108+
)
109+
(tmp_path / "table_ddl.sql").write_text(
110+
"CREATE TABLE `${PROJECT_ID}.${DATASET}.t` (id STRING)"
111+
)
112+
result = _run_deploy(
113+
["--property-graph", str(tmp_path / "property_graph.sql")]
114+
)
115+
assert result.returncode != 0
116+
assert "${PROJECT_ID}" in result.stderr or "placeholder" in result.stderr
117+
118+
119+
@pytest.mark.skipif(shutil.which("bash") is None, reason="bash not available")
120+
def test_rejects_hardcoded_table_ddl_without_placeholders(tmp_path) -> None:
121+
# The graph is placeholdered but the companion table DDL is not -> still a
122+
# wrong-dataset risk at bootstrap time -> reject.
123+
(tmp_path / "property_graph.sql").write_text(
124+
"CREATE PROPERTY GRAPH `${PROJECT_ID}.${DATASET}.g` NODE TABLES ()"
125+
)
126+
(tmp_path / "table_ddl.sql").write_text(
127+
"CREATE TABLE `proj.ds.t` (id STRING)"
128+
)
129+
result = _run_deploy(
130+
["--property-graph", str(tmp_path / "property_graph.sql")]
131+
)
132+
assert result.returncode != 0
133+
assert "${PROJECT_ID}" in result.stderr or "placeholder" in result.stderr
134+
135+
136+
# --------------------------------------------------------------------------- #
137+
# Staging / env-var contract (static text assertions)
138+
# --------------------------------------------------------------------------- #
139+
140+
141+
def test_deploy_script_property_graph_staging_contract() -> None:
142+
text = _DEPLOY.read_text()
143+
# mode arg + boundary env var
144+
assert "--property-graph)" in text
145+
assert "BQAA_PROPERTY_GRAPH=property_graph.sql" in text
146+
# property-graph mode stages the graph + its table DDL, not ontology/binding
147+
assert 'cp "$PROPERTY_GRAPH" "$STAGING/property_graph.sql"' in text
148+
assert 'cp "$TABLE_DDL_SRC" "$STAGING/table_ddl.sql"' in text
149+
# placeholder contract is enforced for both artifacts
150+
assert "grep -qF '${PROJECT_ID}'" in text
151+
assert "grep -qF '${DATASET}'" in text
152+
153+
154+
def test_build_image_property_graph_staging_contract() -> None:
155+
text = _BUILD.read_text()
156+
assert "--property-graph)" in text
157+
assert 'cp "$PROPERTY_GRAPH" "$STAGING/property_graph.sql"' in text
158+
assert 'cp "$TABLE_DDL_SRC" "$STAGING/table_ddl.sql"' in text
159+
# Terraform-built images can't bake hardcoded artifacts either.
160+
assert "grep -qF '${PROJECT_ID}'" in text
161+
assert "grep -qF '${DATASET}'" in text

0 commit comments

Comments
 (0)