odctl 1.2.0__tar.gz → 1.2.1__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 (150) hide show
  1. {odctl-1.2.0 → odctl-1.2.1}/.github/ISSUE_TEMPLATE/bug_report.md +6 -6
  2. {odctl-1.2.0 → odctl-1.2.1}/.github/ISSUE_TEMPLATE/feature_request.md +1 -1
  3. {odctl-1.2.0 → odctl-1.2.1}/.github/scripts/select-profiles.py +39 -10
  4. {odctl-1.2.0 → odctl-1.2.1}/.github/scripts/smoke.sh +179 -51
  5. {odctl-1.2.0 → odctl-1.2.1}/CLAUDE.md +11 -6
  6. odctl-1.2.1/PKG-INFO +136 -0
  7. odctl-1.2.1/README.md +99 -0
  8. odctl-1.2.1/image/demo.gif +0 -0
  9. odctl-1.2.1/image/demo.png +0 -0
  10. {odctl-1.2.0 → odctl-1.2.1}/mkdocs.yml +26 -12
  11. {odctl-1.2.0 → odctl-1.2.1}/pyproject.toml +3 -3
  12. {odctl-1.2.0 → odctl-1.2.1}/scripts/diagrams.py +87 -3
  13. {odctl-1.2.0 → odctl-1.2.1}/scripts/gen_pages.py +155 -20
  14. {odctl-1.2.0 → odctl-1.2.1}/scripts/llmstxt_sections.py +9 -0
  15. {odctl-1.2.0 → odctl-1.2.1}/scripts/local_links.py +21 -1
  16. odctl-1.2.1/scripts/release_links.py +96 -0
  17. {odctl-1.2.0 → odctl-1.2.1}/skills/odctl/SKILL.md +45 -6
  18. {odctl-1.2.0 → odctl-1.2.1}/src/odctl/config.py +7 -2
  19. {odctl-1.2.0 → odctl-1.2.1}/src/odctl/docker.py +16 -120
  20. odctl-1.2.1/src/odctl/logs.py +136 -0
  21. {odctl-1.2.0 → odctl-1.2.1}/src/odctl/main.py +142 -28
  22. odctl-1.2.1/src/odctl/projects.py +79 -0
  23. {odctl-1.2.0 → odctl-1.2.1}/src/odctl/ui.py +1 -1
  24. odctl-1.2.1/tests/docs_helpers.py +53 -0
  25. {odctl-1.2.0 → odctl-1.2.1}/tests/test_cli_extended.py +51 -0
  26. odctl-1.2.1/tests/test_confirmed_pages.py +23 -0
  27. odctl-1.2.1/tests/test_diagrams.py +69 -0
  28. {odctl-1.2.0 → odctl-1.2.1}/tests/test_docker.py +6 -36
  29. {odctl-1.2.0 → odctl-1.2.1}/tests/test_docs.py +42 -57
  30. {odctl-1.2.0 → odctl-1.2.1}/tests/test_evidently_profile.py +2 -1
  31. odctl-1.2.1/tests/test_feature_docs.py +120 -0
  32. odctl-1.2.1/tests/test_logs.py +36 -0
  33. odctl-1.2.1/tests/test_projects.py +71 -0
  34. odctl-1.2.1/tests/test_release_links.py +47 -0
  35. {odctl-1.2.0 → odctl-1.2.1}/uv.lock +1 -1
  36. odctl-1.2.0/PKG-INFO +0 -121
  37. odctl-1.2.0/README.md +0 -84
  38. odctl-1.2.0/image/demo.gif +0 -0
  39. {odctl-1.2.0 → odctl-1.2.1}/.github/renovate.json +0 -0
  40. {odctl-1.2.0 → odctl-1.2.1}/.github/scripts/spark_avro_producer.py +0 -0
  41. {odctl-1.2.0 → odctl-1.2.1}/.github/workflows/build-deps.yml +0 -0
  42. {odctl-1.2.0 → odctl-1.2.1}/.github/workflows/build-platform-images.yml +0 -0
  43. {odctl-1.2.0 → odctl-1.2.1}/.github/workflows/ci.yml +0 -0
  44. {odctl-1.2.0 → odctl-1.2.1}/.github/workflows/e2e-test.yml +0 -0
  45. {odctl-1.2.0 → odctl-1.2.1}/.github/workflows/pipeline.yml +0 -0
  46. {odctl-1.2.0 → odctl-1.2.1}/.github/workflows/publish.yml +0 -0
  47. {odctl-1.2.0 → odctl-1.2.1}/.gitignore +0 -0
  48. {odctl-1.2.0 → odctl-1.2.1}/.pre-commit-config.yaml +0 -0
  49. {odctl-1.2.0 → odctl-1.2.1}/.vscode/settings.json +0 -0
  50. {odctl-1.2.0 → odctl-1.2.1}/LICENSE +0 -0
  51. {odctl-1.2.0 → odctl-1.2.1}/image/diagram.drawio +0 -0
  52. {odctl-1.2.0 → odctl-1.2.1}/image/diagram.png +0 -0
  53. {odctl-1.2.0 → odctl-1.2.1}/scripts/release_notes.py +0 -0
  54. {odctl-1.2.0 → odctl-1.2.1}/src/odctl/__init__.py +0 -0
  55. {odctl-1.2.0 → odctl-1.2.1}/src/odctl/planner.py +0 -0
  56. {odctl-1.2.0 → odctl-1.2.1}/src/odctl/registry.py +0 -0
  57. {odctl-1.2.0 → odctl-1.2.1}/src/odctl/resources/airflow/odctl_log_config.py +0 -0
  58. {odctl-1.2.0 → odctl-1.2.1}/src/odctl/resources/clickhouse/config.xml +0 -0
  59. {odctl-1.2.0 → odctl-1.2.1}/src/odctl/resources/clickhouse/init.sql +0 -0
  60. {odctl-1.2.0 → odctl-1.2.1}/src/odctl/resources/clickhouse/keeper_config.xml +0 -0
  61. {odctl-1.2.0 → odctl-1.2.1}/src/odctl/resources/clickhouse/macros-11-lite.xml +0 -0
  62. {odctl-1.2.0 → odctl-1.2.1}/src/odctl/resources/clickhouse/macros-11.xml +0 -0
  63. {odctl-1.2.0 → odctl-1.2.1}/src/odctl/resources/clickhouse/macros-12-lite.xml +0 -0
  64. {odctl-1.2.0 → odctl-1.2.1}/src/odctl/resources/clickhouse/macros-12.xml +0 -0
  65. {odctl-1.2.0 → odctl-1.2.1}/src/odctl/resources/clickhouse/macros-21.xml +0 -0
  66. {odctl-1.2.0 → odctl-1.2.1}/src/odctl/resources/clickhouse/macros-22.xml +0 -0
  67. {odctl-1.2.0 → odctl-1.2.1}/src/odctl/resources/clickhouse/users.xml +0 -0
  68. {odctl-1.2.0 → odctl-1.2.1}/src/odctl/resources/compose-analytics.yml +0 -0
  69. {odctl-1.2.0 → odctl-1.2.1}/src/odctl/resources/compose-deps.yml +0 -0
  70. {odctl-1.2.0 → odctl-1.2.1}/src/odctl/resources/compose-flink.yml +0 -0
  71. {odctl-1.2.0 → odctl-1.2.1}/src/odctl/resources/compose-infra.yml +0 -0
  72. {odctl-1.2.0 → odctl-1.2.1}/src/odctl/resources/compose-kafka.yml +0 -0
  73. {odctl-1.2.0 → odctl-1.2.1}/src/odctl/resources/compose-metadata.yml +0 -0
  74. {odctl-1.2.0 → odctl-1.2.1}/src/odctl/resources/compose-mlops.yml +0 -0
  75. {odctl-1.2.0 → odctl-1.2.1}/src/odctl/resources/compose-obsv.yml +0 -0
  76. {odctl-1.2.0 → odctl-1.2.1}/src/odctl/resources/compose-orch.yml +0 -0
  77. {odctl-1.2.0 → odctl-1.2.1}/src/odctl/resources/compose-spark.yml +0 -0
  78. {odctl-1.2.0 → odctl-1.2.1}/src/odctl/resources/compose-store.yml +0 -0
  79. {odctl-1.2.0 → odctl-1.2.1}/src/odctl/resources/deps/Dockerfile +0 -0
  80. {odctl-1.2.0 → odctl-1.2.1}/src/odctl/resources/deps/build.gradle +0 -0
  81. {odctl-1.2.0 → odctl-1.2.1}/src/odctl/resources/deps/build_connectors.sh +0 -0
  82. {odctl-1.2.0 → odctl-1.2.1}/src/odctl/resources/deps/fetch_flink_dependencies.sh +0 -0
  83. {odctl-1.2.0 → odctl-1.2.1}/src/odctl/resources/deps/fetch_spark_dependencies.sh +0 -0
  84. {odctl-1.2.0 → odctl-1.2.1}/src/odctl/resources/deps/fetch_standalone_plugins.sh +0 -0
  85. {odctl-1.2.0 → odctl-1.2.1}/src/odctl/resources/deps/versions.env +0 -0
  86. {odctl-1.2.0 → odctl-1.2.1}/src/odctl/resources/docker/airflow/Dockerfile +0 -0
  87. {odctl-1.2.0 → odctl-1.2.1}/src/odctl/resources/docker/mlflow/Dockerfile +0 -0
  88. {odctl-1.2.0 → odctl-1.2.1}/src/odctl/resources/docker/postgres/Dockerfile +0 -0
  89. {odctl-1.2.0 → odctl-1.2.1}/src/odctl/resources/docker/spark/Dockerfile +0 -0
  90. {odctl-1.2.0 → odctl-1.2.1}/src/odctl/resources/evidently/config.yaml +0 -0
  91. {odctl-1.2.0 → odctl-1.2.1}/src/odctl/resources/feast/feature_store.yaml +0 -0
  92. {odctl-1.2.0 → odctl-1.2.1}/src/odctl/resources/flink/flink-conf.yml +0 -0
  93. {odctl-1.2.0 → odctl-1.2.1}/src/odctl/resources/grafana/dashboards/airflow.json +0 -0
  94. {odctl-1.2.0 → odctl-1.2.1}/src/odctl/resources/grafana/dashboards/clickhouse.json +0 -0
  95. {odctl-1.2.0 → odctl-1.2.1}/src/odctl/resources/grafana/dashboards/feast.json +0 -0
  96. {odctl-1.2.0 → odctl-1.2.1}/src/odctl/resources/grafana/dashboards/flink.json +0 -0
  97. {odctl-1.2.0 → odctl-1.2.1}/src/odctl/resources/grafana/dashboards/fluss.json +0 -0
  98. {odctl-1.2.0 → odctl-1.2.1}/src/odctl/resources/grafana/dashboards/iceberg-catalog.json +0 -0
  99. {odctl-1.2.0 → odctl-1.2.1}/src/odctl/resources/grafana/dashboards/kafka-connect.json +0 -0
  100. {odctl-1.2.0 → odctl-1.2.1}/src/odctl/resources/grafana/dashboards/kafka.json +0 -0
  101. {odctl-1.2.0 → odctl-1.2.1}/src/odctl/resources/grafana/dashboards/karapace.json +0 -0
  102. {odctl-1.2.0 → odctl-1.2.1}/src/odctl/resources/grafana/dashboards/marquez.json +0 -0
  103. {odctl-1.2.0 → odctl-1.2.1}/src/odctl/resources/grafana/dashboards/metabase.json +0 -0
  104. {odctl-1.2.0 → odctl-1.2.1}/src/odctl/resources/grafana/dashboards/mlflow.json +0 -0
  105. {odctl-1.2.0 → odctl-1.2.1}/src/odctl/resources/grafana/dashboards/openmetadata.json +0 -0
  106. {odctl-1.2.0 → odctl-1.2.1}/src/odctl/resources/grafana/dashboards/postgresql.json +0 -0
  107. {odctl-1.2.0 → odctl-1.2.1}/src/odctl/resources/grafana/dashboards/seaweedfs.json +0 -0
  108. {odctl-1.2.0 → odctl-1.2.1}/src/odctl/resources/grafana/dashboards/spark.json +0 -0
  109. {odctl-1.2.0 → odctl-1.2.1}/src/odctl/resources/grafana/dashboards/temporal.json +0 -0
  110. {odctl-1.2.0 → odctl-1.2.1}/src/odctl/resources/grafana/dashboards/trino.json +0 -0
  111. {odctl-1.2.0 → odctl-1.2.1}/src/odctl/resources/grafana/dashboards/valkey.json +0 -0
  112. {odctl-1.2.0 → odctl-1.2.1}/src/odctl/resources/grafana/odctl-dashboards.yaml +0 -0
  113. {odctl-1.2.0 → odctl-1.2.1}/src/odctl/resources/grafana/sources.yml +0 -0
  114. {odctl-1.2.0 → odctl-1.2.1}/src/odctl/resources/jmx/jmx-exporter.yml +0 -0
  115. {odctl-1.2.0 → odctl-1.2.1}/src/odctl/resources/marquez/marquez.yml +0 -0
  116. {odctl-1.2.0 → odctl-1.2.1}/src/odctl/resources/otelcol/odctl-receivers.yaml +0 -0
  117. {odctl-1.2.0 → odctl-1.2.1}/src/odctl/resources/postgres/01-init-databases.sh +0 -0
  118. {odctl-1.2.0 → odctl-1.2.1}/src/odctl/resources/prometheus/prometheus.yml +0 -0
  119. {odctl-1.2.0 → odctl-1.2.1}/src/odctl/resources/registry.yml +0 -0
  120. {odctl-1.2.0 → odctl-1.2.1}/src/odctl/resources/seaweed/s3.json +0 -0
  121. {odctl-1.2.0 → odctl-1.2.1}/src/odctl/resources/spark/log4j2.properties +0 -0
  122. {odctl-1.2.0 → odctl-1.2.1}/src/odctl/resources/spark/metrics.properties +0 -0
  123. {odctl-1.2.0 → odctl-1.2.1}/src/odctl/resources/spark/spark-defaults.conf +0 -0
  124. {odctl-1.2.0 → odctl-1.2.1}/src/odctl/resources/trino/access-control.properties +0 -0
  125. {odctl-1.2.0 → odctl-1.2.1}/src/odctl/resources/trino/catalog/clickhouse.properties +0 -0
  126. {odctl-1.2.0 → odctl-1.2.1}/src/odctl/resources/trino/catalog/iceberg.properties +0 -0
  127. {odctl-1.2.0 → odctl-1.2.1}/src/odctl/resources/trino/catalog/kafka.properties +0 -0
  128. {odctl-1.2.0 → odctl-1.2.1}/src/odctl/resources/trino/catalog/postgres.properties +0 -0
  129. {odctl-1.2.0 → odctl-1.2.1}/src/odctl/resources/trino/catalog/valkey.properties +0 -0
  130. {odctl-1.2.0 → odctl-1.2.1}/src/odctl/resources/trino/group-provider.properties +0 -0
  131. {odctl-1.2.0 → odctl-1.2.1}/src/odctl/resources/trino/groups.txt +0 -0
  132. {odctl-1.2.0 → odctl-1.2.1}/src/odctl/resources/trino/rules.json +0 -0
  133. {odctl-1.2.0 → odctl-1.2.1}/src/odctl/workspace.py +0 -0
  134. {odctl-1.2.0 → odctl-1.2.1}/tests/conftest.py +0 -0
  135. {odctl-1.2.0 → odctl-1.2.1}/tests/test_airflow_profile.py +0 -0
  136. {odctl-1.2.0 → odctl-1.2.1}/tests/test_cli.py +0 -0
  137. {odctl-1.2.0 → odctl-1.2.1}/tests/test_compose_profiles.py +0 -0
  138. {odctl-1.2.0 → odctl-1.2.1}/tests/test_config.py +0 -0
  139. {odctl-1.2.0 → odctl-1.2.1}/tests/test_feast_profile.py +0 -0
  140. {odctl-1.2.0 → odctl-1.2.1}/tests/test_grafana_dashboards.py +0 -0
  141. {odctl-1.2.0 → odctl-1.2.1}/tests/test_metrics_collector.py +0 -0
  142. {odctl-1.2.0 → odctl-1.2.1}/tests/test_metrics_jvm.py +0 -0
  143. {odctl-1.2.0 → odctl-1.2.1}/tests/test_metrics_python_services.py +0 -0
  144. {odctl-1.2.0 → odctl-1.2.1}/tests/test_planner.py +0 -0
  145. {odctl-1.2.0 → odctl-1.2.1}/tests/test_postgres_image.py +0 -0
  146. {odctl-1.2.0 → odctl-1.2.1}/tests/test_registry.py +0 -0
  147. {odctl-1.2.0 → odctl-1.2.1}/tests/test_release_notes.py +0 -0
  148. {odctl-1.2.0 → odctl-1.2.1}/tests/test_telemetry_profile.py +0 -0
  149. {odctl-1.2.0 → odctl-1.2.1}/tests/test_temporal_profile.py +0 -0
  150. {odctl-1.2.0 → odctl-1.2.1}/tests/test_workspace.py +0 -0
@@ -14,7 +14,7 @@ A clear and concise description of what the bug is.
14
14
 
15
15
  Steps to reproduce the behavior:
16
16
 
17
- 1. What profiles were you trying to launch? (e.g., `odctl up airflow spark-lite ch-lite`)
17
+ 1. What profiles were you trying to launch? (for example, `odctl up airflow spark-lite ch-lite`)
18
18
  2. Run this CLI command: '...'
19
19
  3. See error
20
20
 
@@ -34,13 +34,13 @@ If applicable, paste the full CLI error or the specific container logs here (you
34
34
 
35
35
  **Environment (please complete the following information):**
36
36
 
37
- * OS: [e.g. Ubuntu 24.04, macOS Sonoma, Windows 11 / WSL2]
38
- * Python Version: [e.g. 3.12]
39
- * odctl version (`odctl info`): [e.g. 1.0.0]
40
- * Docker Version: [e.g. Docker Desktop 4.28.0, Docker Engine 25.0]
37
+ * OS: [for example Ubuntu 24.04, macOS Sonoma, Windows 11 / WSL2]
38
+ * Python Version: [for example 3.12]
39
+ * odctl version (`odctl info`): [for example 1.0.0]
40
+ * Docker Version: [for example Docker Desktop 4.28.0, Docker Engine 25.0]
41
41
 
42
42
  ## **Additional context**
43
43
 
44
- * Did you customize any files in your local `./.odctl/` workspace? (e.g., changing ports in `compose-orch.yml` or editing `registry.yml`)
44
+ * Did you customize any files in your local `./.odctl/` workspace? (for example, changing ports in `compose-orch.yml` or editing `registry.yml`)
45
45
  * How much RAM/CPU is allocated to your Docker daemon? (Many Open Data profiles are resource-heavy)
46
46
  * Add any other context about the problem here.
@@ -12,7 +12,7 @@ A clear and concise description of what the problem is. Ex. I'm always frustrate
12
12
 
13
13
  **Describe the solution you'd like**
14
14
 
15
- A clear and concise description of what you want to happen. (e.g., "I want a new profile added to `compose-orch.yml` for Dagster", or "Add a dedicated connector script for MongoDB CDC.")
15
+ A clear and concise description of what you want to happen. (for example, "I want a new profile added to `compose-orch.yml` for Dagster", or "Add a dedicated connector script for MongoDB CDC.")
16
16
 
17
17
  **Describe alternatives you've considered**
18
18
 
@@ -70,12 +70,12 @@ REGISTRY = Path("src/odctl/resources/registry.yml")
70
70
 
71
71
  # smoke.sh starts some profiles from another group's case: the kafka case starts
72
72
  # catalog, the flink case starts kafka-lite and feast, the spark case starts
73
- # kafka-lite, and the fluss case starts flink-lite. A change to that compose file
74
- # needs these groups as well as the ones the registry maps to it.
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.
75
75
  ALSO_RUNS = {
76
76
  "compose-infra.yml": ["kafka-lite kafka-full"],
77
77
  "compose-kafka.yml": ["flink-lite flink-full", "spark-lite spark-full"],
78
- "compose-flink.yml": ["fluss"],
78
+ "compose-flink.yml": ["fluss", "feast"],
79
79
  "compose-mlops.yml": ["flink-lite flink-full"],
80
80
  }
81
81
 
@@ -84,21 +84,23 @@ ALSO_RUNS = {
84
84
  # map runs everything, and a test fails until it is added.
85
85
  SNIPPET_PREFIX = "docs/snippets/"
86
86
  SNIPPET_GROUPS = {
87
+ "getting-started": ["kafka-lite kafka-full"],
88
+ "how-it-works": ["storage", "catalog", "trino", "airflow"],
87
89
  "kafka": ["kafka-lite kafka-full"],
88
90
  "kafka-connect": ["kafka-lite kafka-full"],
89
91
  "iceberg-ingestion": ["kafka-lite kafka-full", "flink-lite flink-full"],
90
- "flink-sql": ["flink-lite flink-full", "mlflow"],
92
+ "flink-sql": ["flink-lite flink-full"],
93
+ "rolling-windows": ["flink-lite flink-full"],
91
94
  "fluss": ["fluss"],
92
95
  "spark": ["spark-lite spark-full"],
93
96
  "clickhouse": ["ch-lite ch-full"],
94
97
  "trino-metabase": ["trino", "metabase"],
95
98
  "hybrid-search": ["postgres"],
96
- "storage": ["storage"],
97
99
  "valkey": ["valkey"],
98
100
  "airflow-dags": ["airflow"],
99
101
  "temporal-workers": ["temporal"],
100
102
  "mlflow": ["mlflow"],
101
- "feast-iceberg": ["feast"],
103
+ "feast-iceberg": ["feast", "flink-lite flink-full", "mlflow"],
102
104
  "evidently-reports": ["evidently"],
103
105
  "lineage": ["lineage"],
104
106
  "openmetadata": ["metadata"],
@@ -106,8 +108,16 @@ SNIPPET_GROUPS = {
106
108
  }
107
109
 
108
110
 
109
- def profile_to_file(registry_path: Path = REGISTRY) -> dict:
110
- """Map each profile to the compose file the registry says provides it."""
111
+ def _profile_to_file(registry_path: Path = REGISTRY) -> dict:
112
+ """
113
+ Map each profile to the compose file the registry says provides it.
114
+
115
+ Args:
116
+ registry_path (Path): The registry.yml to read.
117
+
118
+ Returns:
119
+ dict: The compose file name of each profile.
120
+ """
111
121
  data = yaml.safe_load(registry_path.read_text())
112
122
  mapping = {}
113
123
  for stack in data["stacks"].values():
@@ -117,12 +127,30 @@ def profile_to_file(registry_path: Path = REGISTRY) -> dict:
117
127
 
118
128
 
119
129
  def select(changed: list, registry_path: Path = REGISTRY) -> list:
120
- """Return the profile groups that the changed files require."""
130
+ """
131
+ Return the profile groups that the changed files require.
132
+
133
+ Args:
134
+ changed (list): The paths of the changed files, relative to the repository.
135
+ registry_path (Path): The registry.yml that maps profiles to compose files.
136
+
137
+ Returns:
138
+ list: The groups to run, every group when a change affects them all, or none.
139
+ """
121
140
  changed = [c for c in changed if c.strip()]
122
141
  if not changed:
123
142
  return []
124
143
 
125
144
  def is_compose(path: str) -> bool:
145
+ """
146
+ Tell whether a path is one of odctl's compose files.
147
+
148
+ Args:
149
+ path (str): A changed file's path, relative to the repository.
150
+
151
+ Returns:
152
+ bool: True when the path is under the compose files' folder.
153
+ """
126
154
  return path.startswith(COMPOSE_PREFIX)
127
155
 
128
156
  broad = [
@@ -141,7 +169,7 @@ def select(changed: list, registry_path: Path = REGISTRY) -> list:
141
169
  return list(GROUPS)
142
170
  snippet_groups.update(SNIPPET_GROUPS[guide])
143
171
 
144
- mapping = profile_to_file(registry_path)
172
+ mapping = _profile_to_file(registry_path)
145
173
  touched_files = {Path(c).name for c in changed if is_compose(c)}
146
174
 
147
175
  selected = []
@@ -154,6 +182,7 @@ def select(changed: list, registry_path: Path = REGISTRY) -> list:
154
182
 
155
183
 
156
184
  def main() -> None:
185
+ """Print the groups for the files named on the command line, or every group with `--all`, as JSON."""
157
186
  args = sys.argv[1:]
158
187
  if "--all" in args:
159
188
  print(json.dumps(list(GROUPS)))
@@ -72,6 +72,65 @@ run_snippet() {
72
72
  bash -o pipefail -c "$code"
73
73
  }
74
74
 
75
+ # Run a section and check its output: snippet_says kafka/commands.sh list -F 'orders'.
76
+ # The whole output is read before grep looks at it. A grep -q at the end of a pipe
77
+ # stops at the first match, the command then fails writing its next line, and
78
+ # pipefail reports the check as failed.
79
+ snippet_says() {
80
+ local file="$1" section="$2" out
81
+ shift 2
82
+ out=$(run_snippet "$file" "$section" 2>/dev/null) || return 1
83
+ grep -q "$@" <<<"$out"
84
+ }
85
+
86
+ # Run a guide's Runnable Example from the current directory, where the .odctl
87
+ # workspace is, and check that its output matches an extended regular
88
+ # expression: run_example kafka '"n":5'. The optional third argument is the
89
+ # command that runs the file, such as a Python with the client installed.
90
+ run_example() {
91
+ local guide="$1" want="$2" runner="${3:-bash}" file out
92
+ file=$(ls "$SNIPPETS/$guide"/example.* 2>/dev/null | head -1)
93
+ [ -n "$file" ] || fail "no runnable example in docs/snippets/$guide"
94
+ out=$($runner "$file" 2>&1) || { echo "$out" | tail -20; fail "the $guide example failed"; }
95
+ grep -qE -- "$want" <<<"$out" || { echo "$out" | tail -20; fail "the $guide example printed no '$want'"; }
96
+ pass "the $guide guide's runnable example printed '$want'"
97
+ }
98
+
99
+ # The commands that run the MLflow and Evidently examples: a Python 3.12 with
100
+ # the client versions the images pin, as the guides tell the reader to install.
101
+ mlflow_runner() {
102
+ local dockerfile="$SNIPPETS/../../src/odctl/resources/docker/mlflow/Dockerfile" mlflow xgboost
103
+ mlflow=$(grep -oE 'mlflow/mlflow:v[0-9.]+' "$dockerfile" | head -1 | cut -dv -f2)
104
+ xgboost=$(grep -oE "xgboost~=[0-9.]+" "$dockerfile" | head -1 | cut -d= -f2)
105
+ echo "uv run --no-project --quiet --python 3.12 --with mlflow==$mlflow --with xgboost~=$xgboost"
106
+ }
107
+ evidently_runner() {
108
+ local version
109
+ version=$(grep -oE 'evidently/evidently-service:[0-9.]+' \
110
+ "$SNIPPETS/../../src/odctl/resources/compose-mlops.yml" | head -1 | cut -d: -f2)
111
+ echo "uv run --no-project --quiet --python 3.12 --with evidently==$version --with pandas<3.1 python"
112
+ }
113
+
114
+ # How It Works edits a workspace file and applies it with odctl recreate. Each
115
+ # check runs the guide's section, puts the file back and recreates again, so
116
+ # later profiles in the same job see the shipped settings, and matches the
117
+ # output against an extended regular expression.
118
+ how_it_works_edit() {
119
+ local section="$1" file="$2" profile="$3" want="$4" out
120
+ out=$(run_snippet how-it-works/commands.sh "$section" 2>&1) || { echo "$out" | tail -20; fail "the $section example failed"; }
121
+ mv "$file.bak" "$file" || fail "the $section example left no $file.bak"
122
+ odctl recreate "$profile" >/dev/null 2>&1 || fail "could not recreate $profile after the $section example"
123
+ grep -qE -- "$want" <<<"$out" || { echo "$out" | tail -20; fail "the $section example printed no '$want'"; }
124
+ pass "How It Works' $section example printed '$want'"
125
+ }
126
+ how_it_works_trino() {
127
+ how_it_works_edit trino-port .odctl/compose-analytics.yml trino '8080/tcp -> 127\.0\.0\.1:8180'
128
+ retry 30 5 http_ok "http://127.0.0.1:8080/v1/info" || fail "Trino did not answer on 8080 after the port was put back"
129
+ }
130
+ how_it_works_airflow() {
131
+ how_it_works_edit airflow-deps .odctl/.env airflow '^1\.4\.3$'
132
+ }
133
+
75
134
  # Metrics: nothing else here reads a metrics endpoint, so a change that breaks
76
135
  # one goes unseen. In 0.8.0 the JMX agent in KAFKA_OPTS broke every Kafka CLI
77
136
  # run, and only an unrelated topic check caught it. So each profile reads its
@@ -176,6 +235,50 @@ connect_plugins() {
176
235
  pass "Connect lists the Iceberg, Debezium and ClickHouse plugins"
177
236
  }
178
237
 
238
+ # The Kafka Connect guide registers the MSK data generator, which needs only
239
+ # Kafka, reads three of its records, then checks and removes the connector.
240
+ connect_datagen() {
241
+ (cd "$SNIPPETS/kafka-connect" && run_snippet kafka-connect/commands.sh register) >/dev/null 2>&1 \
242
+ || fail "Connect rejected the data generator configuration"
243
+ local got status
244
+ got=$(run_snippet kafka-connect/commands.sh read 2>/dev/null | grep -c '"name"')
245
+ [ "$got" -eq 3 ] || fail "read $got of 3 data generator records from customers"
246
+ retry 12 5 sh -c "curl -fsS http://127.0.0.1:8083/connectors/demo-datagen/status | grep -q '\"state\":\"RUNNING\"'" \
247
+ || fail "the data generator was not RUNNING"
248
+ status=$(run_snippet kafka-connect/commands.sh status-remove 2>/dev/null)
249
+ grep -q '"state":"RUNNING"' <<<"$status" || { echo "$status"; fail "the data generator status was not RUNNING"; }
250
+ curl -fsS "http://127.0.0.1:8083/connectors/demo-datagen" >/dev/null 2>&1 \
251
+ && fail "the data generator is still registered after the delete"
252
+ pass "the data generator wrote records to customers, and was checked and removed"
253
+ }
254
+
255
+ # Getting Started runs each read-only command on kafka-lite, then restarts it.
256
+ getting_started() {
257
+ local out
258
+ out=$(run_snippet getting-started/commands.sh list 2>&1) || fail "odctl list failed"
259
+ grep -q "kafka-lite" <<<"$out" || fail "odctl list does not name kafka-lite"
260
+ out=$(run_snippet getting-started/commands.sh list-details 2>&1) || fail "odctl list -d failed"
261
+ grep -q "kafka-ui" <<<"$out" || fail "odctl list -d does not name the kafka-ui service"
262
+ out=$(run_snippet getting-started/commands.sh explain 2>&1) || fail "odctl explain failed"
263
+ grep -q "karapace" <<<"$out" || fail "odctl explain kafka-lite does not name karapace"
264
+ out=$(run_snippet getting-started/commands.sh info 2>&1) || fail "odctl info failed"
265
+ grep -q "Reachable" <<<"$out" || fail "odctl info does not say Docker is reachable"
266
+ out=$(run_snippet getting-started/commands.sh ps 2>&1) || fail "odctl ps failed"
267
+ grep -q "connect" <<<"$out" || fail "odctl ps kafka-lite does not list connect"
268
+ out=$(run_snippet getting-started/commands.sh ps-all 2>&1) || fail "odctl ps --all failed"
269
+ grep -q "karapace" <<<"$out" || fail "odctl ps --all does not list karapace"
270
+ out=$(run_snippet getting-started/commands.sh logs 2>&1) || fail "odctl logs failed"
271
+ grep -q "^connect" <<<"$out" || fail "odctl logs printed no connect lines"
272
+ out=$(run_snippet getting-started/commands.sh logs-time 2>&1) || fail "odctl logs with --since and --until failed"
273
+ grep -qE "^connect +\| [0-9]{4}-" <<<"$out" || fail "odctl logs -t printed no timestamped connect lines: $out"
274
+ out=$(run_snippet getting-started/commands.sh down-dry-run 2>&1) || fail "odctl down --dry-run failed"
275
+ docker inspect connect >/dev/null 2>&1 || fail "odctl down --dry-run removed a container"
276
+ out=$(run_snippet getting-started/commands.sh restart 2>&1) || fail "odctl restart failed"
277
+ grep -q "Restart complete" <<<"$out" || fail "odctl restart did not complete"
278
+ retry 40 5 http_ok "http://127.0.0.1:8083/connectors" || fail "Connect never answered after the restart"
279
+ pass "Getting Started's list, explain, info, ps, logs, restart and down --dry-run ran on kafka-lite"
280
+ }
281
+
179
282
  # The Iceberg sink is built from source, so prove it commits a snapshot rather
180
283
  # than only loading. It needs the catalog, which kafka-lite does not start.
181
284
  smoke_iceberg_sink() {
@@ -186,7 +289,7 @@ smoke_iceberg_sink() {
186
289
  run_snippet iceberg-ingestion/commands.sh topics-produce >/dev/null 2>&1 \
187
290
  || fail "could not create the topics or produce to demo-iceberg"
188
291
  # From the snippet folder, where the guide's @demo-iceberg.json is.
189
- (cd "$SNIPPETS/kafka-connect" && run_snippet kafka-connect/commands.sh register) >/dev/null 2>&1 \
292
+ (cd "$SNIPPETS/iceberg-ingestion" && run_snippet iceberg-ingestion/commands.sh register) >/dev/null 2>&1 \
190
293
  || fail "Connect rejected the Iceberg sink configuration"
191
294
 
192
295
  # The first commit waits for the control consumer to join its group, which
@@ -208,7 +311,9 @@ print(snap[0]["summary"].get("total-records", "") if snap else "")' 2>/dev/null)
208
311
  fi
209
312
  run_snippet kafka-connect/commands.sh logs >/dev/null 2>&1 || fail "could not read the Connect log"
210
313
  local status
211
- status=$(run_snippet kafka-connect/commands.sh status-remove 2>/dev/null)
314
+ snippet_says iceberg-ingestion/commands.sh records -F '"total-records":"5"' \
315
+ || fail "the guide's records step does not show 5 records"
316
+ status=$(run_snippet iceberg-ingestion/commands.sh status-remove 2>/dev/null)
212
317
  grep -q '"state":"RUNNING"' <<<"$status" || { echo "$status"; fail "the connector status was not RUNNING"; }
213
318
  curl -fsS "http://127.0.0.1:8083/connectors/demo-iceberg" >/dev/null 2>&1 \
214
319
  && fail "the connector is still registered after the delete"
@@ -270,11 +375,11 @@ smoke_flink_kafka_jdbc() {
270
375
  # Two different OVER windows in one streaming query are refused, so the guide
271
376
  # runs one statement for each window size, each writing its own table.
272
377
  local refused
273
- refused=$(snippet flink-sql/steps.sql windows-refused | docker exec -i flink-jobmanager /opt/flink/bin/sql-client.sh 2>&1)
378
+ refused=$(snippet rolling-windows/steps.sql windows-refused | docker exec -i flink-jobmanager /opt/flink/bin/sql-client.sh 2>&1)
274
379
  grep -qF '[ERROR]' <<<"$refused" || fail "Flink did not refuse two different OVER windows: $(tail -5 <<<"$refused")"
275
- run_snippet flink-sql/commands.sh create-window-tables >/dev/null 2>&1 || fail "could not create the window tables"
276
- out=$(snippet flink-sql/steps.sql windows-one-per-size | docker exec -i flink-jobmanager /opt/flink/bin/sql-client.sh 2>&1)
277
- run_snippet flink-sql/commands.sh check-windows >/dev/null 2>&1 || fail "the guide's window check query failed"
380
+ run_snippet rolling-windows/commands.sh create-window-tables >/dev/null 2>&1 || fail "could not create the window tables"
381
+ out=$(snippet rolling-windows/steps.sql windows-one-per-size | docker exec -i flink-jobmanager /opt/flink/bin/sql-client.sh 2>&1)
382
+ run_snippet rolling-windows/commands.sh check-windows >/dev/null 2>&1 || fail "the guide's window check query failed"
278
383
  rows=$(docker exec postgres psql -U user -d odctl -tAc \
279
384
  "SELECT (SELECT count(*) FROM card_sum_10m) || ',' || (SELECT count(*) FROM card_sum_1h)" 2>/dev/null)
280
385
  [ "$rows" = "20,20" ] || { echo "$out" | grep -iE "error|exception" | tail -10; fail "one statement for each OVER window gave '$rows' rows, expected 20,20"; }
@@ -288,11 +393,11 @@ smoke_flink_kafka_jdbc() {
288
393
  smoke_flink_http() {
289
394
  odctl up feast >/dev/null 2>&1 || fail "could not start feast for the Flink HTTP check"
290
395
  retry 40 5 http_ok "http://127.0.0.1:6566/health" || fail "Feast never answered on :6566"
291
- (cd "$SNIPPETS/flink-sql" && run_snippet flink-sql/commands.sh feast-apply) >/dev/null 2>&1 \
396
+ (cd "$SNIPPETS/feast-iceberg" && run_snippet feast-iceberg/commands.sh push-apply) >/dev/null 2>&1 \
292
397
  || fail "could not apply the Feast push source"
293
398
  local out
294
- out=$(snippet flink-sql/steps.sql feast-push | docker exec -i flink-jobmanager /opt/flink/bin/sql-client.sh 2>&1)
295
- feast_value() { run_snippet flink-sql/commands.sh feast-read 2>/dev/null | grep -F 0.8999999761581421; }
399
+ out=$(snippet feast-iceberg/steps.sql feast-push | docker exec -i flink-jobmanager /opt/flink/bin/sql-client.sh 2>&1)
400
+ feast_value() { run_snippet feast-iceberg/commands.sh push-read 2>/dev/null | grep -F 0.8999999761581421; }
296
401
  retry 24 5 feast_value \
297
402
  || { echo "$out" | grep -iE "error|exception" | tail -10; fail "Feast never returned the value the Flink HTTP sink pushed"; }
298
403
  odctl down feast >/dev/null 2>&1 || fail "could not stop feast after the Flink HTTP check"
@@ -421,7 +526,8 @@ smoke_infra() {
421
526
  [ "$metres" = "714" ] || fail "PostGIS distance returned '$metres' km, expected 714"
422
527
  pass "PostGIS: geography distance works"
423
528
  # Read by the telemetry collector's postgresql receiver.
424
- assert_pushed_metrics 'postgresql_.+' PostgreSQL ;;
529
+ assert_pushed_metrics 'postgresql_.+' PostgreSQL
530
+ run_example hybrid-search '^ +2$' ;;
425
531
  storage)
426
532
  retry 30 5 http_reachable "http://127.0.0.1:8333" || fail "S3 API never answered"
427
533
  # A port that answers is not a bucket that stores anything. Spark, Flink,
@@ -457,10 +563,11 @@ smoke_infra() {
457
563
  pass "three uploads of 268 MB passed and seaweed was not killed"
458
564
  # How it works shows how to create a bucket before its profile exists.
459
565
  local buckets
460
- buckets=$(run_snippet storage/commands.sh create-bucket 2>&1) || fail "the create-bucket example failed: $buckets"
566
+ buckets=$(run_snippet how-it-works/commands.sh create-bucket 2>&1) || fail "the create-bucket example failed: $buckets"
461
567
  grep -q "'desflow'" <<<"$buckets" || fail "the bucket list has no desflow: $buckets"
462
568
  docker exec seaweed sh -c "curl -fsS $sig '$cred' -X DELETE 'http://localhost:8333/desflow'" >/dev/null 2>&1 || true
463
569
  pass "the create-bucket example created a bucket with the S3 login"
570
+ how_it_works_edit seaweed-memory .odctl/compose-infra.yml storage '^2147483648$'
464
571
  assert_metrics http://seaweed:9327/metrics ;;
465
572
  catalog)
466
573
  retry 30 5 http_ok "http://127.0.0.1:8181/v1/config" || fail "REST catalog never answered"
@@ -481,6 +588,22 @@ smoke_infra() {
481
588
  | grep -q 'metadata-location' || fail "table metadata did not read back"
482
589
  curl -fsS -X DELETE "http://127.0.0.1:8181/v1/namespaces/smoke/tables/t" >/dev/null 2>&1 || true
483
590
  pass "table created through the catalog and its metadata read back"
591
+ # How It Works reaches the catalog from a container on the odctl network,
592
+ # previews a start and pulls the catalog's images.
593
+ snippet_says how-it-works/commands.sh reach '"defaults"' \
594
+ || fail "a container on the odctl network did not reach catalog:8181"
595
+ snippet_says how-it-works/commands.sh dry-run 'compose-flink.yml' \
596
+ || fail "odctl up flink-lite --dry-run did not list compose-flink.yml"
597
+ snippet_says how-it-works/commands.sh pull 'All images pulled successfully' \
598
+ || fail "odctl pull catalog did not finish"
599
+ run_snippet how-it-works/commands.sh up-pull >/dev/null 2>&1 || fail "odctl up catalog --pull failed"
600
+ snippet_says how-it-works/commands.sh tag-override 'odctl/postgres:1.1.0' \
601
+ || fail "a TAG in the shell did not change the image odctl explain shows"
602
+ snippet_says how-it-works/commands.sh workspace-flag 'catalog' \
603
+ || fail "odctl --workspace ps catalog listed no catalog container"
604
+ run_snippet how-it-works/commands.sh recreate-pull >/dev/null 2>&1 || fail "odctl recreate catalog --pull failed"
605
+ retry 30 5 http_ok "http://127.0.0.1:8181/v1/config" || fail "REST catalog never answered after the recreate"
606
+ pass "How It Works' reach, dry-run, pull, up --pull, TAG, --workspace and recreate --pull examples ran"
484
607
  assert_metrics http://catalog:9404/metrics ;;
485
608
  valkey)
486
609
  retry 30 5 docker exec valkey valkey-cli --no-auth-warning -u "redis://user:password@localhost:6379" ping \
@@ -492,7 +615,8 @@ smoke_infra() {
492
615
  [ "$got" -eq 2 ] || fail "batch MGET returned $got of 2 values, expected 2"
493
616
  pass "authenticated, multi-key write and batch MGET round-tripped"
494
617
  # Read by the telemetry collector's redis receiver.
495
- assert_pushed_metrics 'redis_.+' Valkey ;;
618
+ assert_pushed_metrics 'redis_.+' Valkey
619
+ run_example valkey '^\[5\]$' ;;
496
620
  esac
497
621
  }
498
622
 
@@ -544,7 +668,7 @@ for db in (d.get("data") if isinstance(d, dict) else d) or []:
544
668
  smoke_metabase() {
545
669
  local sid
546
670
  retry 60 5 http_ok "http://127.0.0.1:3000/api/health" || fail "no HTTP response from :3000"
547
- run_snippet trino-metabase/commands.sh metabase-health 2>/dev/null | grep -q '"status":"ok"' \
671
+ snippet_says trino-metabase/commands.sh metabase-health '"status":"ok"' \
548
672
  || fail "Metabase health did not report ok"
549
673
  assert_metrics http://metabase:9191/metrics
550
674
  sid=$(retry_out 12 5 mb_session) || fail "could not obtain a Metabase session"
@@ -676,17 +800,17 @@ smoke_model_server() {
676
800
  smoke_model_features() {
677
801
  odctl up feast >/dev/null 2>&1 || fail "could not start feast for the features model"
678
802
  retry 40 5 http_ok "http://127.0.0.1:6566/health" || fail "Feast never answered on :6566"
679
- (cd "$SNIPPETS/flink-sql" && run_snippet flink-sql/commands.sh feast-apply) >/dev/null 2>&1 \
803
+ (cd "$SNIPPETS/feast-iceberg" && run_snippet feast-iceberg/commands.sh push-apply) >/dev/null 2>&1 \
680
804
  || fail "could not apply the Feast push source"
681
805
  local now
682
806
  now=$(date -u +%Y-%m-%dT%H:%M:%S)
683
807
  retry 12 5 curl -fsS -X POST http://127.0.0.1:6566/push -H 'Content-Type: application/json' \
684
- -d "{\"push_source_name\": \"driver_stats_push\", \"allow_registry_cache\": false, \"df\": {\"driver_id\": [1001], \"conv_rate\": [0.9], \"event_timestamp\": [\"$now\"]}}" \
808
+ -d "{\"push_source_name\": \"driver_push\", \"allow_registry_cache\": false, \"df\": {\"driver_id\": [1001], \"conv_rate\": [0.9], \"event_timestamp\": [\"$now\"]}}" \
685
809
  >/dev/null 2>&1 || fail "could not push a feature value to Feast"
686
810
  mlflow_snippet features-model >/dev/null 2>&1 || fail "could not register the features model"
687
811
  sed -i'' -e '/^MODEL_URI=/d' .odctl/.env
688
812
  cat "$SNIPPETS/mlflow/model-uri-features.env" >> .odctl/.env
689
- odctl up mlflow >/dev/null 2>&1 || fail "could not start mlflow-serve with the features model"
813
+ odctl recreate mlflow >/dev/null 2>&1 || fail "could not recreate mlflow-serve with the features model"
690
814
  retry 30 5 http_ok "http://127.0.0.1:5003/ping" || fail "no HTTP response from :5003 for the features model"
691
815
  local got
692
816
  # feast-serve caches its registry for up to a minute after apply, and until it
@@ -1109,6 +1233,11 @@ smoke_feast() {
1109
1233
  # catalogs, and Iceberg's REST fixture server serves the spec with no prefix,
1110
1234
  # so a real warehouse gives HTTP 400 "Ambiguous URI empty segment".
1111
1235
  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.
1239
+ sed 's/^project: demo$/project: smoke/' "$work/feature_repo/feature_store.yaml" \
1240
+ > "$work/feature_store.yaml" && mv "$work/feature_store.yaml" "$work/feature_repo/feature_store.yaml"
1112
1241
 
1113
1242
  cat > "$work/check.py" <<'PYCHECK'
1114
1243
  import datetime, sys, pandas as pd
@@ -1174,7 +1303,8 @@ PYONLINE
1174
1303
  curl -fsS --max-time 10 -X DELETE \
1175
1304
  "http://127.0.0.1:8181/v1/namespaces/demo/tables/driver_stats?purgeRequested=true" >/dev/null 2>&1 || true
1176
1305
  curl -fsS --max-time 10 -X DELETE "http://127.0.0.1:8181/v1/namespaces/demo" >/dev/null 2>&1 || true
1177
- rm -rf "$work"
1306
+ # The venv stays: the guide's Runnable Example runs with it next, and the feast
1307
+ # case removes $work after that.
1178
1308
  }
1179
1309
 
1180
1310
  # Temporal: a healthy server proves only that it answers. Workflows run in the
@@ -1193,29 +1323,14 @@ smoke_temporal() {
1193
1323
  VIRTUAL_ENV="$work/.venv" uv pip install -q "temporalio==1.34.0" >/dev/null 2>&1 \
1194
1324
  || fail "could not install the temporalio SDK"
1195
1325
 
1196
- # Worker script from the guide as written, then the same script with its Approval
1197
- # workflow added and the body of main replaced by its approval step, as the
1198
- # guide tells the reader to do.
1326
+ # The worker step as the guide writes it, then the Runnable Example, which
1327
+ # adds the Approval workflow and its signal to the same script.
1199
1328
  snippet temporal-workers/steps.py worker > "$work/greet.py"
1200
- snippet temporal-workers/steps.py approval-workflow > "$work/approval.part"
1201
- snippet temporal-workers/steps.py approval-run > "$work/run.part"
1202
- python3 - "$work" <<'PYEOF' || fail "could not combine the guide's approval steps"
1203
- import pathlib, sys, textwrap
1204
- work = pathlib.Path(sys.argv[1])
1205
- script = (work / "greet.py").read_text()
1206
- head, tail = script.split("async def main() -> None:\n")
1207
- body, rest = tail.split("\n\n", 1)
1208
- connect = body.splitlines()[0]
1209
- run = textwrap.indent((work / "run.part").read_text().rstrip(), " ")
1210
- approval = (work / "approval.part").read_text()
1211
- (work / "approval.py").write_text(
1212
- f"{head}{approval}\n\nasync def main() -> None:\n{connect}\n{run}\n\n{rest}")
1213
- PYEOF
1214
1329
  local got
1215
1330
  got=$(cd "$work" && "$work/.venv/bin/python" greet.py 2>&1 | tail -1)
1216
1331
  [ "$got" = "hello odctl" ] || fail "the guide's worker printed '$got', expected hello odctl"
1217
- got=$(cd "$work" && "$work/.venv/bin/python" approval.py 2>&1 | tail -1)
1218
- [ "$got" = "approved by alice" ] || fail "the guide's approval step printed '$got', expected approved by alice"
1332
+ got=$(cd "$work" && "$work/.venv/bin/python" "$SNIPPETS/temporal-workers/example.py" 2>&1 | tail -1)
1333
+ [ "$got" = "approved by alice" ] || fail "the guide's example printed '$got', expected approved by alice"
1219
1334
  pass "the guide's worker retried its activity, and its approval workflow completed on a signal"
1220
1335
 
1221
1336
  cat > "$work/workflows.py" <<'PYEOF'
@@ -1390,9 +1505,11 @@ SPARK_MASTER=http://spark-master:8080/metrics/master/prometheus/
1390
1505
 
1391
1506
  case "$PROFILE" in
1392
1507
  kafka-lite)
1393
- guide_kafka; connect_plugins
1508
+ guide_kafka; connect_plugins; connect_datagen; getting_started
1394
1509
  assert_metrics http://kafka:9404/metrics "${KAFKA_SIDE[@]}"
1395
- smoke_iceberg_sink ;;
1510
+ smoke_iceberg_sink
1511
+ run_example kafka 'Processed a total of 5 messages'
1512
+ run_example kafka-connect 'Processed a total of 3 messages' ;;
1396
1513
  kafka-full)
1397
1514
  smoke_kafka kafka-1; connect_plugins
1398
1515
  assert_metrics http://kafka-{1,2,3}:9404/metrics "${KAFKA_SIDE[@]}" ;;
@@ -1400,31 +1517,42 @@ case "$PROFILE" in
1400
1517
  smoke_flink
1401
1518
  smoke_flink_kafka_jdbc
1402
1519
  smoke_flink_http
1403
- assert_metrics http://jobmanager:9249/metrics http://taskmanager-a:9249/metrics ;;
1520
+ assert_metrics http://jobmanager:9249/metrics http://taskmanager-a:9249/metrics
1521
+ run_example flink-sql 'b +\| +1 +\| +7\.5'
1522
+ run_example rolling-windows '20 +\| +20'
1523
+ run_example iceberg-ingestion 'records written by the Kafka Connect sink' ;;
1404
1524
  flink-full)
1405
1525
  smoke_flink
1406
1526
  assert_metrics http://jobmanager:9249/metrics http://taskmanager-{a,b,c}:9249/metrics ;;
1407
1527
  spark-lite)
1408
1528
  smoke_spark
1409
- assert_metrics "$SPARK_MASTER" http://spark-worker-1:8081/metrics/prometheus/ ;;
1529
+ assert_metrics "$SPARK_MASTER" http://spark-worker-1:8081/metrics/prometheus/
1530
+ run_example spark '"n":3' ;;
1410
1531
  spark-full)
1411
1532
  smoke_spark
1412
1533
  assert_metrics "$SPARK_MASTER" http://spark-worker-{1,2,3}:8081/metrics/prometheus/ ;;
1413
- ch-lite) smoke_ch_lite ;;
1534
+ ch-lite) smoke_ch_lite; run_example clickhouse 'ch-12 holds 50 rows' ;;
1414
1535
  ch-full) smoke_ch_full ;;
1415
- trino) smoke_trino ;;
1536
+ trino) smoke_trino; how_it_works_trino ;;
1416
1537
  deps) smoke_deps ;;
1417
1538
  postgres|storage|catalog|valkey) smoke_infra ;;
1418
- metabase) smoke_metabase ;;
1419
- airflow) smoke_airflow ;;
1539
+ metabase) smoke_metabase; run_example trino-metabase '"status":"ok"' ;;
1540
+ airflow) smoke_airflow; run_example airflow-dags 'success'; how_it_works_airflow ;;
1420
1541
  temporal) smoke_temporal ;;
1421
- mlflow) smoke_mlflow ;;
1422
- lineage) smoke_lineage ;;
1423
- telemetry) smoke_telemetry ;;
1424
- metadata) smoke_metadata ;;
1425
- fluss) smoke_fluss ;;
1426
- feast) smoke_feast ;;
1427
- evidently) smoke_evidently ;;
1542
+ mlflow)
1543
+ smoke_mlflow
1544
+ how_it_works_edit mlops-deps .odctl/.env mlflow '^0\.9\.0$'
1545
+ how_it_works_edit timezone .odctl/.env mlflow '^AE[DS]T$'
1546
+ run_example mlflow '"predictions": \[0, 0\]' "$(mlflow_runner) bash" ;;
1547
+ lineage) smoke_lineage; run_example lineage 'COMPLETED' ;;
1548
+ telemetry) smoke_telemetry; run_example telemetry '"42"' ;;
1549
+ metadata) smoke_metadata; run_example openmetadata 'removed demo_pg' ;;
1550
+ fluss) smoke_fluss; run_example fluss '\| +3 +\| +c +\|' ;;
1551
+ feast)
1552
+ smoke_feast
1553
+ run_example feast-iceberg '0.8999999761581421' "env PATH=${TMPDIR:-/tmp}/odctl-feast-smoke/.venv/bin:$PATH bash"
1554
+ rm -rf "${TMPDIR:-/tmp}/odctl-feast-smoke" ;;
1555
+ evidently) smoke_evidently; run_example evidently-reports 'report pushed to project' "$(evidently_runner)" ;;
1428
1556
  *)
1429
1557
  echo "ℹ️ $PROFILE: no functional assertion defined, checking containers only"
1430
1558
  [ "$(docker ps -q | wc -l)" -ge 1 ] || fail "no containers running"
@@ -8,14 +8,17 @@ A CLI that starts a local open data stack with Docker Compose profiles: Kafka, F
8
8
  |---|---|
9
9
  | `src/odctl/main.py` | the Typer CLI (`list`, `explain`, `ps`, `info`, `init`, `pull`, `up`, `down`, `logs`, `restart`, `recreate`) |
10
10
  | `src/odctl/registry.py`, `resources/registry.yml` | profiles, the services in each, dependencies, ports and usage text |
11
- | `src/odctl/planner.py`, `docker.py`, `workspace.py`, `config.py`, `ui.py` | startup order, Docker Compose calls, the `.odctl` workspace, settings, terminal output |
11
+ | `src/odctl/planner.py`, `docker.py`, `projects.py`, `logs.py`, `workspace.py`, `config.py`, `ui.py` | startup order, Docker Compose calls, `restart` and `recreate`, and `logs`, each with one Compose project per file as `up` runs them, the `.odctl` workspace, settings, terminal output |
12
12
  | `src/odctl/resources/compose-*.yml` | one compose file per area (kafka, flink, spark, store, analytics, mlops, orch, obsv, metadata, infra, deps) |
13
13
  | `src/odctl/resources/docker/` | odctl's own images (postgres, mlflow, airflow, spark), published to `ghcr.io/jaehyeon-kim/odctl/<name>:<version>` |
14
14
  | `src/odctl/resources/deps/` | the `deps` image: connector and engine jars built or downloaded into the `odctl-shared-deps` volume; versions in `versions.env` |
15
- | `scripts/gen_pages.py` | builds a page per technology and the CLI reference at docs build time, from `registry.yml`, the compose files and `--help`; run directly, it writes `skills/odctl/SKILL.md`, which a test keeps in step |
16
- | `scripts/diagrams.py`, `docs/images/*.drawio` | exports each draw.io diagram to the PNG, or GIF for steps, that the docs use; `_legend.drawio` holds the shared shapes |
17
- | `.github/scripts/smoke.sh` | the e2e assertions per profile group, which run the guide snippets; `select-profiles.py` chooses the groups |
18
- | `docs/snippets/` | the code each guide shows, one folder per guide |
15
+ | `scripts/gen_pages.py` | builds a page per technology and the CLI reference at docs build time, from `registry.yml`, the compose files and `--help`; run directly, it writes `skills/odctl/SKILL.md`, with every command, option and `.env` key, which a test keeps in step |
16
+ | `scripts/local_links.py`, `scripts/llmstxt_sections.py`, `scripts/release_links.py` | MkDocs hooks: clickable local addresses, the `llms.txt` sections from the nav, and links to files on `main` pointed at the release tag `v<version>` |
17
+ | `scripts/diagrams.py`, `docs/assets/diagrams/*.drawio` | exports each draw.io diagram to the PNG, or GIF for steps, that the docs use, and writes a still PNG of each GIF's last frame beside it; `_legend.drawio` holds the shared shapes; `tests/test_diagrams.py` checks the exports and the stills |
18
+ | `docs/images/` | screenshots of the services' web UIs and the terminal |
19
+ | `image/` | the README's diagram and recording, with the recording's still; they stay here because the PyPI page of every released version links them on `main` |
20
+ | `.github/scripts/smoke.sh` | the e2e assertions per profile group, which run the guide snippets and each guide's Runnable Example; `select-profiles.py` chooses the groups |
21
+ | `docs/snippets/` | the code each guide, Getting Started and How It Works show, one folder per page |
19
22
  | `skills/odctl/SKILL.md` | the odctl skill for AI assistants, generated by `scripts/gen_pages.py` |
20
23
  | `docs/` | MkDocs Material site, versioned with mike; `docs/about/` holds the public Roadmap and the Changelog |
21
24
  | `scripts/release_notes.py` | prints one version's Changelog section, the text of its GitHub release |
@@ -40,7 +43,9 @@ Run the unit suite, `pytest tests/`, in full once before each commit, never afte
40
43
  - **One profile per tool**, with `-lite` and `-full` variants where a tool has a small and a clustered form (`kafka-lite`, `kafka-full`).
41
44
  - **Fixed container names.** Every service has a `container_name`, so `odctl up` cannot scale a service; several copies are separate named services (Flink's `taskmanager-a` and `taskmanager-b`).
42
45
  - **Docs pages that list versions or ports are generated** by `scripts/gen_pages.py`; do not copy those values into hand-written guides.
43
- - **Guide code lives in `docs/snippets/<guide>/`**, one section per step (`# --8<-- [start:name]`). A guide block labelled `title="path:section"` or `title="path"` is a copy of that section or file, checked by `tests/test_docs.py`; use copies, not `--8<--` includes, so the Markdown reads correctly on GitHub. `smoke.sh` runs the same sections, and `select-profiles.py` maps each snippet folder to the e2e groups that run it.
46
+ - **Guide code lives in `docs/snippets/<guide>/`**, one section per step (`# --8<-- [start:name]`), and Getting Started and How It Works follow the same rule. A block labelled `title="path:section"` or `title="path"` is a copy of that section or file, checked by `tests/test_docs.py`; use copies, not `--8<--` includes, so the Markdown reads correctly on GitHub. Every guide ends with a Runnable Example, `example.sh` or `example.py` in its folder. `smoke.sh` runs the same sections and examples, and `select-profiles.py` maps each snippet folder to the e2e groups that run it.
47
+ - **A feature merges only with its docs.** Each command, option, profile and `.env` key needs a tested example on a guide, Getting Started or How It Works, a reference entry (the CLI reference, a profile page or How It Works), and a place in the skill; a command also needs a line in the README's Commands section. `test_every_feature_has_its_docs` checks this, and its short list of exceptions gives a reason for each.
48
+ - **Each guide says which pages it builds on**, the output of every step, and where a value it quotes is set. A page has at most 10 `##` headings and 15 in all, and headings use title case, keeping product names such as `odctl` as they are.
44
49
  - **e2e runs only on tags and manual dispatch**, because a PR's images for an unreleased version do not exist yet. A PR gets lint, unit tests, image builds when image inputs change, and the strict docs build.
45
50
  - **Renovate PRs** open on Mondays, image updates grouped by compose file. Show any Renovate config change to the owner before making it.
46
51
  - **Writing.** Plain English for non-native readers. No em or en dashes. Headings do not start with "The". Markdown paragraphs are one line each, never hard-wrapped.