odctl 1.2.2__tar.gz → 1.4.0__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 (156) hide show
  1. {odctl-1.2.2 → odctl-1.4.0}/.github/scripts/select-profiles.py +23 -7
  2. {odctl-1.2.2 → odctl-1.4.0}/.github/scripts/smoke.sh +414 -34
  3. {odctl-1.2.2 → odctl-1.4.0}/.github/workflows/ci.yml +1 -1
  4. {odctl-1.2.2 → odctl-1.4.0}/.github/workflows/e2e-test.yml +9 -5
  5. {odctl-1.2.2 → odctl-1.4.0}/.github/workflows/pipeline.yml +3 -3
  6. {odctl-1.2.2 → odctl-1.4.0}/.github/workflows/publish.yml +1 -1
  7. odctl-1.4.0/CLAUDE.md +20 -0
  8. odctl-1.4.0/CONTRIBUTING.md +99 -0
  9. {odctl-1.2.2 → odctl-1.4.0}/PKG-INFO +5 -26
  10. {odctl-1.2.2 → odctl-1.4.0}/README.md +4 -25
  11. {odctl-1.2.2 → odctl-1.4.0}/mkdocs.yml +4 -0
  12. {odctl-1.2.2 → odctl-1.4.0}/pyproject.toml +1 -1
  13. {odctl-1.2.2 → odctl-1.4.0}/scripts/gen_pages.py +2 -0
  14. {odctl-1.2.2 → odctl-1.4.0}/skills/odctl/SKILL.md +25 -14
  15. {odctl-1.2.2 → odctl-1.4.0}/src/odctl/resources/compose-analytics.yml +1 -1
  16. {odctl-1.2.2 → odctl-1.4.0}/src/odctl/resources/compose-infra.yml +1 -1
  17. {odctl-1.2.2 → odctl-1.4.0}/src/odctl/resources/compose-kafka.yml +1 -1
  18. {odctl-1.2.2 → odctl-1.4.0}/src/odctl/resources/compose-metadata.yml +79 -9
  19. {odctl-1.2.2 → odctl-1.4.0}/src/odctl/resources/deps/build.gradle +1 -1
  20. {odctl-1.2.2 → odctl-1.4.0}/src/odctl/resources/feast/feature_store.yaml +10 -5
  21. odctl-1.4.0/src/odctl/resources/openmetadata/autopilot_init.py +158 -0
  22. odctl-1.4.0/src/odctl/resources/openmetadata/connector_sync.py +178 -0
  23. {odctl-1.2.2 → odctl-1.4.0}/src/odctl/resources/registry.yml +12 -4
  24. {odctl-1.2.2 → odctl-1.4.0}/src/odctl/workspace.py +14 -0
  25. odctl-1.4.0/tests/test_autopilot_init.py +117 -0
  26. odctl-1.4.0/tests/test_connector_sync.py +183 -0
  27. {odctl-1.2.2 → odctl-1.4.0}/tests/test_docs.py +35 -3
  28. {odctl-1.2.2 → odctl-1.4.0}/tests/test_feast_profile.py +11 -0
  29. odctl-1.4.0/tests/test_metadata_profile.py +119 -0
  30. odctl-1.4.0/tests/test_named_paths.py +62 -0
  31. odctl-1.4.0/tests/test_roadmap.py +55 -0
  32. {odctl-1.2.2 → odctl-1.4.0}/tests/test_workspace.py +4 -0
  33. {odctl-1.2.2 → odctl-1.4.0}/uv.lock +1 -1
  34. odctl-1.2.2/CLAUDE.md +0 -64
  35. {odctl-1.2.2 → odctl-1.4.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  36. {odctl-1.2.2 → odctl-1.4.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  37. {odctl-1.2.2 → odctl-1.4.0}/.github/renovate.json +0 -0
  38. {odctl-1.2.2 → odctl-1.4.0}/.github/scripts/spark_avro_producer.py +0 -0
  39. {odctl-1.2.2 → odctl-1.4.0}/.github/workflows/build-deps.yml +0 -0
  40. {odctl-1.2.2 → odctl-1.4.0}/.github/workflows/build-platform-images.yml +0 -0
  41. {odctl-1.2.2 → odctl-1.4.0}/.gitignore +0 -0
  42. {odctl-1.2.2 → odctl-1.4.0}/.pre-commit-config.yaml +0 -0
  43. {odctl-1.2.2 → odctl-1.4.0}/.vscode/settings.json +0 -0
  44. {odctl-1.2.2 → odctl-1.4.0}/LICENSE +0 -0
  45. {odctl-1.2.2 → odctl-1.4.0}/image/demo.gif +0 -0
  46. {odctl-1.2.2 → odctl-1.4.0}/image/demo.png +0 -0
  47. {odctl-1.2.2 → odctl-1.4.0}/image/diagram.drawio +0 -0
  48. {odctl-1.2.2 → odctl-1.4.0}/image/diagram.png +0 -0
  49. {odctl-1.2.2 → odctl-1.4.0}/scripts/diagrams.py +0 -0
  50. {odctl-1.2.2 → odctl-1.4.0}/scripts/llmstxt_sections.py +0 -0
  51. {odctl-1.2.2 → odctl-1.4.0}/scripts/local_links.py +0 -0
  52. {odctl-1.2.2 → odctl-1.4.0}/scripts/release_links.py +0 -0
  53. {odctl-1.2.2 → odctl-1.4.0}/scripts/release_notes.py +0 -0
  54. {odctl-1.2.2 → odctl-1.4.0}/src/odctl/__init__.py +0 -0
  55. {odctl-1.2.2 → odctl-1.4.0}/src/odctl/config.py +0 -0
  56. {odctl-1.2.2 → odctl-1.4.0}/src/odctl/docker.py +0 -0
  57. {odctl-1.2.2 → odctl-1.4.0}/src/odctl/logs.py +0 -0
  58. {odctl-1.2.2 → odctl-1.4.0}/src/odctl/main.py +0 -0
  59. {odctl-1.2.2 → odctl-1.4.0}/src/odctl/planner.py +0 -0
  60. {odctl-1.2.2 → odctl-1.4.0}/src/odctl/projects.py +0 -0
  61. {odctl-1.2.2 → odctl-1.4.0}/src/odctl/registry.py +0 -0
  62. {odctl-1.2.2 → odctl-1.4.0}/src/odctl/resources/airflow/odctl_log_config.py +0 -0
  63. {odctl-1.2.2 → odctl-1.4.0}/src/odctl/resources/clickhouse/config.xml +0 -0
  64. {odctl-1.2.2 → odctl-1.4.0}/src/odctl/resources/clickhouse/init.sql +0 -0
  65. {odctl-1.2.2 → odctl-1.4.0}/src/odctl/resources/clickhouse/keeper_config.xml +0 -0
  66. {odctl-1.2.2 → odctl-1.4.0}/src/odctl/resources/clickhouse/macros-11-lite.xml +0 -0
  67. {odctl-1.2.2 → odctl-1.4.0}/src/odctl/resources/clickhouse/macros-11.xml +0 -0
  68. {odctl-1.2.2 → odctl-1.4.0}/src/odctl/resources/clickhouse/macros-12-lite.xml +0 -0
  69. {odctl-1.2.2 → odctl-1.4.0}/src/odctl/resources/clickhouse/macros-12.xml +0 -0
  70. {odctl-1.2.2 → odctl-1.4.0}/src/odctl/resources/clickhouse/macros-21.xml +0 -0
  71. {odctl-1.2.2 → odctl-1.4.0}/src/odctl/resources/clickhouse/macros-22.xml +0 -0
  72. {odctl-1.2.2 → odctl-1.4.0}/src/odctl/resources/clickhouse/users.xml +0 -0
  73. {odctl-1.2.2 → odctl-1.4.0}/src/odctl/resources/compose-deps.yml +0 -0
  74. {odctl-1.2.2 → odctl-1.4.0}/src/odctl/resources/compose-flink.yml +0 -0
  75. {odctl-1.2.2 → odctl-1.4.0}/src/odctl/resources/compose-mlops.yml +0 -0
  76. {odctl-1.2.2 → odctl-1.4.0}/src/odctl/resources/compose-obsv.yml +0 -0
  77. {odctl-1.2.2 → odctl-1.4.0}/src/odctl/resources/compose-orch.yml +0 -0
  78. {odctl-1.2.2 → odctl-1.4.0}/src/odctl/resources/compose-spark.yml +0 -0
  79. {odctl-1.2.2 → odctl-1.4.0}/src/odctl/resources/compose-store.yml +0 -0
  80. {odctl-1.2.2 → odctl-1.4.0}/src/odctl/resources/deps/Dockerfile +0 -0
  81. {odctl-1.2.2 → odctl-1.4.0}/src/odctl/resources/deps/build_connectors.sh +0 -0
  82. {odctl-1.2.2 → odctl-1.4.0}/src/odctl/resources/deps/fetch_flink_dependencies.sh +0 -0
  83. {odctl-1.2.2 → odctl-1.4.0}/src/odctl/resources/deps/fetch_spark_dependencies.sh +0 -0
  84. {odctl-1.2.2 → odctl-1.4.0}/src/odctl/resources/deps/fetch_standalone_plugins.sh +0 -0
  85. {odctl-1.2.2 → odctl-1.4.0}/src/odctl/resources/deps/versions.env +0 -0
  86. {odctl-1.2.2 → odctl-1.4.0}/src/odctl/resources/docker/airflow/Dockerfile +0 -0
  87. {odctl-1.2.2 → odctl-1.4.0}/src/odctl/resources/docker/mlflow/Dockerfile +0 -0
  88. {odctl-1.2.2 → odctl-1.4.0}/src/odctl/resources/docker/postgres/Dockerfile +0 -0
  89. {odctl-1.2.2 → odctl-1.4.0}/src/odctl/resources/docker/spark/Dockerfile +0 -0
  90. {odctl-1.2.2 → odctl-1.4.0}/src/odctl/resources/evidently/config.yaml +0 -0
  91. {odctl-1.2.2 → odctl-1.4.0}/src/odctl/resources/flink/flink-conf.yml +0 -0
  92. {odctl-1.2.2 → odctl-1.4.0}/src/odctl/resources/grafana/dashboards/airflow.json +0 -0
  93. {odctl-1.2.2 → odctl-1.4.0}/src/odctl/resources/grafana/dashboards/clickhouse.json +0 -0
  94. {odctl-1.2.2 → odctl-1.4.0}/src/odctl/resources/grafana/dashboards/feast.json +0 -0
  95. {odctl-1.2.2 → odctl-1.4.0}/src/odctl/resources/grafana/dashboards/flink.json +0 -0
  96. {odctl-1.2.2 → odctl-1.4.0}/src/odctl/resources/grafana/dashboards/fluss.json +0 -0
  97. {odctl-1.2.2 → odctl-1.4.0}/src/odctl/resources/grafana/dashboards/iceberg-catalog.json +0 -0
  98. {odctl-1.2.2 → odctl-1.4.0}/src/odctl/resources/grafana/dashboards/kafka-connect.json +0 -0
  99. {odctl-1.2.2 → odctl-1.4.0}/src/odctl/resources/grafana/dashboards/kafka.json +0 -0
  100. {odctl-1.2.2 → odctl-1.4.0}/src/odctl/resources/grafana/dashboards/karapace.json +0 -0
  101. {odctl-1.2.2 → odctl-1.4.0}/src/odctl/resources/grafana/dashboards/marquez.json +0 -0
  102. {odctl-1.2.2 → odctl-1.4.0}/src/odctl/resources/grafana/dashboards/metabase.json +0 -0
  103. {odctl-1.2.2 → odctl-1.4.0}/src/odctl/resources/grafana/dashboards/mlflow.json +0 -0
  104. {odctl-1.2.2 → odctl-1.4.0}/src/odctl/resources/grafana/dashboards/openmetadata.json +0 -0
  105. {odctl-1.2.2 → odctl-1.4.0}/src/odctl/resources/grafana/dashboards/postgresql.json +0 -0
  106. {odctl-1.2.2 → odctl-1.4.0}/src/odctl/resources/grafana/dashboards/seaweedfs.json +0 -0
  107. {odctl-1.2.2 → odctl-1.4.0}/src/odctl/resources/grafana/dashboards/spark.json +0 -0
  108. {odctl-1.2.2 → odctl-1.4.0}/src/odctl/resources/grafana/dashboards/temporal.json +0 -0
  109. {odctl-1.2.2 → odctl-1.4.0}/src/odctl/resources/grafana/dashboards/trino.json +0 -0
  110. {odctl-1.2.2 → odctl-1.4.0}/src/odctl/resources/grafana/dashboards/valkey.json +0 -0
  111. {odctl-1.2.2 → odctl-1.4.0}/src/odctl/resources/grafana/odctl-dashboards.yaml +0 -0
  112. {odctl-1.2.2 → odctl-1.4.0}/src/odctl/resources/grafana/sources.yml +0 -0
  113. {odctl-1.2.2 → odctl-1.4.0}/src/odctl/resources/jmx/jmx-exporter.yml +0 -0
  114. {odctl-1.2.2 → odctl-1.4.0}/src/odctl/resources/marquez/marquez.yml +0 -0
  115. {odctl-1.2.2 → odctl-1.4.0}/src/odctl/resources/otelcol/odctl-receivers.yaml +0 -0
  116. {odctl-1.2.2 → odctl-1.4.0}/src/odctl/resources/postgres/01-init-databases.sh +0 -0
  117. {odctl-1.2.2 → odctl-1.4.0}/src/odctl/resources/prometheus/prometheus.yml +0 -0
  118. {odctl-1.2.2 → odctl-1.4.0}/src/odctl/resources/seaweed/s3.json +0 -0
  119. {odctl-1.2.2 → odctl-1.4.0}/src/odctl/resources/spark/log4j2.properties +0 -0
  120. {odctl-1.2.2 → odctl-1.4.0}/src/odctl/resources/spark/metrics.properties +0 -0
  121. {odctl-1.2.2 → odctl-1.4.0}/src/odctl/resources/spark/spark-defaults.conf +0 -0
  122. {odctl-1.2.2 → odctl-1.4.0}/src/odctl/resources/trino/access-control.properties +0 -0
  123. {odctl-1.2.2 → odctl-1.4.0}/src/odctl/resources/trino/catalog/clickhouse.properties +0 -0
  124. {odctl-1.2.2 → odctl-1.4.0}/src/odctl/resources/trino/catalog/iceberg.properties +0 -0
  125. {odctl-1.2.2 → odctl-1.4.0}/src/odctl/resources/trino/catalog/kafka.properties +0 -0
  126. {odctl-1.2.2 → odctl-1.4.0}/src/odctl/resources/trino/catalog/postgres.properties +0 -0
  127. {odctl-1.2.2 → odctl-1.4.0}/src/odctl/resources/trino/catalog/valkey.properties +0 -0
  128. {odctl-1.2.2 → odctl-1.4.0}/src/odctl/resources/trino/group-provider.properties +0 -0
  129. {odctl-1.2.2 → odctl-1.4.0}/src/odctl/resources/trino/groups.txt +0 -0
  130. {odctl-1.2.2 → odctl-1.4.0}/src/odctl/resources/trino/rules.json +0 -0
  131. {odctl-1.2.2 → odctl-1.4.0}/src/odctl/ui.py +0 -0
  132. {odctl-1.2.2 → odctl-1.4.0}/tests/conftest.py +0 -0
  133. {odctl-1.2.2 → odctl-1.4.0}/tests/docs_helpers.py +0 -0
  134. {odctl-1.2.2 → odctl-1.4.0}/tests/test_airflow_profile.py +0 -0
  135. {odctl-1.2.2 → odctl-1.4.0}/tests/test_cli.py +0 -0
  136. {odctl-1.2.2 → odctl-1.4.0}/tests/test_cli_extended.py +0 -0
  137. {odctl-1.2.2 → odctl-1.4.0}/tests/test_compose_profiles.py +0 -0
  138. {odctl-1.2.2 → odctl-1.4.0}/tests/test_config.py +0 -0
  139. {odctl-1.2.2 → odctl-1.4.0}/tests/test_confirmed_pages.py +0 -0
  140. {odctl-1.2.2 → odctl-1.4.0}/tests/test_diagrams.py +0 -0
  141. {odctl-1.2.2 → odctl-1.4.0}/tests/test_docker.py +0 -0
  142. {odctl-1.2.2 → odctl-1.4.0}/tests/test_evidently_profile.py +0 -0
  143. {odctl-1.2.2 → odctl-1.4.0}/tests/test_feature_docs.py +0 -0
  144. {odctl-1.2.2 → odctl-1.4.0}/tests/test_grafana_dashboards.py +0 -0
  145. {odctl-1.2.2 → odctl-1.4.0}/tests/test_logs.py +0 -0
  146. {odctl-1.2.2 → odctl-1.4.0}/tests/test_metrics_collector.py +0 -0
  147. {odctl-1.2.2 → odctl-1.4.0}/tests/test_metrics_jvm.py +0 -0
  148. {odctl-1.2.2 → odctl-1.4.0}/tests/test_metrics_python_services.py +0 -0
  149. {odctl-1.2.2 → odctl-1.4.0}/tests/test_planner.py +0 -0
  150. {odctl-1.2.2 → odctl-1.4.0}/tests/test_postgres_image.py +0 -0
  151. {odctl-1.2.2 → odctl-1.4.0}/tests/test_projects.py +0 -0
  152. {odctl-1.2.2 → odctl-1.4.0}/tests/test_registry.py +0 -0
  153. {odctl-1.2.2 → odctl-1.4.0}/tests/test_release_links.py +0 -0
  154. {odctl-1.2.2 → odctl-1.4.0}/tests/test_release_notes.py +0 -0
  155. {odctl-1.2.2 → odctl-1.4.0}/tests/test_telemetry_profile.py +0 -0
  156. {odctl-1.2.2 → odctl-1.4.0}/tests/test_temporal_profile.py +0 -0
@@ -45,6 +45,11 @@ GROUPS = [
45
45
  "lineage",
46
46
  "telemetry",
47
47
  "metadata",
48
+ # The part before the colon is the profile to start. metadata:deps and
49
+ # metadata:autopilot start the same profile as metadata. Each examples group
50
+ # recreates metadata twice, which is why each runs alone, in parallel with the others.
51
+ "metadata:deps",
52
+ "metadata:autopilot",
48
53
  "airflow",
49
54
  "temporal",
50
55
  "postgres",
@@ -70,13 +75,14 @@ REGISTRY = Path("src/odctl/resources/registry.yml")
70
75
 
71
76
  # smoke.sh starts some profiles from another group's case: the kafka case starts
72
77
  # catalog, the flink case starts kafka-lite and feast, the spark case starts
73
- # kafka-lite, and the fluss and feast cases start flink-lite. A change to that
74
- # compose file needs these groups as well as the ones the registry maps to it.
78
+ # kafka-lite, the fluss and feast cases start flink-lite, and the airflow case
79
+ # starts feast. A change to that compose file needs these groups as well as the
80
+ # ones the registry maps to it.
75
81
  ALSO_RUNS = {
76
82
  "compose-infra.yml": ["kafka-lite kafka-full"],
77
83
  "compose-kafka.yml": ["flink-lite flink-full", "spark-lite spark-full"],
78
84
  "compose-flink.yml": ["fluss", "feast"],
79
- "compose-mlops.yml": ["flink-lite flink-full"],
85
+ "compose-mlops.yml": ["flink-lite flink-full", "airflow"],
80
86
  }
81
87
 
82
88
  # smoke.sh runs the guide commands in docs/snippets/<guide>/, so a change there
@@ -85,7 +91,15 @@ ALSO_RUNS = {
85
91
  SNIPPET_PREFIX = "docs/snippets/"
86
92
  SNIPPET_GROUPS = {
87
93
  "getting-started": ["kafka-lite kafka-full"],
88
- "how-it-works": ["storage", "catalog", "trino", "airflow"],
94
+ "how-it-works": [
95
+ "storage",
96
+ "catalog",
97
+ "trino",
98
+ "airflow",
99
+ "mlflow",
100
+ "metadata:deps",
101
+ "metadata:autopilot",
102
+ ],
89
103
  "kafka": ["kafka-lite kafka-full"],
90
104
  "kafka-connect": ["kafka-lite kafka-full"],
91
105
  "iceberg-ingestion": ["kafka-lite kafka-full", "flink-lite flink-full"],
@@ -100,10 +114,11 @@ SNIPPET_GROUPS = {
100
114
  "airflow-dags": ["airflow"],
101
115
  "temporal-workers": ["temporal"],
102
116
  "mlflow": ["mlflow"],
103
- "feast-iceberg": ["feast", "flink-lite flink-full", "mlflow"],
117
+ "feast-iceberg": ["feast", "flink-lite flink-full", "mlflow", "airflow"],
118
+ "feast-vector-retrieval": ["feast"],
104
119
  "evidently-reports": ["evidently"],
105
120
  "lineage": ["lineage"],
106
- "openmetadata": ["metadata"],
121
+ "openmetadata": ["metadata", "metadata:autopilot"],
107
122
  "telemetry": ["telemetry"],
108
123
  }
109
124
 
@@ -174,7 +189,8 @@ def select(changed: list, registry_path: Path = REGISTRY) -> list:
174
189
 
175
190
  selected = []
176
191
  for group in GROUPS:
177
- files = {mapping.get(p) for p in group.split()}
192
+ # A group name may carry a suffix after a colon, as metadata:deps does.
193
+ files = {mapping.get(p.split(":")[0]) for p in group.split()}
178
194
  also = any(group in ALSO_RUNS.get(name, []) for name in touched_files)
179
195
  if files & touched_files or group in snippet_groups or also:
180
196
  selected.append(group)
@@ -9,10 +9,10 @@
9
9
  # committed nothing while its checkpoints succeeded. Each assertion below exists
10
10
  # because something shipped broken past a liveness check.
11
11
  #
12
- # Usage: smoke.sh <profile>
12
+ # Usage: smoke.sh <profile or group name, such as metadata:deps>
13
13
  set -uo pipefail
14
14
 
15
- PROFILE="${1:?usage: smoke.sh <profile>}"
15
+ PROFILE="${1:?usage: smoke.sh <profile or group name, such as metadata:deps>}"
16
16
  MB_USER="smoke@example.com"
17
17
  MB_PASS="Smoke-test-1234"
18
18
 
@@ -880,6 +880,104 @@ if bad:
880
880
 
881
881
  # Airflow pushes OTLP to the telemetry collector rather than being scraped.
882
882
  assert_pushed_metrics 'airflow_.+' Airflow
883
+
884
+ smoke_airflow_materialize
885
+ }
886
+
887
+ # Feast guide's On a Schedule section: a DAG that builds a FeatureStore from
888
+ # FEAST_REGISTRY and calls materialize_incremental. It needs the registry entry and
889
+ # the Iceberg table the guide's earlier steps make, so this runs those from the
890
+ # host as the guide does, then uploads the DAG, runs it and reads the value it
891
+ # copied into Valkey. The feature view source has no catalog address, so the
892
+ # registry entry works from the host here and from the Airflow container in the DAG.
893
+ # Its own project, so that the guide's Runnable Example can still register demo.
894
+ smoke_airflow_materialize() {
895
+ odctl up feast >/dev/null 2>&1 || fail "could not start feast for the materialise DAG"
896
+ retry 30 5 http_ok "http://127.0.0.1:6566/health" || fail "Feast never answered on :6566"
897
+
898
+ local work="${TMPDIR:-/tmp}/odctl-airflow-feast-smoke"
899
+ rm -rf "$work"; mkdir -p "$work"
900
+ uv venv "$work/.venv" >/dev/null 2>&1 || fail "could not create a venv for the feast client"
901
+ VIRTUAL_ENV="$work/.venv" uv pip install -q \
902
+ "feast[duckdb,iceberg,redis,postgres]==0.66.0" "pyarrow" >/dev/null 2>&1 \
903
+ || fail "could not install the feast client"
904
+
905
+ cp -r "$SNIPPETS/feast-iceberg/feature_repo" "$work/"
906
+ sed 's/^project: demo$/project: airflow_smoke/' "$work/feature_repo/feature_store.yaml" \
907
+ > "$work/feature_store.yaml" && mv "$work/feature_store.yaml" "$work/feature_repo/feature_store.yaml"
908
+ sed 's/project="demo"/project="airflow_smoke"/' "$SNIPPETS/feast-iceberg/feast_materialize.py" \
909
+ > "$work/feast_materialize.py"
910
+ snippet feast-iceberg/steps.py create-table > "$work/build.py"
911
+ snippet feast-iceberg/steps.py append > "$work/append.py"
912
+ snippet feast-iceberg/steps.py read > "$work/read.py"
913
+
914
+ local py="$work/.venv/bin/python" log="$work/step.log"
915
+ run_step() {
916
+ local what="$1"; shift
917
+ if ! (cd "$work" && "$@") >"$log" 2>&1; then
918
+ echo "---- $what ----"
919
+ tail -25 "$log"
920
+ fail "$what"
921
+ fi
922
+ }
923
+
924
+ # A table left by the Runnable Example would make the guide's create step fail.
925
+ local code
926
+ code=$(curl -sS --max-time 10 -o /dev/null -w '%{http_code}' -X DELETE "http://127.0.0.1:8181/v1/namespaces/demo/tables/driver_stats?purgeRequested=true")
927
+ case "$code" in 204|404) ;; *) fail "could not delete demo.driver_stats before the guide's create step (HTTP $code)";; esac
928
+ eval "$(snippet feast-iceberg/commands.sh catalog-env)"
929
+ run_step "could not create the Iceberg table through the catalog" "$py" build.py
930
+ # Delete the view, so that materialise starts again and the check reads this run's values.
931
+ (cd "$work" && "$work/.venv/bin/feast" -c feature_repo delete driver_stats) >/dev/null 2>&1 || true
932
+ run_step "feast apply or materialize failed without a catalog address in the source" \
933
+ env PATH="$work/.venv/bin:$PATH" bash -e -o pipefail -c "$(snippet feast-iceberg/commands.sh apply-materialize)"
934
+ pass "the guide's source, with no catalog address, applied and materialised from the host"
935
+
936
+ run_step "could not upload the materialise DAG to s3://airflow/dags" \
937
+ run_snippet feast-iceberg/commands.sh dag-upload
938
+ retry 30 10 bash -c \
939
+ "docker exec airflow airflow dags list 2>/dev/null | grep -q feast_materialize" \
940
+ || fail "the S3 DAG bundle never delivered the materialise DAG, or it was never parsed"
941
+
942
+ # The new row is newer than the host's materialise, so only the DAG can copy it.
943
+ run_step "could not append a row to the Iceberg table" "$py" append.py
944
+ run_step "the guide's unpause and trigger commands failed" \
945
+ run_snippet feast-iceberg/commands.sh dag-run
946
+
947
+ # Unpausing also starts a scheduled run, so wait until every run has finished.
948
+ local states=""
949
+ for _ in $(seq 1 30); do
950
+ states=$(docker exec airflow airflow dags list-runs feast_materialize -o plain 2>/dev/null \
951
+ | grep -E "^feast_materialize" | tr -s ' ' | cut -d' ' -f3)
952
+ grep -qE 'queued|running' <<<"$states" || [ -z "$states" ] || break
953
+ sleep 10
954
+ done
955
+ [ -n "$states" ] && ! grep -qvx success <<<"$states" \
956
+ || fail "the materialise DAG runs ended in states: ${states:-none}"
957
+ pass "the materialise DAG ran to success inside the Airflow container"
958
+
959
+ local got
960
+ got=$(cd "$work" && "$py" read.py 2>"$log") || { tail -25 "$log"; fail "the online read failed"; }
961
+ [ "$got" = "{'driver_id': [1001, 1002], 'conv_rate': [0.6000000238418579, 0.75]}" ] \
962
+ || fail "Valkey holds $got, not the 0.6 the DAG should have copied"
963
+ pass "the DAG copied the new value from Iceberg into Valkey"
964
+
965
+ # Leave no hourly run behind: a DAG that stays unpaused fails on its next run once
966
+ # the table below is gone, and the next smoke run would read that failed run.
967
+ docker exec airflow airflow dags pause feast_materialize >/dev/null 2>&1 \
968
+ || fail "could not pause the materialise DAG"
969
+ docker exec airflow python -c '
970
+ import boto3
971
+ boto3.client("s3", endpoint_url="http://seaweed:8333").delete_object(
972
+ Bucket="airflow", Key="dags/feast_materialize.py")' >/dev/null 2>&1 \
973
+ || fail "could not delete the materialise DAG from s3://airflow/dags"
974
+
975
+ # No feast teardown: it leaves the view's row in feature_view_version_history, and
976
+ # a second run of this check would then fail to apply the view again.
977
+ curl -fsS --max-time 10 -X DELETE \
978
+ "http://127.0.0.1:8181/v1/namespaces/demo/tables/driver_stats?purgeRequested=true" >/dev/null 2>&1 || true
979
+ curl -fsS --max-time 10 -X DELETE "http://127.0.0.1:8181/v1/namespaces/demo" >/dev/null 2>&1 || true
980
+ rm -rf "$work"
883
981
  }
884
982
 
885
983
  # deps is a one-shot copy into a shared volume, so there is no container to
@@ -1094,7 +1192,7 @@ smoke_fluss() {
1094
1192
  pass "Flink wrote 3 rows to a Fluss table and read them back"
1095
1193
  }
1096
1194
 
1097
- # OpenMetadata: the version endpoint answers through every failure the 2.0.1
1195
+ # OpenMetadata: the version endpoint answers through every failure an
1098
1196
  # upgrade could cause. The ingestion container runs its own Airflow 3, whose
1099
1197
  # health path moved to /api/v2/monitor/health, and a stale image or a failed
1100
1198
  # migration both leave a server that still reports a version. Drive the whole
@@ -1110,14 +1208,23 @@ smoke_metadata() {
1110
1208
  pass "schema migration completed"
1111
1209
  assert_metrics http://openmetadata-server:8586/prometheus
1112
1210
 
1113
- # The principal domain odctl sets is open-data.local, not the upstream
1211
+ # The principal domain odctl sets is odctl.io, not the upstream
1114
1212
  # default. A wrong domain fails with a misleading invalid password error.
1115
1213
  local token
1116
1214
  token=$(om_step login 'echo "$TOKEN"' 2>/dev/null | tail -1)
1117
- [ -n "$token" ] || fail "admin@open-data.local could not log in"
1215
+ [ -n "$token" ] || fail "admin@odctl.io could not log in"
1118
1216
  pass "admin logged in and received a JWT"
1119
1217
  local auth="Authorization: Bearer $token"
1120
1218
 
1219
+ # user is the second admin. Its entry in AUTHORIZER_ADMIN_PRINCIPALS,
1220
+ # user:password, sets its first password.
1221
+ local code
1222
+ code=$(curl -s -o /dev/null -w '%{http_code}' -H 'Content-Type: application/json' \
1223
+ -d "{\"email\":\"user@odctl.io\",\"password\":\"$(printf password | base64)\"}" \
1224
+ http://127.0.0.1:8585/api/v1/users/login)
1225
+ [ "$code" = "200" ] || fail "user@odctl.io could not log in with the password password (HTTP $code)"
1226
+ pass "user@odctl.io logged in with the password password"
1227
+
1121
1228
  # Airflow 3 moved this path. The old /health returns 404 with a message saying so.
1122
1229
  retry 60 5 http_ok "http://127.0.0.1:8087/api/v2/monitor/health" \
1123
1230
  || fail "ingestion Airflow never answered on 8087"
@@ -1126,6 +1233,20 @@ smoke_metadata() {
1126
1233
  || fail "the server cannot reach the ingestion container, so no pipeline can deploy"
1127
1234
  pass "ingestion Airflow healthy and reachable from the server"
1128
1235
 
1236
+ # AIRFLOW_ADMIN_USER and AIRFLOW_ADMIN_PASSWORD set this login. A new token
1237
+ # answers 201, and a wrong login 401.
1238
+ code=$(curl -s -o /dev/null -w '%{http_code}' -H 'Content-Type: application/json' \
1239
+ -d '{"username":"user","password":"password"}' http://127.0.0.1:8087/auth/token)
1240
+ [ "$code" = "201" ] || fail "the ingestion Airflow refused user / password (HTTP $code)"
1241
+ pass "ingestion Airflow accepted user / password"
1242
+
1243
+ # openmetadata-init sets AutoPilot from METADATA_AUTOPILOT, false by default.
1244
+ retry 24 5 om_autopilot_is "$token" False \
1245
+ || fail "AutoPilot does not report active: false after the start"
1246
+ [ "$(docker inspect -f '{{.State.ExitCode}}' openmetadata-init)" = "0" ] \
1247
+ || fail "openmetadata-init did not exit with code 0"
1248
+ pass "AutoPilot reports active: false and openmetadata-init exited 0"
1249
+
1129
1250
  # A real ingestion, with the guide's steps. Postgres is already running, so it
1130
1251
  # is its own source.
1131
1252
  local svc
@@ -1142,23 +1263,11 @@ smoke_metadata() {
1142
1263
  # Airflow's dag_processor has to parse the new file before it can be
1143
1264
  # triggered, so the guide's trigger can come too early. Trigger again here.
1144
1265
  if ! grep -q 'has been triggered' <<<"$out"; then
1145
- local triggered=no
1146
- for _ in $(seq 1 24); do
1147
- if curl -fsS --max-time 90 -X POST -H "$auth" \
1148
- "http://127.0.0.1:8585/api/v1/services/ingestionPipelines/trigger/$pipe" 2>/dev/null \
1149
- | grep -q 'has been triggered'; then triggered=yes; break; fi
1150
- sleep 5
1151
- done
1152
- [ "$triggered" = "yes" ] || fail "the pipeline never triggered"
1266
+ om_trigger "$token" "$pipe" || fail "the pipeline never triggered"
1153
1267
  fi
1154
1268
 
1155
- local state=""
1156
- for _ in $(seq 1 30); do
1157
- state=$(TOKEN="$token" om_step status 2>/dev/null \
1158
- | python3 -c 'import json,sys; s=json.load(sys.stdin).get("pipelineStatuses") or []; print(s[0]["pipelineState"] if s else "")' 2>/dev/null)
1159
- case "$state" in success|failed|partialSuccess) break ;; esac
1160
- sleep 10
1161
- done
1269
+ local state
1270
+ state=$(om_run_state "$token" om_step "")
1162
1271
  [ "$state" = "success" ] || fail "ingestion run finished in state ${state:-none}, not success"
1163
1272
  pass "ingestion run reached success"
1164
1273
 
@@ -1191,8 +1300,214 @@ smoke_metadata() {
1191
1300
  done
1192
1301
  pass "Context Center pages and memories both answer"
1193
1302
 
1303
+ smoke_metadata_connector "$token"
1304
+ smoke_metadata_autopilot "$token"
1305
+
1194
1306
  TOKEN="$token" om_step remove >/dev/null 2>&1 || fail "the guide's remove step failed"
1195
- pass "the guide's remove step deleted the service and its pipeline"
1307
+ code=$(curl -s -o /dev/null -w '%{http_code}' -H "$auth" \
1308
+ http://127.0.0.1:8585/api/v1/services/databaseServices/name/demo_connector)
1309
+ [ "$code" = "404" ] || fail "the guide's remove step left the demo_connector service (HTTP $code)"
1310
+ retry 12 5 bash -c '! docker exec openmetadata-ingestion test -e /opt/airflow/connectors/demo_connector.py' \
1311
+ || fail "the ingestion container kept the connector after the remove step deleted it from S3"
1312
+ pass "the guide's remove step deleted both services, their pipelines and the uploaded connector"
1313
+ }
1314
+
1315
+ # Trigger an ingestion pipeline until the server accepts it: Airflow has to parse
1316
+ # a new DAG file first. Usage: om_trigger <token> <pipeline id>
1317
+ om_trigger() {
1318
+ local _
1319
+ for _ in $(seq 1 24); do
1320
+ if curl -fsS --max-time 90 -X POST -H "Authorization: Bearer $1" \
1321
+ "http://127.0.0.1:8585/api/v1/services/ingestionPipelines/trigger/$2" 2>/dev/null \
1322
+ | grep -q 'has been triggered'; then return 0; fi
1323
+ sleep 5
1324
+ done
1325
+ return 1
1326
+ }
1327
+
1328
+ # The latest run of the pipeline a guide's status step reads, as "<run id> <state>",
1329
+ # or nothing when there is no run yet. The step is the guide's own: om_step for
1330
+ # demo_pg, om_connector_step for the custom connector.
1331
+ # Usage: om_latest_run <token> <om_step|om_connector_step>
1332
+ om_latest_run() {
1333
+ TOKEN="$1" "$2" status 2>/dev/null | python3 -c '
1334
+ import json, sys
1335
+ runs = json.load(sys.stdin).get("pipelineStatuses") or []
1336
+ if runs:
1337
+ print(runs[0]["runId"], runs[0]["pipelineState"])' 2>/dev/null
1338
+ }
1339
+
1340
+ # Wait for the first run whose id is not <previous run id> to finish, and print its
1341
+ # state. Usage: om_run_state <token> <om_step|om_connector_step> <previous run id>
1342
+ om_run_state() {
1343
+ local _ run state
1344
+ for _ in $(seq 1 30); do
1345
+ read -r run state < <(om_latest_run "$1" "$2")
1346
+ if [ -n "${run:-}" ] && [ "$run" != "$3" ]; then
1347
+ case "$state" in success|failed|partialSuccess) echo "$state"; return ;; esac
1348
+ fi
1349
+ sleep 10
1350
+ done
1351
+ echo ""
1352
+ }
1353
+
1354
+ # Whether the guide's autopilot step prints <value>. Usage: om_autopilot_is <token> <True|False>
1355
+ om_autopilot_is() {
1356
+ [ "$(TOKEN="$1" om_step autopilot 2>/dev/null | tail -1)" = "$2" ]
1357
+ }
1358
+
1359
+ # Add a service the way the UI does: create it, then trigger AutoPilotApplication with
1360
+ # the service's entityLink. Usage: om_add_service_like_the_ui <token> <service name>
1361
+ om_add_service_like_the_ui() {
1362
+ local auth="Authorization: Bearer $1"
1363
+ curl -fsS -X PUT -H "$auth" -H 'Content-Type: application/json' \
1364
+ -d "{\"name\":\"$2\",\"serviceType\":\"CustomDatabase\",\"connection\":{\"config\":{\"type\":\"CustomDatabase\",\"sourcePythonClass\":\"demo_connector.DemoSource\"}}}" \
1365
+ http://127.0.0.1:8585/api/v1/services/databaseServices >/dev/null || fail "could not create the $2 service"
1366
+ curl -fsS -X POST -H "$auth" -H 'Content-Type: application/json' \
1367
+ -d "{\"entityLink\":\"<#E::databaseService::$2>\"}" \
1368
+ http://127.0.0.1:8585/api/v1/apps/trigger/AutoPilotApplication >/dev/null \
1369
+ || fail "the server refused the AutoPilot trigger the UI sends"
1370
+ }
1371
+
1372
+ # The number of agents a service has. Usage: om_agent_count <token> <service name>
1373
+ om_agent_count() {
1374
+ curl -fsS -H "Authorization: Bearer $1" "http://127.0.0.1:8585/api/v1/services/ingestionPipelines?service=$2" \
1375
+ | python3 -c 'import json,sys; print(json.load(sys.stdin)["paging"]["total"])'
1376
+ }
1377
+
1378
+ om_delete_service() {
1379
+ curl -s -o /dev/null -X DELETE -H "Authorization: Bearer $1" \
1380
+ "http://127.0.0.1:8585/api/v1/services/databaseServices/name/$2?hardDelete=true&recursive=true"
1381
+ }
1382
+
1383
+ # With AutoPilot off, a service added the way the UI adds one gets no agent. The
1384
+ # check waits 60 seconds, as long as the on-case below allows AutoPilot to create its agent.
1385
+ smoke_metadata_autopilot() {
1386
+ local agents
1387
+ om_add_service_like_the_ui "$1" demo_ui
1388
+ sleep 60
1389
+ agents=$(om_agent_count "$1" demo_ui)
1390
+ om_delete_service "$1" demo_ui
1391
+ [ "$agents" = "0" ] || fail "a service added the way the UI adds one got $agents agents with AutoPilot off"
1392
+ pass "a service added the way the UI adds one, with its AutoPilot trigger, got no agent"
1393
+ }
1394
+
1395
+ # The same two calls with AutoPilot on create at least one agent. It proves
1396
+ # METADATA_AUTOPILOT=true works and that the off-case above could have failed.
1397
+ smoke_metadata_autopilot_on() {
1398
+ local agents=0 _
1399
+ om_add_service_like_the_ui "$1" demo_ui_on
1400
+ for _ in $(seq 1 12); do
1401
+ agents=$(om_agent_count "$1" demo_ui_on)
1402
+ [ "${agents:-0}" -ge 1 ] && break
1403
+ sleep 5
1404
+ done
1405
+ om_delete_service "$1" demo_ui_on
1406
+ [ "${agents:-0}" -ge 1 ] || fail "a service added the way the UI adds one got no agent with AutoPilot on"
1407
+ pass "with METADATA_AUTOPILOT true, a service added the way the UI adds one got $agents agent(s)"
1408
+ }
1409
+
1410
+ # The How It Works example turns AutoPilot on and recreates metadata. While it is on,
1411
+ # check the UI's calls create an agent, then put the key back and check it is off.
1412
+ how_it_works_autopilot() {
1413
+ local out
1414
+ out=$(run_snippet how-it-works/commands.sh metadata-autopilot 2>&1) || { echo "$out" | tail -20; fail "the metadata-autopilot example failed"; }
1415
+ grep -qE -- 'AutoPilot active: true' <<<"$out" || { echo "$out" | tail -20; fail "the metadata-autopilot example printed no 'AutoPilot active: true'"; }
1416
+ pass "How It Works' metadata-autopilot example printed 'AutoPilot active: true'"
1417
+ smoke_metadata_autopilot_on "$(om_token)"
1418
+ mv .odctl/.env.bak .odctl/.env || fail "the metadata-autopilot example left no .env.bak"
1419
+ odctl recreate metadata >/dev/null 2>&1 || fail "could not recreate metadata after the metadata-autopilot example"
1420
+ retry 24 5 om_autopilot_is "$(om_token)" False \
1421
+ || fail "AutoPilot is not off again after the key was put back"
1422
+ pass "AutoPilot is off again after the key was put back"
1423
+ }
1424
+
1425
+ # Run a connector section until its output has <text>. Usage: om_connector_says <section> <text>
1426
+ om_connector_says() {
1427
+ om_connector_step "$1" 2>/dev/null | grep -qF "$2"
1428
+ }
1429
+
1430
+ # The guide's custom connector steps, run from the folder that holds the file, as
1431
+ # the guide tells the reader. Usage: om_connector_step <section> [extra commands]
1432
+ om_connector_step() {
1433
+ local code
1434
+ code=$(snippet openmetadata/connector/commands.sh "$1")
1435
+ (cd "$CONNECTOR_DIR" && bash -o pipefail -c 'AUTH="Authorization: Bearer ${TOKEN:-}"
1436
+ '"$code"'
1437
+ '"${2:-}")
1438
+ }
1439
+
1440
+ # A custom connector uploaded to s3://openmetadata/connectors is importable in the
1441
+ # ingestion container, and a service and an agent made through the API run it.
1442
+ # Then a changed connector is used by the next run with no restart, which is why
1443
+ # nothing here restarts the container: its start time must not move.
1444
+ smoke_metadata_connector() {
1445
+ local token="$1" auth="Authorization: Bearer $1" started svc pipe out run state agents
1446
+ CONNECTOR_DIR=$(mktemp -d)
1447
+ cp "$SNIPPETS/openmetadata/connector/demo_connector.py" "$CONNECTOR_DIR/"
1448
+ started=$(docker inspect -f '{{.State.StartedAt}}' openmetadata-ingestion)
1449
+
1450
+ om_connector_step upload >/dev/null 2>&1 || fail "could not upload the connector to s3://openmetadata/connectors"
1451
+ retry 12 5 om_connector_says copied demo_connector.py \
1452
+ || fail "the ingestion container never copied the connector from S3"
1453
+ pass "the connector reached /opt/airflow/connectors in the ingestion container"
1454
+
1455
+ svc=$(TOKEN="$token" om_connector_step service 'echo "$SVC"' 2>/dev/null | tail -1)
1456
+ [ -n "$svc" ] || fail "could not create the CustomDatabase service through the API"
1457
+ out=$(TOKEN="$token" SVC="$svc" om_connector_step agent 'echo; echo "$PIPE"' 2>/dev/null)
1458
+ pipe=$(tail -1 <<<"$out")
1459
+ [ -n "$pipe" ] || fail "could not create the custom connector's agent"
1460
+ grep -q 'has been created' <<<"$out" || { echo "$out"; fail "the custom connector's agent did not deploy a DAG"; }
1461
+ grep -q 'has been triggered' <<<"$out" || om_trigger "$token" "$pipe" || fail "the custom connector's agent never triggered"
1462
+ state=$(om_run_state "$token" om_connector_step "")
1463
+ [ "$state" = "success" ] || fail "the custom connector's run finished in state ${state:-none}, not success"
1464
+ out=$(TOKEN="$token" om_connector_step table 2>/dev/null)
1465
+ grep -q '"fullyQualifiedName":"demo_connector.demo_db.public.orders"' <<<"$out" \
1466
+ && grep -q '"name":"id"' <<<"$out" && grep -q '"name":"amount"' <<<"$out" \
1467
+ || fail "the custom connector's table orders, with its columns id and amount, is not in the catalogue"
1468
+ pass "the custom connector's agent ran and its table orders is in the catalogue"
1469
+
1470
+ # A service made through the API starts no AutoPilot agents. The UI does.
1471
+ agents=$(curl -fsS -H "$auth" "http://127.0.0.1:8585/api/v1/services/ingestionPipelines?service=demo_connector" \
1472
+ | python3 -c 'import json,sys; print(json.load(sys.stdin)["paging"]["total"])')
1473
+ [ "$agents" = "1" ] || fail "the demo_connector service has $agents agents, not only its own"
1474
+ pass "the demo_connector service has only its own agent"
1475
+
1476
+ # Change the connector, upload it again and trigger the same agent.
1477
+ read -r run _ < <(om_latest_run "$token" om_connector_step)
1478
+ om_connector_step change >/dev/null 2>&1 || fail "could not change the connector"
1479
+ om_connector_step upload >/dev/null 2>&1 || fail "could not upload the changed connector"
1480
+ retry 12 5 om_connector_says copied-change 1 \
1481
+ || fail "the ingestion container never copied the changed connector"
1482
+ TOKEN="$token" PIPE="$pipe" om_connector_step retrigger 2>/dev/null | grep -q 'has been triggered' \
1483
+ || fail "the custom connector's agent did not trigger again"
1484
+ state=$(om_run_state "$token" om_connector_step "$run")
1485
+ [ "$state" = "success" ] || fail "the second run finished in state ${state:-none}, not success"
1486
+ TOKEN="$token" om_connector_step changed-table 2>/dev/null \
1487
+ | grep -q '"name":"orders_v2"' || fail "the changed connector's table orders_v2 is not in the catalogue"
1488
+ [ "$(docker inspect -f '{{.State.StartedAt}}' openmetadata-ingestion)" = "$started" ] \
1489
+ || fail "the ingestion container restarted while the connector changed"
1490
+ pass "the changed connector was used by the next run, with no restart"
1491
+ rm -rf "$CONNECTOR_DIR"
1492
+ }
1493
+
1494
+ # The guide's runnable example needs demo_connector.py in the folder it runs in.
1495
+ smoke_metadata_example() {
1496
+ local out
1497
+ cp "$SNIPPETS/openmetadata/connector/demo_connector.py" .
1498
+ out=$(bash "$SNIPPETS/openmetadata/example.sh" 2>&1) || { rm -f demo_connector.py; echo "$out" | tail -20; fail "the openmetadata example failed"; }
1499
+ rm -f demo_connector.py
1500
+ local want
1501
+ for want in 'autopilot active: False' 'demo_pg ingestion: success' 'demo_connector ingestion: success' \
1502
+ '^custom table: orders$' 'removed demo_pg and demo_connector'; do
1503
+ grep -qE -- "$want" <<<"$out" || { echo "$out" | tail -20; fail "the openmetadata example printed no '$want'"; }
1504
+ done
1505
+ pass "the openmetadata guide's runnable example ran both services and printed its results"
1506
+ }
1507
+
1508
+ # An admin token, from the guide's login step.
1509
+ om_token() {
1510
+ om_step login 'echo "$TOKEN"' 2>/dev/null | tail -1
1196
1511
  }
1197
1512
 
1198
1513
  # The OpenMetadata guide's steps share shell variables: login sets TOKEN, the
@@ -1210,7 +1525,9 @@ om_step() {
1210
1525
  # Feast keeps no feature values of its own, so liveness proves nothing here.
1211
1526
  # This builds a real Iceberg table through the catalog, registers a feature view
1212
1527
  # over it, takes a point-in-time join, materializes to Valkey and reads it back.
1213
- # A failure in any of those is a broken profile even when both containers are up.
1528
+ # It also retrieves the nearest rows from a second repository on PostgreSQL with
1529
+ # pgvector. A failure in any of those is a broken profile even when both
1530
+ # containers are up.
1214
1531
  smoke_feast() {
1215
1532
  retry 60 5 http_ok "http://127.0.0.1:8890/api/v1/projects" || fail "no HTTP response from the feast UI on :8890"
1216
1533
  pass "feast UI answering, registry reachable"
@@ -1225,17 +1542,16 @@ smoke_feast() {
1225
1542
  # the published image carries feast[minimal], which has no iceberg or duckdb.
1226
1543
  uv venv "$work/.venv" >/dev/null 2>&1 || fail "could not create a venv for the feast client"
1227
1544
  VIRTUAL_ENV="$work/.venv" uv pip install -q \
1228
- "feast[duckdb,iceberg,redis,postgres]==0.66.0" "pyarrow" >/dev/null 2>&1 \
1229
- || fail "could not install the feast client"
1545
+ "feast[duckdb,iceberg,redis,postgres]==0.66.0" "pyarrow" "mcp>=1.24,<2" >/dev/null 2>&1 \
1546
+ || fail "could not install the feast and mcp clients"
1230
1547
 
1231
- # Repository from the guide, with warehouse="" in its source. Feast's REST client
1232
- # puts warehouse into the URL path as a prefix for Polaris and Nessie style
1233
- # catalogs, and Iceberg's REST fixture server serves the spec with no prefix,
1234
- # so a real warehouse gives HTTP 400 "Ambiguous URI empty segment".
1548
+ # Repository from the guide, whose source passes warehouse="" so that PyIceberg
1549
+ # takes the warehouse from PYICEBERG_CATALOG__ODCTL__WAREHOUSE.
1235
1550
  cp "$SNIPPETS/feast-iceberg/feature_repo/"* "$work/feature_repo/"
1236
- # Its own project, because the guide's Runnable Example registers the same
1237
- # feature view in demo afterwards. feast teardown leaves the view's row in
1238
- # feature_view_version_history, so a second apply in demo fails on its key.
1551
+ # Its own project, so that the guide's Runnable Example can still register demo.
1552
+ # No feast teardown: it deletes every project in the registry, and it leaves each
1553
+ # view's row in feature_view_version_history, so the next apply of that view fails
1554
+ # on its key. feast delete removes one view and its online values, and the view applies again.
1239
1555
  sed 's/^project: demo$/project: smoke/' "$work/feature_repo/feature_store.yaml" \
1240
1556
  > "$work/feature_store.yaml" && mv "$work/feature_store.yaml" "$work/feature_repo/feature_store.yaml"
1241
1557
 
@@ -1286,11 +1602,17 @@ PYONLINE
1286
1602
  fi
1287
1603
  }
1288
1604
 
1605
+ # A table left by the Runnable Example or by a reader would make the guide's create step fail.
1606
+ local code
1607
+ code=$(curl -sS --max-time 10 -o /dev/null -w '%{http_code}' -X DELETE "http://127.0.0.1:8181/v1/namespaces/demo/tables/driver_stats?purgeRequested=true")
1608
+ case "$code" in 204|404) ;; *) fail "could not delete demo.driver_stats before the guide's create step (HTTP $code)";; esac
1289
1609
  run_step "could not create the Iceberg table through the catalog" "$py" build.py
1290
1610
  pass "Iceberg table created through the REST catalog on SeaweedFS"
1291
1611
 
1612
+ # Delete the view, so that materialise starts again and the check reads this run's values.
1613
+ (cd "$work" && "$feast_bin" -c feature_repo delete driver_stats) >/dev/null 2>&1 || true
1292
1614
  run_step "feast apply or materialize failed" \
1293
- env PATH="$work/.venv/bin:$PATH" bash -o pipefail -c "$(snippet feast-iceberg/commands.sh apply-materialize)"
1615
+ env PATH="$work/.venv/bin:$PATH" bash -e -o pipefail -c "$(snippet feast-iceberg/commands.sh apply-materialize)"
1294
1616
  pass "feast apply registered the feature view, and materialize wrote to valkey"
1295
1617
 
1296
1618
  run_step "the point-in-time join returned the wrong values" "$py" check.py
@@ -1299,7 +1621,25 @@ PYONLINE
1299
1621
  run_step "the online read from valkey returned the wrong values" "$py" online.py
1300
1622
  pass "get_online_features read the materialised values from valkey"
1301
1623
 
1302
- (cd "$work" && "$feast_bin" -c feature_repo teardown) >/dev/null 2>&1 || true
1624
+ # Vector retrieval, from the vector page: a second repository, with
1625
+ # PostgreSQL and pgvector as its online store. Its own project, because the
1626
+ # Runnable Example registers vector_demo afterwards.
1627
+ mkdir -p "$work/vector_repo"
1628
+ cp "$SNIPPETS/feast-vector-retrieval/vector_repo/"* "$work/vector_repo/"
1629
+ sed 's/^project: vector_demo$/project: vector_smoke/' "$work/vector_repo/feature_store.yaml" \
1630
+ > "$work/feature_store.yaml" && mv "$work/feature_store.yaml" "$work/vector_repo/feature_store.yaml"
1631
+ snippet feast-vector-retrieval/steps.py write > "$work/vector_write.py"
1632
+ snippet feast-vector-retrieval/steps.py retrieve > "$work/vector_retrieve.py"
1633
+ run_step "feast apply failed on the vector repository" \
1634
+ env PATH="$work/.venv/bin:$PATH" bash -o pipefail -c "$(snippet feast-vector-retrieval/commands.sh apply)"
1635
+ run_step "writing the documents to the PostgreSQL online store failed" "$py" vector_write.py
1636
+ local nearest
1637
+ nearest=$(cd "$work" && "$py" vector_retrieve.py 2>"$log") \
1638
+ || { tail -25 "$log"; fail "retrieve_online_documents_v2 failed"; }
1639
+ [ "$nearest" = "['Feast', 'Iceberg']" ] \
1640
+ || fail "retrieve_online_documents_v2 returned $nearest, not the two nearest documents"
1641
+ pass "retrieve_online_documents_v2 returned the nearest rows from pgvector"
1642
+
1303
1643
  curl -fsS --max-time 10 -X DELETE \
1304
1644
  "http://127.0.0.1:8181/v1/namespaces/demo/tables/driver_stats?purgeRequested=true" >/dev/null 2>&1 || true
1305
1645
  curl -fsS --max-time 10 -X DELETE "http://127.0.0.1:8181/v1/namespaces/demo" >/dev/null 2>&1 || true
@@ -1307,6 +1647,31 @@ PYONLINE
1307
1647
  # case removes $work after that.
1308
1648
  }
1309
1649
 
1650
+ # The checks run after the guide's Runnable Example, so they reuse its push
1651
+ # definitions in feast-serve; the HTTP push then stores a newer value than the
1652
+ # example's Flink push. Read it back until the feature server's cached registry
1653
+ # knows the feature view, then read it through the MCP endpoint.
1654
+ smoke_feast_push_and_mcp() {
1655
+ local work="${TMPDIR:-/tmp}/odctl-feast-smoke"
1656
+ local out
1657
+ out=$(cd "$work" && run_snippet feast-iceberg/commands.sh push-http 2>&1) \
1658
+ || { echo "$out" | tail -20; fail "the HTTP push to /push failed"; }
1659
+ [ "$out" = "200" ] || { echo "$out" | tail -20; fail "the HTTP push to /push answered '$out', not 200"; }
1660
+ retry 24 5 snippet_says feast-iceberg/commands.sh push-read -F 0.699999988079071 \
1661
+ || fail "the value pushed over HTTP was not read back from /get-online-features"
1662
+ pass "a value pushed over HTTP was read back from the feature server"
1663
+
1664
+ cp "$SNIPPETS/feast-iceberg/mcp_client.py" "$work/"
1665
+ out=$(cd "$work" && "$work/.venv/bin/python" mcp_client.py 2>&1) \
1666
+ || { echo "$out" | tail -20; fail "the MCP client failed"; }
1667
+ grep -qx 'get_online_features_get_online_features_post' <<<"$out" \
1668
+ && grep -qx 'push_push_post' <<<"$out" \
1669
+ || { echo "$out" | tail -20; fail "the MCP endpoint did not list Feast's tools"; }
1670
+ grep -qxF '[0.699999988079071]' <<<"$out" \
1671
+ || { echo "$out" | tail -20; fail "the MCP tool did not return the pushed value"; }
1672
+ pass "an MCP client listed Feast's tools and read the pushed value"
1673
+ }
1674
+
1310
1675
  # Temporal: a healthy server proves only that it answers. Workflows run in the
1311
1676
  # caller's worker, so this runs one with the temporalio SDK from the host: an
1312
1677
  # activity that fails once and succeeds on retry, and a workflow that waits for
@@ -1546,11 +1911,26 @@ case "$PROFILE" in
1546
1911
  run_example mlflow '"predictions": \[0, 0\]' "$(mlflow_runner) bash" ;;
1547
1912
  lineage) smoke_lineage; run_example lineage 'COMPLETED' ;;
1548
1913
  telemetry) smoke_telemetry; run_example telemetry '"42"' ;;
1549
- metadata) smoke_metadata; run_example openmetadata 'removed demo_pg' ;;
1914
+ metadata)
1915
+ smoke_metadata
1916
+ smoke_metadata_example ;;
1917
+ # Two How It Works examples that recreate metadata, each in a group of its own
1918
+ # so that they run in parallel with the checks above and with each other. Each
1919
+ # recreates metadata twice, once for the example and once to restore it.
1920
+ metadata:deps)
1921
+ how_it_works_edit metadata-deps .odctl/.env metadata '^1\.4\.3$' ;;
1922
+ metadata:autopilot)
1923
+ how_it_works_autopilot
1924
+ retry 60 5 http_ok "http://127.0.0.1:8087/api/v2/monitor/health" \
1925
+ || fail "ingestion Airflow did not answer after the package was put back" ;;
1550
1926
  fluss) smoke_fluss; run_example fluss '\| +3 +\| +c +\|' ;;
1551
1927
  feast)
1552
1928
  smoke_feast
1553
1929
  run_example feast-iceberg '0.8999999761581421' "env PATH=${TMPDIR:-/tmp}/odctl-feast-smoke/.venv/bin:$PATH bash"
1930
+ smoke_feast_push_and_mcp
1931
+ run_example feast-vector-retrieval "\['Feast', 'Iceberg'\]" "env PATH=${TMPDIR:-/tmp}/odctl-feast-smoke/.venv/bin:$PATH bash"
1932
+ # The two examples write their repositories into the current folder, as a reader's would.
1933
+ rm -rf feature_repo vector_repo push_definitions.py
1554
1934
  rm -rf "${TMPDIR:-/tmp}/odctl-feast-smoke" ;;
1555
1935
  evidently) smoke_evidently; run_example evidently-reports 'report pushed to project' "$(evidently_runner)" ;;
1556
1936
  *)
@@ -20,7 +20,7 @@ jobs:
20
20
  - name: Install uv
21
21
  # Pinned to a full version on purpose: setup-uv stopped publishing
22
22
  # moving major tags at v8, so astral-sh/setup-uv@v10 does not exist.
23
- uses: astral-sh/setup-uv@v10.3.1
23
+ uses: astral-sh/setup-uv@v10.3.2
24
24
  with:
25
25
  enable-cache: true
26
26
  # The default key holds the runner's Python, not the matrix one, so