odctl 0.3.0__tar.gz → 0.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 (90) hide show
  1. odctl-0.4.0/.github/scripts/smoke.sh +196 -0
  2. {odctl-0.3.0 → odctl-0.4.0}/.github/workflows/e2e-test.yml +33 -27
  3. odctl-0.4.0/.github/workflows/pipeline.yml +69 -0
  4. odctl-0.4.0/.github/workflows/publish.yml +90 -0
  5. {odctl-0.3.0 → odctl-0.4.0}/PKG-INFO +1 -1
  6. {odctl-0.3.0 → odctl-0.4.0}/pyproject.toml +1 -1
  7. {odctl-0.3.0 → odctl-0.4.0}/src/odctl/docker.py +18 -2
  8. {odctl-0.3.0 → odctl-0.4.0}/src/odctl/main.py +6 -2
  9. {odctl-0.3.0 → odctl-0.4.0}/src/odctl/planner.py +43 -0
  10. {odctl-0.3.0 → odctl-0.4.0}/src/odctl/resources/clickhouse/config.xml +21 -4
  11. {odctl-0.3.0 → odctl-0.4.0}/src/odctl/resources/compose-analytics.yml +7 -0
  12. {odctl-0.3.0 → odctl-0.4.0}/src/odctl/resources/compose-infra.yml +22 -4
  13. {odctl-0.3.0 → odctl-0.4.0}/src/odctl/resources/compose-kafka.yml +5 -5
  14. {odctl-0.3.0 → odctl-0.4.0}/src/odctl/resources/compose-metadata.yml +2 -2
  15. {odctl-0.3.0 → odctl-0.4.0}/src/odctl/resources/compose-spark.yml +8 -0
  16. {odctl-0.3.0 → odctl-0.4.0}/src/odctl/resources/compose-store.yml +2 -2
  17. {odctl-0.3.0 → odctl-0.4.0}/src/odctl/resources/deps/Dockerfile +7 -3
  18. {odctl-0.3.0 → odctl-0.4.0}/src/odctl/resources/deps/build.gradle +4 -2
  19. {odctl-0.3.0 → odctl-0.4.0}/src/odctl/resources/deps/fetch_flink_dependencies.sh +5 -6
  20. {odctl-0.3.0 → odctl-0.4.0}/src/odctl/resources/deps/fetch_spark_dependencies.sh +12 -2
  21. {odctl-0.3.0 → odctl-0.4.0}/src/odctl/resources/deps/fetch_standalone_plugins.sh +6 -2
  22. odctl-0.4.0/src/odctl/resources/deps/versions.env +37 -0
  23. {odctl-0.3.0 → odctl-0.4.0}/src/odctl/resources/registry.yml +12 -7
  24. {odctl-0.3.0 → odctl-0.4.0}/src/odctl/resources/spark/spark-defaults.conf +18 -0
  25. odctl-0.4.0/src/odctl/resources/trino/access-control.properties +22 -0
  26. odctl-0.4.0/src/odctl/resources/trino/group-provider.properties +5 -0
  27. odctl-0.4.0/src/odctl/resources/trino/groups.txt +4 -0
  28. odctl-0.4.0/src/odctl/resources/trino/rules.json +49 -0
  29. odctl-0.4.0/tests/test_compose_profiles.py +126 -0
  30. {odctl-0.3.0 → odctl-0.4.0}/tests/test_docker.py +3 -1
  31. odctl-0.3.0/.github/workflows/pipeline.yml +0 -34
  32. odctl-0.3.0/.github/workflows/publish.yml +0 -34
  33. {odctl-0.3.0 → odctl-0.4.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  34. {odctl-0.3.0 → odctl-0.4.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  35. {odctl-0.3.0 → odctl-0.4.0}/.github/renovate.json +0 -0
  36. {odctl-0.3.0 → odctl-0.4.0}/.github/workflows/build-deps.yml +0 -0
  37. {odctl-0.3.0 → odctl-0.4.0}/.github/workflows/build-platform-images.yml +0 -0
  38. {odctl-0.3.0 → odctl-0.4.0}/.github/workflows/ci.yml +0 -0
  39. {odctl-0.3.0 → odctl-0.4.0}/.gitignore +0 -0
  40. {odctl-0.3.0 → odctl-0.4.0}/.pre-commit-config.yaml +0 -0
  41. {odctl-0.3.0 → odctl-0.4.0}/.vscode/settings.json +0 -0
  42. {odctl-0.3.0 → odctl-0.4.0}/LICENSE +0 -0
  43. {odctl-0.3.0 → odctl-0.4.0}/README.md +0 -0
  44. {odctl-0.3.0 → odctl-0.4.0}/image/diagram.png +0 -0
  45. {odctl-0.3.0 → odctl-0.4.0}/image/diagram1.drawio +0 -0
  46. {odctl-0.3.0 → odctl-0.4.0}/image/diagram2.drawio +0 -0
  47. {odctl-0.3.0 → odctl-0.4.0}/src/odctl/__init__.py +0 -0
  48. {odctl-0.3.0 → odctl-0.4.0}/src/odctl/config.py +0 -0
  49. {odctl-0.3.0 → odctl-0.4.0}/src/odctl/registry.py +0 -0
  50. {odctl-0.3.0 → odctl-0.4.0}/src/odctl/resources/clickhouse/init.sql +0 -0
  51. {odctl-0.3.0 → odctl-0.4.0}/src/odctl/resources/clickhouse/keeper_config.xml +0 -0
  52. {odctl-0.3.0 → odctl-0.4.0}/src/odctl/resources/clickhouse/macros-11.xml +0 -0
  53. {odctl-0.3.0 → odctl-0.4.0}/src/odctl/resources/clickhouse/macros-12.xml +0 -0
  54. {odctl-0.3.0 → odctl-0.4.0}/src/odctl/resources/clickhouse/macros-21.xml +0 -0
  55. {odctl-0.3.0 → odctl-0.4.0}/src/odctl/resources/clickhouse/macros-22.xml +0 -0
  56. {odctl-0.3.0 → odctl-0.4.0}/src/odctl/resources/clickhouse/users.xml +0 -0
  57. {odctl-0.3.0 → odctl-0.4.0}/src/odctl/resources/compose-deps.yml +0 -0
  58. {odctl-0.3.0 → odctl-0.4.0}/src/odctl/resources/compose-flink.yml +0 -0
  59. {odctl-0.3.0 → odctl-0.4.0}/src/odctl/resources/compose-mlops.yml +0 -0
  60. {odctl-0.3.0 → odctl-0.4.0}/src/odctl/resources/compose-obsv.yml +0 -0
  61. {odctl-0.3.0 → odctl-0.4.0}/src/odctl/resources/compose-orch.yml +0 -0
  62. {odctl-0.3.0 → odctl-0.4.0}/src/odctl/resources/deps/build_connectors.sh +0 -0
  63. {odctl-0.3.0 → odctl-0.4.0}/src/odctl/resources/docker/airflow/Dockerfile +0 -0
  64. {odctl-0.3.0 → odctl-0.4.0}/src/odctl/resources/docker/ray-mlops/Dockerfile +0 -0
  65. {odctl-0.3.0 → odctl-0.4.0}/src/odctl/resources/docker/spark/Dockerfile +0 -0
  66. {odctl-0.3.0 → odctl-0.4.0}/src/odctl/resources/flink/flink-conf.yml +0 -0
  67. {odctl-0.3.0 → odctl-0.4.0}/src/odctl/resources/grafana/prometheus.yml +0 -0
  68. {odctl-0.3.0 → odctl-0.4.0}/src/odctl/resources/marquez/marquez.yml +0 -0
  69. {odctl-0.3.0 → odctl-0.4.0}/src/odctl/resources/mlops/serving/lightgbm_inference.py.example +0 -0
  70. {odctl-0.3.0 → odctl-0.4.0}/src/odctl/resources/mlops/serving/vector_search.py.example +0 -0
  71. {odctl-0.3.0 → odctl-0.4.0}/src/odctl/resources/postgres/01-init-databases.sh +0 -0
  72. {odctl-0.3.0 → odctl-0.4.0}/src/odctl/resources/prometheus/alertmanager.yml +0 -0
  73. {odctl-0.3.0 → odctl-0.4.0}/src/odctl/resources/prometheus/prometheus.yml +0 -0
  74. {odctl-0.3.0 → odctl-0.4.0}/src/odctl/resources/seaweed/s3.json +0 -0
  75. {odctl-0.3.0 → odctl-0.4.0}/src/odctl/resources/spark/log4j2.properties +0 -0
  76. {odctl-0.3.0 → odctl-0.4.0}/src/odctl/resources/trino/catalog/clickhouse.properties +0 -0
  77. {odctl-0.3.0 → odctl-0.4.0}/src/odctl/resources/trino/catalog/iceberg.properties +0 -0
  78. {odctl-0.3.0 → odctl-0.4.0}/src/odctl/resources/trino/catalog/kafka.properties +0 -0
  79. {odctl-0.3.0 → odctl-0.4.0}/src/odctl/resources/trino/catalog/postgres.properties +0 -0
  80. {odctl-0.3.0 → odctl-0.4.0}/src/odctl/resources/trino/catalog/valkey.properties +0 -0
  81. {odctl-0.3.0 → odctl-0.4.0}/src/odctl/ui.py +0 -0
  82. {odctl-0.3.0 → odctl-0.4.0}/src/odctl/workspace.py +0 -0
  83. {odctl-0.3.0 → odctl-0.4.0}/tests/conftest.py +0 -0
  84. {odctl-0.3.0 → odctl-0.4.0}/tests/test_cli.py +0 -0
  85. {odctl-0.3.0 → odctl-0.4.0}/tests/test_cli_extended.py +0 -0
  86. {odctl-0.3.0 → odctl-0.4.0}/tests/test_config.py +0 -0
  87. {odctl-0.3.0 → odctl-0.4.0}/tests/test_planner.py +0 -0
  88. {odctl-0.3.0 → odctl-0.4.0}/tests/test_registry.py +0 -0
  89. {odctl-0.3.0 → odctl-0.4.0}/tests/test_workspace.py +0 -0
  90. {odctl-0.3.0 → odctl-0.4.0}/uv.lock +0 -0
@@ -0,0 +1,196 @@
1
+ #!/usr/bin/env bash
2
+ # Functional smoke test for one odctl profile.
3
+ #
4
+ # Liveness is not enough: every bug fixed in 0.2.x and 0.3.0 left its containers
5
+ # running and healthy while the profile was unusable. Kafka consumer groups hung
6
+ # forever, Spark could not start a SparkContext, Flink could not find a connector
7
+ # factory, ClickHouse could not consume from the broker, the Trino ClickHouse
8
+ # catalog pointed at a hostname that did not exist, and the Flink Iceberg sink
9
+ # committed nothing while its checkpoints succeeded. Each assertion below exists
10
+ # because something shipped broken past a liveness check.
11
+ #
12
+ # Usage: smoke.sh <profile>
13
+ set -uo pipefail
14
+
15
+ PROFILE="${1:?usage: smoke.sh <profile>}"
16
+
17
+ fail() { echo "❌ $PROFILE: $*"; exit 1; }
18
+ pass() { echo "✅ $PROFILE: $*"; }
19
+
20
+ # Retry a command until it succeeds or the budget runs out.
21
+ retry() {
22
+ local tries="$1" delay="$2"; shift 2
23
+ for ((i = 1; i <= tries; i++)); do
24
+ if "$@" >/dev/null 2>&1; then return 0; fi
25
+ sleep "$delay"
26
+ done
27
+ return 1
28
+ }
29
+
30
+ http_ok() { curl -fsS -o /dev/null --max-time 10 "$1"; }
31
+
32
+ # Some endpoints answer 403 or 404 to an unauthenticated probe, which still
33
+ # proves the service is listening.
34
+ http_reachable() {
35
+ local code
36
+ code=$(curl -s -o /dev/null --max-time 10 -w '%{http_code}' "$1")
37
+ [ -n "$code" ] && [ "$code" != "000" ]
38
+ }
39
+
40
+ ch() { docker exec "$1" clickhouse-client --password password -q "$2" 2>&1; }
41
+
42
+ kafka_topic() {
43
+ docker exec kafka /opt/kafka/bin/kafka-topics.sh --bootstrap-server broker-1:19092 "$@"
44
+ }
45
+
46
+ smoke_kafka() {
47
+ local container="$1"
48
+ retry 30 5 docker exec "$container" /opt/kafka/bin/kafka-topics.sh \
49
+ --bootstrap-server broker-1:19092 --list || fail "broker never became reachable"
50
+ docker exec "$container" /opt/kafka/bin/kafka-topics.sh --bootstrap-server broker-1:19092 \
51
+ --create --topic smoke --partitions 1 --replication-factor 1 >/dev/null 2>&1
52
+ for i in 1 2 3 4 5; do echo "{\"n\":$i}"; done | docker exec -i "$container" \
53
+ /opt/kafka/bin/kafka-console-producer.sh --bootstrap-server broker-1:19092 --topic smoke >/dev/null 2>&1 \
54
+ || fail "produce failed"
55
+ # Group mode on purpose. Assign mode bypasses the coordinator and would pass
56
+ # even when __consumer_offsets cannot be created on a single broker.
57
+ local got
58
+ got=$(docker exec "$container" /opt/kafka/bin/kafka-console-consumer.sh \
59
+ --bootstrap-server broker-1:19092 --topic smoke --from-beginning \
60
+ --max-messages 5 --timeout-ms 60000 2>/dev/null | grep -c '"n"')
61
+ [ "$got" -eq 5 ] || fail "group-mode consume returned $got of 5 messages"
62
+ pass "produced and consumed 5 messages through a consumer group"
63
+ }
64
+
65
+ smoke_flink() {
66
+ retry 40 5 http_ok "http://127.0.0.1:8082/config" || fail "JobManager REST never answered"
67
+ local out
68
+ out=$(docker exec -i flink-jobmanager /opt/flink/bin/sql-client.sh 2>&1 <<'SQL'
69
+ CREATE CATALOG ice WITH ('type'='iceberg','catalog-type'='rest','uri'='http://catalog:8181','warehouse'='s3://warehouse','s3.endpoint'='http://seaweed:8333','s3.path-style-access'='true','s3.access-key-id'='user','s3.secret-access-key'='password');
70
+ SHOW CATALOGS;
71
+ SQL
72
+ )
73
+ grep -q "ice" <<<"$out" || { echo "$out" | tail -20; fail "Iceberg catalog did not register (connector factory or Hadoop classes missing)"; }
74
+ # The image ships --add-opens in config.yaml; mounting a replacement wipes them
75
+ # and Kryo then fails on java.nio buffers at checkpoint time.
76
+ docker exec flink-jobmanager sh -c 'ps ax | grep -q "add-opens=java.base/java.nio"' \
77
+ || fail "JVM module flags missing from the TaskManager command line"
78
+ pass "Iceberg catalog registered and JVM module flags present"
79
+ }
80
+
81
+ smoke_spark() {
82
+ local out
83
+ out=$(docker exec spark-master /opt/spark/bin/spark-sql -e "
84
+ CREATE NAMESPACE IF NOT EXISTS iceberg.smoke;
85
+ CREATE OR REPLACE TABLE iceberg.smoke.t (id BIGINT) USING iceberg;
86
+ INSERT INTO iceberg.smoke.t VALUES (1),(2),(3);
87
+ SELECT count(*) FROM iceberg.smoke.t;" 2>&1)
88
+ grep -qE "^3$" <<<"$out" || { echo "$out" | tail -20; fail "spark-sql could not round-trip an Iceberg table"; }
89
+ docker exec spark-master sh -c 'ls /tmp/spark-events | head -1' >/dev/null 2>&1 \
90
+ || fail "no event log written, so the event log directory is not writable"
91
+ pass "spark-sql wrote and read an Iceberg table, event logging works"
92
+ }
93
+
94
+ smoke_ch_lite() {
95
+ retry 30 5 docker exec ch-11 clickhouse-client --password password -q "SELECT 1" \
96
+ || fail "ch-11 never answered"
97
+ # Databases come from /docker-entrypoint-initdb.d on every fresh server.
98
+ ch ch-11 "SHOW DATABASES" | grep -q feature_store || fail "feature_store database missing"
99
+ ch ch-11 "DROP TABLE IF EXISTS default.smoke" >/dev/null
100
+ ch ch-11 "CREATE TABLE default.smoke (id UInt32) ENGINE=MergeTree ORDER BY id" >/dev/null
101
+ ch ch-11 "INSERT INTO default.smoke SELECT number FROM numbers(10)" >/dev/null
102
+ [ "$(ch ch-11 "SELECT count() FROM default.smoke")" = "10" ] || fail "MergeTree round trip failed"
103
+ pass "databases initialised and MergeTree round trip works"
104
+ }
105
+
106
+ smoke_ch_full() {
107
+ retry 30 5 docker exec ch-21 clickhouse-client --password password -q "SELECT 1" \
108
+ || fail "ch-21 never answered"
109
+ # Exercises keeper and the dual-stack listeners: replicated DDL fails with
110
+ # "Connection refused" when keeper listens on IPv4 only.
111
+ ch ch-11 "DROP TABLE IF EXISTS default.repl ON CLUSTER '{cluster}' SYNC" >/dev/null 2>&1
112
+ ch ch-11 "CREATE TABLE default.repl ON CLUSTER '{cluster}' (id UInt32) ENGINE=ReplicatedMergeTree ORDER BY id" >/dev/null \
113
+ || fail "replicated DDL failed, check keeper reachability"
114
+ ch ch-11 "INSERT INTO default.repl SELECT number FROM numbers(50)" >/dev/null
115
+ retry 12 5 bash -c '[ "$(docker exec ch-12 clickhouse-client --password password -q "SELECT count() FROM default.repl" 2>/dev/null)" = "50" ]' \
116
+ || fail "rows never replicated to the sibling replica"
117
+ [ "$(ch ch-21 "SELECT count() FROM default.repl")" = "0" ] || fail "shard 2 unexpectedly holds shard 1 data"
118
+ pass "replicated table converged on shard 1 and stayed off shard 2"
119
+ }
120
+
121
+ # Catalog registration finishes after the health endpoint starts answering, so
122
+ # poll rather than asserting once. Catalogs load even when their backing service
123
+ # is absent, which is why this can be required in a trino-only profile group.
124
+ catalogs_loaded() {
125
+ local cat
126
+ cat=$(docker exec trino trino --execute "SHOW CATALOGS" 2>/dev/null | tr -d '"')
127
+ for expected in iceberg kafka clickhouse postgres; do
128
+ grep -q "^${expected}$" <<<"$cat" || return 1
129
+ done
130
+ }
131
+
132
+ smoke_trino() {
133
+ retry 40 5 http_ok "http://127.0.0.1:8080/v1/info" || fail "Trino never answered"
134
+ retry 24 5 catalogs_loaded || {
135
+ docker exec trino trino --execute "SHOW CATALOGS" 2>&1 | tail -10
136
+ fail "not every catalog loaded within two minutes"
137
+ }
138
+ docker exec trino trino --execute "SELECT 1" >/dev/null 2>&1 || fail "query execution failed"
139
+ pass "all catalogs loaded and a query ran"
140
+ }
141
+
142
+ smoke_infra() {
143
+ case "$PROFILE" in
144
+ postgres)
145
+ retry 30 5 docker exec postgres pg_isready -U user || fail "postgres never became ready"
146
+ docker exec postgres psql -U user -d odctl -c "SELECT 1" >/dev/null 2>&1 || fail "psql query failed"
147
+ pass "accepting connections and running queries" ;;
148
+ storage)
149
+ retry 30 5 http_reachable "http://127.0.0.1:8333" || fail "S3 API never answered"
150
+ pass "S3 API reachable" ;;
151
+ catalog)
152
+ retry 30 5 http_ok "http://127.0.0.1:8181/v1/config" || fail "REST catalog never answered"
153
+ curl -fsS -X POST -H 'Content-Type: application/json' \
154
+ -d '{"namespace":["smoke"]}' "http://127.0.0.1:8181/v1/namespaces" >/dev/null 2>&1
155
+ curl -fsS "http://127.0.0.1:8181/v1/namespaces" 2>/dev/null | grep -q smoke \
156
+ || fail "namespace create or list failed"
157
+ pass "namespace created and listed through the REST API" ;;
158
+ valkey)
159
+ retry 30 5 docker exec valkey valkey-cli -u redis://user:password@localhost:6379 ping \
160
+ || fail "valkey never answered PING"
161
+ pass "responding to PING" ;;
162
+ esac
163
+ }
164
+
165
+ # A profile with no functional assertion yet still has to expose its endpoint.
166
+ smoke_http_only() {
167
+ local url="$1"
168
+ retry 60 5 http_ok "$url" || fail "no HTTP response from $url"
169
+ pass "HTTP endpoint answering at $url"
170
+ }
171
+
172
+ case "$PROFILE" in
173
+ kafka-lite) smoke_kafka kafka ;;
174
+ kafka-full) smoke_kafka kafka-1 ;;
175
+ flink-lite|flink-full) smoke_flink ;;
176
+ spark-lite|spark-full) smoke_spark ;;
177
+ ch-lite) smoke_ch_lite ;;
178
+ ch-full) smoke_ch_full ;;
179
+ trino) smoke_trino ;;
180
+ postgres|storage|catalog|valkey) smoke_infra ;;
181
+ metabase) smoke_http_only "http://127.0.0.1:3000/api/health" ;;
182
+ airflow) smoke_http_only "http://127.0.0.1:8085/api/v2/monitor/health" ;;
183
+ mlflow) smoke_http_only "http://127.0.0.1:5000/health" ;;
184
+ ray-serve) smoke_http_only "http://127.0.0.1:8265" ;;
185
+ lineage) smoke_http_only "http://127.0.0.1:5002/api/v1/namespaces" ;;
186
+ telemetry) smoke_http_only "http://127.0.0.1:19090/-/ready" ;;
187
+ metadata) smoke_http_only "http://127.0.0.1:8585/api/v1/system/version" ;;
188
+ fluss)
189
+ retry 40 5 bash -c 'docker ps --format "{{.Names}}" | grep -q fluss-coordinator' \
190
+ || fail "coordinator container never appeared"
191
+ pass "coordinator running" ;;
192
+ *)
193
+ echo "ℹ️ $PROFILE: no functional assertion defined, checking containers only"
194
+ [ "$(docker ps -q | wc -l)" -ge 1 ] || fail "no containers running"
195
+ pass "containers running" ;;
196
+ esac
@@ -3,8 +3,6 @@ name: End-to-End Integration Tests
3
3
  on:
4
4
  workflow_call:
5
5
 
6
-
7
-
8
6
  jobs:
9
7
  e2e:
10
8
  runs-on: ubuntu-latest
@@ -12,18 +10,30 @@ jobs:
12
10
  contents: read
13
11
  packages: read
14
12
  strategy:
13
+ fail-fast: false
15
14
  matrix:
15
+ # Profiles are grouped only where they share a large image, so the pull
16
+ # is amortised: the lite and full variants of Kafka, Flink, Spark and
17
+ # ClickHouse. Everything else runs alone, which keeps the failure signal
18
+ # per profile and shortens the critical path.
16
19
  profile_group:
17
- - "metadata"
18
- - "airflow"
19
20
  - "kafka-lite kafka-full"
20
- - "flink1-lite flink1-full fluss"
21
+ - "flink-lite flink-full"
21
22
  - "spark-lite spark-full"
22
23
  - "ch-lite ch-full"
23
- - "trino metabase"
24
- - "mlflow ray-serve"
25
- - "lineage telemetry"
26
- - "postgres storage catalog valkey"
24
+ - "fluss"
25
+ - "trino"
26
+ - "metabase"
27
+ - "mlflow"
28
+ - "ray-serve"
29
+ - "lineage"
30
+ - "telemetry"
31
+ - "metadata"
32
+ - "airflow"
33
+ - "postgres"
34
+ - "storage"
35
+ - "catalog"
36
+ - "valkey"
27
37
  steps:
28
38
  - name: Checkout repository
29
39
  uses: actions/checkout@v4
@@ -49,31 +59,27 @@ jobs:
49
59
 
50
60
  - name: Test Profile Group
51
61
  run: |
52
- # Split the space-separated group into an array
62
+ chmod +x .github/scripts/smoke.sh
53
63
  for profile in ${{ matrix.profile_group }}; do
54
64
  echo "::group::Testing Profile: $profile"
55
65
  echo "🚀 Launching $profile..."
56
- odctl up $profile
66
+ odctl up "$profile"
57
67
 
58
- echo "⏳ Validating containers..."
59
- SUCCESS=0
60
- for i in {1..15}; do
61
- RUNNING=$(docker ps -q | wc -l)
62
- if [ "$RUNNING" -ge 1 ]; then
63
- echo "✅ Containers are running!"
64
- docker ps
65
- odctl ps --all
66
- SUCCESS=1
67
- break
68
- fi
69
- echo "Waiting... ($i/15)"
70
- sleep 2
71
- done
68
+ echo "⏳ Containers:"
69
+ docker ps
72
70
 
73
- if [ "$SUCCESS" -eq 0 ]; then
74
- echo "❌ Timeout reached: No containers are running for $profile!"
71
+ # Liveness alone has never been enough here: every bug fixed in 0.2.x
72
+ # and 0.3.0 left healthy containers behind a broken profile, so the
73
+ # smoke script asserts the profile can actually do its job.
74
+ echo "🔍 Smoke testing $profile..."
75
+ if ! .github/scripts/smoke.sh "$profile"; then
76
+ echo "::error::Smoke test failed for $profile"
75
77
  docker ps -a
76
78
  odctl ps --all
79
+ for c in $(docker ps -aq); do
80
+ echo "---- logs: $(docker inspect --format '{{.Name}}' "$c") ----"
81
+ docker logs --tail 40 "$c" 2>&1 || true
82
+ done
77
83
  exit 1
78
84
  fi
79
85
 
@@ -0,0 +1,69 @@
1
+ name: Main Pipeline
2
+
3
+ on:
4
+ push:
5
+ branches: ["main"]
6
+ tags: ["v*"]
7
+ pull_request:
8
+ branches: ["main"]
9
+ workflow_dispatch:
10
+
11
+ concurrency:
12
+ group: ${{ github.workflow }}-${{ github.ref }}
13
+ cancel-in-progress: true
14
+
15
+ permissions:
16
+ contents: read
17
+ packages: write
18
+
19
+ jobs:
20
+ # Which parts of the tree moved. Used to skip the multi-arch image builds on
21
+ # changes that cannot affect them, which is most changes.
22
+ changes:
23
+ runs-on: ubuntu-latest
24
+ permissions:
25
+ contents: read
26
+ pull-requests: read
27
+ outputs:
28
+ images: ${{ steps.filter.outputs.images }}
29
+ steps:
30
+ - uses: actions/checkout@v4
31
+ - uses: dorny/paths-filter@v3
32
+ id: filter
33
+ with:
34
+ filters: |
35
+ images:
36
+ - 'src/odctl/resources/deps/**'
37
+ - 'src/odctl/resources/docker/**'
38
+ - '.github/workflows/build-deps.yml'
39
+ - '.github/workflows/build-platform-images.yml'
40
+
41
+ ci:
42
+ uses: ./.github/workflows/ci.yml
43
+
44
+ # Images are pushed only on a tag, so on any other ref this is a
45
+ # build-and-discard. Run it when the build inputs changed, so a broken
46
+ # Dockerfile surfaces before release instead of at tag time, and skip it
47
+ # otherwise. That removes two multi-arch builds from the typical change.
48
+ build-platform:
49
+ needs: [ci, changes]
50
+ if: >-
51
+ startsWith(github.ref, 'refs/tags/v') ||
52
+ github.event_name == 'workflow_dispatch' ||
53
+ needs.changes.outputs.images == 'true'
54
+ uses: ./.github/workflows/build-platform-images.yml
55
+
56
+ build-deps:
57
+ needs: [ci, changes]
58
+ if: >-
59
+ startsWith(github.ref, 'refs/tags/v') ||
60
+ github.event_name == 'workflow_dispatch' ||
61
+ needs.changes.outputs.images == 'true'
62
+ uses: ./.github/workflows/build-deps.yml
63
+
64
+ # Tags and manual runs only: the e2e job pulls images at the CLI's own version,
65
+ # and those exist on ghcr only once a tag has published them.
66
+ e2e:
67
+ needs: [build-platform, build-deps]
68
+ if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch'
69
+ uses: ./.github/workflows/e2e-test.yml
@@ -0,0 +1,90 @@
1
+ name: Publish to PyPI
2
+
3
+ # Publishing stays tied to publishing a release, so the release notes remain the
4
+ # trigger. What is new is the gate below: a release event fires independently of
5
+ # CI, which is how v0.3.0 reached PyPI while the pipeline's e2e job was failing.
6
+ # This job now waits for the tag's Main Pipeline run and refuses to publish
7
+ # unless it succeeded.
8
+ on:
9
+ release:
10
+ types: [published]
11
+ workflow_dispatch:
12
+ inputs:
13
+ tag:
14
+ description: "Tag to publish, for example v0.3.1"
15
+ required: true
16
+ skip_pipeline_check:
17
+ description: "Publish even if the tag's pipeline is not green"
18
+ type: boolean
19
+ default: false
20
+
21
+ concurrency:
22
+ group: ${{ github.workflow }}-${{ github.event.release.tag_name || inputs.tag }}
23
+ cancel-in-progress: false
24
+
25
+ jobs:
26
+ pypi-publish:
27
+ name: Build and publish Python package
28
+ runs-on: ubuntu-latest
29
+ # These permissions are strictly required for PyPI OIDC Trusted Publishing;
30
+ # actions:read is what lets the gate query the pipeline's conclusion.
31
+ permissions:
32
+ contents: read
33
+ id-token: write
34
+ actions: read
35
+
36
+ steps:
37
+ - name: Checkout source code
38
+ uses: actions/checkout@v7
39
+ with:
40
+ ref: ${{ github.event.release.tag_name || inputs.tag }}
41
+
42
+ - name: Require a green Main Pipeline for this tag
43
+ if: ${{ !inputs.skip_pipeline_check }}
44
+ env:
45
+ GH_TOKEN: ${{ github.token }}
46
+ TAG: ${{ github.event.release.tag_name || inputs.tag }}
47
+ run: |
48
+ set -euo pipefail
49
+ # Wait rather than fail outright: a release is usually published moments
50
+ # after the tag is pushed, so the pipeline is still running e2e.
51
+ deadline=$(( SECONDS + 45 * 60 ))
52
+ while :; do
53
+ run=$(gh api "repos/${GITHUB_REPOSITORY}/actions/runs?event=push&per_page=100" \
54
+ --jq "[.workflow_runs[]
55
+ | select(.name == \"Main Pipeline\" and .head_branch == \"${TAG}\")]
56
+ | sort_by(.created_at) | last // empty")
57
+ if [ -z "$run" ]; then
58
+ echo "::error::No Main Pipeline run found for tag ${TAG}. Push the tag first."
59
+ exit 1
60
+ fi
61
+ status=$(jq -r '.status' <<<"$run")
62
+ concl=$(jq -r '.conclusion' <<<"$run")
63
+ url=$(jq -r '.html_url' <<<"$run")
64
+ if [ "$status" = "completed" ]; then
65
+ if [ "$concl" = "success" ]; then
66
+ echo "✅ Main Pipeline for ${TAG} succeeded: $url"
67
+ exit 0
68
+ fi
69
+ echo "::error::Main Pipeline for ${TAG} concluded '${concl}', refusing to publish: $url"
70
+ exit 1
71
+ fi
72
+ if [ "$SECONDS" -ge "$deadline" ]; then
73
+ echo "::error::Main Pipeline for ${TAG} still '${status}' after 45 minutes: $url"
74
+ exit 1
75
+ fi
76
+ echo "⏳ Main Pipeline for ${TAG} is ${status}; waiting: $url"
77
+ sleep 30
78
+ done
79
+
80
+ - name: Install uv
81
+ uses: astral-sh/setup-uv@v7
82
+
83
+ - name: Set up Python
84
+ run: uv python install 3.10
85
+
86
+ - name: Build Wheel and sdist
87
+ run: uv build
88
+
89
+ - name: Publish package distributions to PyPI
90
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: odctl
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: Orchestrator for the Open Data Stack
5
5
  Project-URL: Homepage, https://github.com/jaehyeon-kim/odctl
6
6
  Project-URL: Repository, https://github.com/jaehyeon-kim/odctl
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "odctl"
3
- version = "0.3.0"
3
+ version = "0.4.0"
4
4
  description = "Orchestrator for the Open Data Stack"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -113,6 +113,18 @@ def get_stack_details(
113
113
  if isinstance(source, str):
114
114
  volumes.append(source)
115
115
 
116
+ # Drop volumes the file declares as external. Compose never removes
117
+ # those, so mounting one is not a reason to tear a profile down. Counting
118
+ # them made `odctl down --all --volumes` select `catalog` purely because
119
+ # it mounts the shared dependency volume, even when nothing of it ran.
120
+ declared = compose_dict.get("volumes") or {}
121
+ external = {
122
+ name
123
+ for name, spec in declared.items()
124
+ if isinstance(spec, dict) and spec.get("external")
125
+ }
126
+ volumes = [v for v in volumes if v not in external]
127
+
116
128
  return (
117
129
  sorted(list(set(services))),
118
130
  sorted(list(set(ports))),
@@ -175,8 +187,12 @@ def stop_stack(
175
187
  """
176
188
  path = get_compose_path(compose_filename)
177
189
  stack_client = _create_client(compose_files=[str(path)], profiles=profiles)
178
- # volumes=True will remove named volumes defined in the compose file
179
- stack_client.compose.down(volumes=remove_volumes)
190
+ # volumes=True will remove named volumes defined in the compose file.
191
+ # remove_orphans catches containers whose service no longer exists in the
192
+ # compose file, which teardown would otherwise leave running forever: an
193
+ # upgrade that drops or renames a service (ch-init-lite in 0.2.0, the
194
+ # flink1-* services in 0.3.0) leaves the old container holding its name.
195
+ stack_client.compose.down(volumes=remove_volumes, remove_orphans=True)
180
196
 
181
197
 
182
198
  def get_managed_containers(execution_plan: Dict[str, List[str]]) -> List[Any]:
@@ -15,7 +15,11 @@ from odctl.docker import (
15
15
  restart_managed_containers,
16
16
  stop_stack,
17
17
  )
18
- from odctl.planner import build_execution_plan, get_profile_map
18
+ from odctl.planner import (
19
+ build_execution_plan,
20
+ expand_same_file_dependencies,
21
+ get_profile_map,
22
+ )
19
23
  from odctl.registry import load_registry
20
24
  from odctl.workspace import get_workspace_dir, init_workspace
21
25
 
@@ -369,7 +373,7 @@ def down(
369
373
  ):
370
374
  active_profs.append(p)
371
375
  if active_profs:
372
- active_plan[file] = active_profs
376
+ active_plan[file] = expand_same_file_dependencies(profs, active_profs)
373
377
  plan = active_plan
374
378
 
375
379
  plan = dict(reversed(list(plan.items())))
@@ -131,3 +131,46 @@ def build_execution_plan(
131
131
  execution_plan[file].append(p)
132
132
 
133
133
  return execution_plan
134
+
135
+
136
+ def expand_same_file_dependencies(
137
+ file_profiles: List[str], active: List[str]
138
+ ) -> List[str]:
139
+ """Widen a set of active profiles to include their same-file dependencies.
140
+
141
+ Compose filters services by the active profiles and then validates
142
+ depends_on against what survives, so activating a profile whose service
143
+ depends on one in another profile of the same file gives "depends on
144
+ undefined service" and the command aborts. Teardown selects profiles by
145
+ running state and by volume ownership, which can pick one profile of a file
146
+ on its own, so it has to widen the selection back to a valid project.
147
+
148
+ Only same-file dependencies are added. Each file is torn down by its own
149
+ compose call, and widening across files would stop shared infrastructure
150
+ that other profiles may still be using.
151
+
152
+ Args:
153
+ file_profiles (List[str]): Every profile the compose file declares.
154
+ active (List[str]): The profiles selected for this file.
155
+
156
+ Returns:
157
+ List[str]: The selected profiles plus their same-file dependencies, in
158
+ the order the file declares them.
159
+ """
160
+ registry = load_registry()
161
+ profile_map = get_profile_map()
162
+
163
+ in_file = set(file_profiles)
164
+ selected = set(active)
165
+ queue = list(active)
166
+ while queue:
167
+ current = queue.pop(0)
168
+ entry = profile_map.get(current)
169
+ if not entry:
170
+ continue
171
+ stack = registry.stacks[entry["stack_id"]]
172
+ for dep in stack.depends_on.get(current, []):
173
+ if dep in in_file and dep not in selected:
174
+ selected.add(dep)
175
+ queue.append(dep)
176
+ return [p for p in file_profiles if p in selected]
@@ -25,19 +25,36 @@
25
25
  </node>
26
26
  </zookeeper>
27
27
 
28
+ <!-- Every replica carries credentials because the default user has a
29
+ password. Without them a server-to-server connection authenticates as
30
+ default with an empty password and fails with AUTHENTICATION_FAILED,
31
+ which any distributed or parallel-replica read would hit as soon as it
32
+ has to leave the local node. -->
28
33
  <remote_servers>
29
34
  <odctl_cluster>
30
35
  <shard>
31
36
  <internal_replication>true</internal_replication>
32
- <replica><host>ch-11</host><port>9000</port></replica>
33
- <replica><host>ch-12</host><port>9000</port></replica>
37
+ <replica><host>ch-11</host><port>9000</port><user>default</user><password>password</password></replica>
38
+ <replica><host>ch-12</host><port>9000</port><user>default</user><password>password</password></replica>
34
39
  </shard>
35
40
  <shard>
36
41
  <internal_replication>true</internal_replication>
37
- <replica><host>ch-21</host><port>9000</port></replica>
38
- <replica><host>ch-22</host><port>9000</port></replica>
42
+ <replica><host>ch-21</host><port>9000</port><user>default</user><password>password</password></replica>
43
+ <replica><host>ch-22</host><port>9000</port><user>default</user><password>password</password></replica>
39
44
  </shard>
40
45
  </odctl_cluster>
46
+
47
+ <!-- Parallel replicas require a cluster of exactly one shard, so they
48
+ cannot use odctl_cluster on any profile. This entry covers the
49
+ single shard that ch-lite runs, and is what
50
+ cluster_for_parallel_replicas should name. -->
51
+ <odctl_single_shard>
52
+ <shard>
53
+ <internal_replication>true</internal_replication>
54
+ <replica><host>ch-11</host><port>9000</port><user>default</user><password>password</password></replica>
55
+ <replica><host>ch-12</host><port>9000</port><user>default</user><password>password</password></replica>
56
+ </shard>
57
+ </odctl_single_shard>
41
58
  </remote_servers>
42
59
 
43
60
  <distributed_ddl>
@@ -145,6 +145,13 @@ services:
145
145
  volumes:
146
146
  # Mount the static catalogs
147
147
  - ./trino/catalog:/etc/trino/catalog:ro
148
+ # File-based access control, which is what enables column masks and row
149
+ # filters. No authenticator is configured, so Trino trusts the identity a
150
+ # client claims and `trino --user analyst` is enough to exercise the rules.
151
+ - ./trino/access-control.properties:/etc/trino/access-control.properties:ro
152
+ - ./trino/rules.json:/etc/trino/rules.json:ro
153
+ - ./trino/group-provider.properties:/etc/trino/group-provider.properties:ro
154
+ - ./trino/groups.txt:/etc/trino/groups.txt:ro
148
155
  healthcheck:
149
156
  test: ["CMD", "curl", "-f", "http://localhost:8080/v1/info"]
150
157
  interval: 10s