odctl 0.4.2__tar.gz → 0.4.3__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.
- {odctl-0.4.2 → odctl-0.4.3}/.github/renovate.json +12 -6
- {odctl-0.4.2 → odctl-0.4.3}/.github/workflows/e2e-test.yml +11 -1
- {odctl-0.4.2 → odctl-0.4.3}/PKG-INFO +1 -1
- {odctl-0.4.2 → odctl-0.4.3}/pyproject.toml +1 -1
- {odctl-0.4.2 → odctl-0.4.3}/src/odctl/main.py +15 -6
- {odctl-0.4.2 → odctl-0.4.3}/src/odctl/planner.py +27 -0
- {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/compose-analytics.yml +2 -2
- {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/compose-infra.yml +1 -1
- {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/compose-kafka.yml +1 -1
- {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/compose-metadata.yml +3 -3
- {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/deps/Dockerfile +1 -1
- {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/deps/build.gradle +1 -1
- {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/deps/versions.env +4 -2
- {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/docker/airflow/Dockerfile +4 -2
- {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/docker/ray-mlops/Dockerfile +1 -1
- {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/docker/spark/Dockerfile +7 -0
- odctl-0.4.3/tests/test_compose_profiles.py +253 -0
- odctl-0.4.2/tests/test_compose_profiles.py +0 -126
- {odctl-0.4.2 → odctl-0.4.3}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {odctl-0.4.2 → odctl-0.4.3}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {odctl-0.4.2 → odctl-0.4.3}/.github/scripts/smoke.sh +0 -0
- {odctl-0.4.2 → odctl-0.4.3}/.github/workflows/build-deps.yml +0 -0
- {odctl-0.4.2 → odctl-0.4.3}/.github/workflows/build-platform-images.yml +0 -0
- {odctl-0.4.2 → odctl-0.4.3}/.github/workflows/ci.yml +0 -0
- {odctl-0.4.2 → odctl-0.4.3}/.github/workflows/pipeline.yml +0 -0
- {odctl-0.4.2 → odctl-0.4.3}/.github/workflows/publish.yml +0 -0
- {odctl-0.4.2 → odctl-0.4.3}/.gitignore +0 -0
- {odctl-0.4.2 → odctl-0.4.3}/.pre-commit-config.yaml +0 -0
- {odctl-0.4.2 → odctl-0.4.3}/.vscode/settings.json +0 -0
- {odctl-0.4.2 → odctl-0.4.3}/LICENSE +0 -0
- {odctl-0.4.2 → odctl-0.4.3}/README.md +0 -0
- {odctl-0.4.2 → odctl-0.4.3}/image/diagram.png +0 -0
- {odctl-0.4.2 → odctl-0.4.3}/image/diagram1.drawio +0 -0
- {odctl-0.4.2 → odctl-0.4.3}/image/diagram2.drawio +0 -0
- {odctl-0.4.2 → odctl-0.4.3}/src/odctl/__init__.py +0 -0
- {odctl-0.4.2 → odctl-0.4.3}/src/odctl/config.py +0 -0
- {odctl-0.4.2 → odctl-0.4.3}/src/odctl/docker.py +0 -0
- {odctl-0.4.2 → odctl-0.4.3}/src/odctl/registry.py +0 -0
- {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/clickhouse/config.xml +0 -0
- {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/clickhouse/init.sql +0 -0
- {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/clickhouse/keeper_config.xml +0 -0
- {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/clickhouse/macros-11.xml +0 -0
- {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/clickhouse/macros-12.xml +0 -0
- {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/clickhouse/macros-21.xml +0 -0
- {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/clickhouse/macros-22.xml +0 -0
- {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/clickhouse/users.xml +0 -0
- {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/compose-deps.yml +0 -0
- {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/compose-flink.yml +0 -0
- {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/compose-mlops.yml +0 -0
- {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/compose-obsv.yml +0 -0
- {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/compose-orch.yml +0 -0
- {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/compose-spark.yml +0 -0
- {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/compose-store.yml +0 -0
- {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/deps/build_connectors.sh +0 -0
- {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/deps/fetch_flink_dependencies.sh +0 -0
- {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/deps/fetch_spark_dependencies.sh +0 -0
- {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/deps/fetch_standalone_plugins.sh +0 -0
- {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/flink/flink-conf.yml +0 -0
- {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/grafana/prometheus.yml +0 -0
- {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/marquez/marquez.yml +0 -0
- {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/mlops/serving/lightgbm_inference.py.example +0 -0
- {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/mlops/serving/vector_search.py.example +0 -0
- {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/postgres/01-init-databases.sh +0 -0
- {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/prometheus/alertmanager.yml +0 -0
- {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/prometheus/prometheus.yml +0 -0
- {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/registry.yml +0 -0
- {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/seaweed/s3.json +0 -0
- {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/spark/log4j2.properties +0 -0
- {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/spark/spark-defaults.conf +0 -0
- {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/trino/access-control.properties +0 -0
- {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/trino/catalog/clickhouse.properties +0 -0
- {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/trino/catalog/iceberg.properties +0 -0
- {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/trino/catalog/kafka.properties +0 -0
- {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/trino/catalog/postgres.properties +0 -0
- {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/trino/catalog/valkey.properties +0 -0
- {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/trino/group-provider.properties +0 -0
- {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/trino/groups.txt +0 -0
- {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/trino/rules.json +0 -0
- {odctl-0.4.2 → odctl-0.4.3}/src/odctl/ui.py +0 -0
- {odctl-0.4.2 → odctl-0.4.3}/src/odctl/workspace.py +0 -0
- {odctl-0.4.2 → odctl-0.4.3}/tests/conftest.py +0 -0
- {odctl-0.4.2 → odctl-0.4.3}/tests/test_cli.py +0 -0
- {odctl-0.4.2 → odctl-0.4.3}/tests/test_cli_extended.py +0 -0
- {odctl-0.4.2 → odctl-0.4.3}/tests/test_config.py +0 -0
- {odctl-0.4.2 → odctl-0.4.3}/tests/test_docker.py +0 -0
- {odctl-0.4.2 → odctl-0.4.3}/tests/test_planner.py +0 -0
- {odctl-0.4.2 → odctl-0.4.3}/tests/test_registry.py +0 -0
- {odctl-0.4.2 → odctl-0.4.3}/tests/test_workspace.py +0 -0
- {odctl-0.4.2 → odctl-0.4.3}/uv.lock +0 -0
|
@@ -2,13 +2,16 @@
|
|
|
2
2
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
|
3
3
|
"extends": ["config:base", ":dependencyDashboard", "docker:enableMajor"],
|
|
4
4
|
"schedule": ["every weekend"],
|
|
5
|
+
|
|
6
|
+
"automerge": false,
|
|
7
|
+
|
|
5
8
|
"docker-compose": {
|
|
6
9
|
"fileMatch": ["(^|/)compose-[^/]+\\.ya?ml$"]
|
|
7
10
|
},
|
|
8
11
|
"dockerfile": {
|
|
9
12
|
"fileMatch": [
|
|
10
|
-
"^src/
|
|
11
|
-
"^src/
|
|
13
|
+
"^src/odctl/resources/docker/.*/Dockerfile$",
|
|
14
|
+
"^src/odctl/resources/deps/Dockerfile$"
|
|
12
15
|
]
|
|
13
16
|
},
|
|
14
17
|
"packageRules": [
|
|
@@ -23,10 +26,13 @@
|
|
|
23
26
|
"allowedVersions": "<0.9"
|
|
24
27
|
},
|
|
25
28
|
{
|
|
26
|
-
"description": "
|
|
27
|
-
"
|
|
28
|
-
|
|
29
|
-
|
|
29
|
+
"description": "Spark stays on 4.1: Iceberg publishes iceberg-spark-runtime for 4.0 and 4.1 only, so a 4.2 image loads a 4.1 runtime and every Iceberg statement dies with IncompatibleClassChangeError. HADOOP_AWS_V in versions.env is matched to the Hadoop this series bundles, and the airflow image pins pyspark to the same minor. Raise this only when Iceberg ships a runtime for a newer minor, and move SPARK_COMPAT_MINOR, HADOOP_AWS_V and the pyspark pin with it. See PR #29.",
|
|
30
|
+
"matchPackageNames": [
|
|
31
|
+
"apache/spark",
|
|
32
|
+
"pyspark",
|
|
33
|
+
"org.apache.spark:{/,}**"
|
|
34
|
+
],
|
|
35
|
+
"allowedVersions": "<4.2"
|
|
30
36
|
},
|
|
31
37
|
{
|
|
32
38
|
"description": "Group all Python dependencies from pyproject.toml into a single PR",
|
|
@@ -83,7 +83,17 @@ jobs:
|
|
|
83
83
|
exit 1
|
|
84
84
|
fi
|
|
85
85
|
|
|
86
|
-
|
|
86
|
+
# Tear down by name before sweeping with --all. These are two code
|
|
87
|
+
# paths: --all resolves dependency profiles for itself, while a
|
|
88
|
+
# named teardown did not until 0.4.3, so compose rejected the
|
|
89
|
+
# project with "depends on undefined service" and only the named
|
|
90
|
+
# form could catch it. Naming the profile is also what a user runs.
|
|
91
|
+
echo "🧹 Tearing down $profile by name..."
|
|
92
|
+
yes | odctl down "$profile" --volumes
|
|
93
|
+
|
|
94
|
+
# Sweep afterwards so a failure above cannot leak state into the
|
|
95
|
+
# next profile in the group. This is a no-op when the named
|
|
96
|
+
# teardown succeeded.
|
|
87
97
|
yes | odctl down --all --volumes
|
|
88
98
|
echo "::endgroup::"
|
|
89
99
|
done
|
|
@@ -17,7 +17,7 @@ from odctl.docker import (
|
|
|
17
17
|
)
|
|
18
18
|
from odctl.planner import (
|
|
19
19
|
build_execution_plan,
|
|
20
|
-
|
|
20
|
+
expand_plan_dependencies,
|
|
21
21
|
get_profile_map,
|
|
22
22
|
)
|
|
23
23
|
from odctl.registry import load_registry
|
|
@@ -373,9 +373,15 @@ def down(
|
|
|
373
373
|
):
|
|
374
374
|
active_profs.append(p)
|
|
375
375
|
if active_profs:
|
|
376
|
-
active_plan[file] =
|
|
376
|
+
active_plan[file] = active_profs
|
|
377
377
|
plan = active_plan
|
|
378
378
|
|
|
379
|
+
# Widen after the running-state filter, not inside it, so a teardown that
|
|
380
|
+
# names profiles directly is validated the same way `--all` is. Without
|
|
381
|
+
# this, `odctl down catalog` hands compose a project where catalog's
|
|
382
|
+
# depends_on points at a postgres that the profile filter removed.
|
|
383
|
+
plan = expand_plan_dependencies(plan)
|
|
384
|
+
|
|
379
385
|
plan = dict(reversed(list(plan.items())))
|
|
380
386
|
|
|
381
387
|
if dry_run:
|
|
@@ -420,8 +426,11 @@ def ps(
|
|
|
420
426
|
ui.print_error("Docker is not reachable.")
|
|
421
427
|
raise typer.Exit(1)
|
|
422
428
|
|
|
423
|
-
# Use resolve_deps=False so we ONLY see what was explicitly requested
|
|
424
|
-
|
|
429
|
+
# Use resolve_deps=False so we ONLY see what was explicitly requested,
|
|
430
|
+
# widened just enough that compose accepts the project.
|
|
431
|
+
plan = expand_plan_dependencies(
|
|
432
|
+
build_execution_plan(profiles, all, resolve_deps=False)
|
|
433
|
+
)
|
|
425
434
|
|
|
426
435
|
containers = get_managed_containers(plan)
|
|
427
436
|
|
|
@@ -504,7 +513,7 @@ def logs(
|
|
|
504
513
|
ui.print_error("Docker is not reachable.")
|
|
505
514
|
raise typer.Exit(1)
|
|
506
515
|
|
|
507
|
-
plan = build_execution_plan(profiles, resolve_deps=False)
|
|
516
|
+
plan = expand_plan_dependencies(build_execution_plan(profiles, resolve_deps=False))
|
|
508
517
|
get_managed_logs(
|
|
509
518
|
execution_plan=plan,
|
|
510
519
|
follow=follow,
|
|
@@ -523,7 +532,7 @@ def restart(profiles: List[str] = typer.Argument(..., help="Profiles to restart.
|
|
|
523
532
|
ui.print_error("Docker is not reachable.")
|
|
524
533
|
raise typer.Exit(1)
|
|
525
534
|
|
|
526
|
-
plan = build_execution_plan(profiles, resolve_deps=False)
|
|
535
|
+
plan = expand_plan_dependencies(build_execution_plan(profiles, resolve_deps=False))
|
|
527
536
|
ui.print_step("Restarting containers...")
|
|
528
537
|
restart_managed_containers(plan)
|
|
529
538
|
ui.print_success("Restart complete.")
|
|
@@ -174,3 +174,30 @@ def expand_same_file_dependencies(
|
|
|
174
174
|
selected.add(dep)
|
|
175
175
|
queue.append(dep)
|
|
176
176
|
return [p for p in file_profiles if p in selected]
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def expand_plan_dependencies(plan: Dict[str, List[str]]) -> Dict[str, List[str]]:
|
|
180
|
+
"""Widen every file in a plan to include its same-file dependencies.
|
|
181
|
+
|
|
182
|
+
Any command that hands profiles to a compose client has to pass a set that
|
|
183
|
+
validates, because compose filters services by active profile and then
|
|
184
|
+
checks depends_on against what survives. Naming a profile whose service
|
|
185
|
+
depends on one in another profile of the same file therefore aborts with
|
|
186
|
+
"depends on undefined service", and that applies to teardown, ps, logs and
|
|
187
|
+
restart alike, not just to teardown of a whole file.
|
|
188
|
+
|
|
189
|
+
Plans built with ``resolve_deps=False`` need this. Plans built with
|
|
190
|
+
``resolve_deps=True`` already carry their dependencies.
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
plan (Dict[str, List[str]]): Compose file to selected profiles.
|
|
194
|
+
|
|
195
|
+
Returns:
|
|
196
|
+
Dict[str, List[str]]: Same mapping, each selection widened to a set
|
|
197
|
+
compose will accept, in the order each file declares.
|
|
198
|
+
"""
|
|
199
|
+
declared = build_execution_plan(None, all_profiles=True, resolve_deps=False)
|
|
200
|
+
return {
|
|
201
|
+
file: expand_same_file_dependencies(declared.get(file, profiles), profiles)
|
|
202
|
+
for file, profiles in plan.items()
|
|
203
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
name: odctl-analytics
|
|
2
2
|
|
|
3
3
|
x-clickhouse-base: &clickhouse-base
|
|
4
|
-
image: clickhouse/clickhouse-server:25.
|
|
4
|
+
image: clickhouse/clickhouse-server:25.12-alpine
|
|
5
5
|
networks:
|
|
6
6
|
- odctl
|
|
7
7
|
mem_limit: 4g
|
|
@@ -32,7 +32,7 @@ services:
|
|
|
32
32
|
# [SHARED] Consensus Node
|
|
33
33
|
# ==========================================
|
|
34
34
|
ch-keeper:
|
|
35
|
-
image: clickhouse/clickhouse-keeper:25.
|
|
35
|
+
image: clickhouse/clickhouse-keeper:25.12-alpine
|
|
36
36
|
container_name: ch-keeper
|
|
37
37
|
# Keeper must run in BOTH profiles
|
|
38
38
|
profiles:
|
|
@@ -53,7 +53,7 @@ services:
|
|
|
53
53
|
# Object Storage (SeaweedFS - Single Node)
|
|
54
54
|
# ==========================================
|
|
55
55
|
seaweed:
|
|
56
|
-
image: &seaweed-image chrislusf/seaweedfs:4.
|
|
56
|
+
image: &seaweed-image chrislusf/seaweedfs:4.40
|
|
57
57
|
container_name: seaweed
|
|
58
58
|
ports:
|
|
59
59
|
- "8333:8333" # S3 API
|
|
@@ -5,7 +5,7 @@ services:
|
|
|
5
5
|
# [PROFILE: metadata] Elasticsearch (OM Backend)
|
|
6
6
|
# ==========================================
|
|
7
7
|
openmetadata-es:
|
|
8
|
-
image: docker.elastic.co/elasticsearch/elasticsearch:8.
|
|
8
|
+
image: docker.elastic.co/elasticsearch/elasticsearch:8.19.19
|
|
9
9
|
container_name: openmetadata-es
|
|
10
10
|
profiles:
|
|
11
11
|
- metadata
|
|
@@ -33,7 +33,7 @@ services:
|
|
|
33
33
|
# [PROFILE: metadata] OpenMetadata DB Migration
|
|
34
34
|
# ==========================================
|
|
35
35
|
openmetadata-migrate:
|
|
36
|
-
image: &om-server-image docker.getcollate.io/openmetadata/server:1.
|
|
36
|
+
image: &om-server-image docker.getcollate.io/openmetadata/server:1.13.3
|
|
37
37
|
container_name: openmetadata-migrate
|
|
38
38
|
command: "./bootstrap/openmetadata-ops.sh migrate"
|
|
39
39
|
profiles:
|
|
@@ -126,7 +126,7 @@ services:
|
|
|
126
126
|
# [PROFILE: metadata] OpenMetadata Ingestion (Standard Image)
|
|
127
127
|
# ==========================================
|
|
128
128
|
openmetadata-ingestion:
|
|
129
|
-
image: docker.getcollate.io/openmetadata/ingestion:1.
|
|
129
|
+
image: docker.getcollate.io/openmetadata/ingestion:1.13.3
|
|
130
130
|
container_name: openmetadata-ingestion
|
|
131
131
|
profiles:
|
|
132
132
|
- metadata
|
|
@@ -13,7 +13,7 @@ RUN --mount=type=cache,target=/root/.gradle \
|
|
|
13
13
|
# ==========================================
|
|
14
14
|
# Stage 2: Gradle Resolver (Schema Converters & Flink Parquet)
|
|
15
15
|
# ==========================================
|
|
16
|
-
FROM gradle:8.
|
|
16
|
+
FROM gradle:8.14-jdk17 AS gradle-resolver
|
|
17
17
|
WORKDIR /app
|
|
18
18
|
COPY build.gradle versions.env ./
|
|
19
19
|
# Let Gradle do the heavy transitive lifting for both Connect and Flink, using the
|
|
@@ -10,7 +10,7 @@ repositories {
|
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
ext {
|
|
13
|
-
confluentVersion = '8.
|
|
13
|
+
confluentVersion = '8.3.1'
|
|
14
14
|
// Supplied by the Dockerfile from versions.env. The '+' resolves the latest
|
|
15
15
|
// patch of the pinned minor, which is what the fetch scripts do too.
|
|
16
16
|
flinkVersion = "${project.findProperty('flinkMinor') ?: '2.1'}.+"
|
|
@@ -21,8 +21,10 @@ HADOOP_V=3.3.6
|
|
|
21
21
|
# Hadoop S3A for Spark. Iceberg's own reads and writes go through S3FileIO, but
|
|
22
22
|
# the remove_orphan_files and add_files procedures list and stage through
|
|
23
23
|
# Hadoop's FileSystem API, so they need hadoop-aws on the Spark classpath.
|
|
24
|
-
# Match apache/spark
|
|
25
|
-
# the
|
|
24
|
+
# Match the Hadoop that the pinned apache/spark image bundles, currently 3.4.2
|
|
25
|
+
# for the 4.1 series, and pin the AWS SDK to the version hadoop-aws declares
|
|
26
|
+
# rather than the newest published one. Spark 4.2.0 bundles Hadoop 3.5.0, so
|
|
27
|
+
# this value moves only when that image's minor does.
|
|
26
28
|
#
|
|
27
29
|
# The shaded `bundle` artifact is deliberate, despite being about 640 MB. Two
|
|
28
30
|
# slimmer attempts both failed: resolving hadoop-aws with `bundle` excluded plus
|
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
FROM apache/airflow:3.
|
|
1
|
+
FROM apache/airflow:3.3.0
|
|
2
2
|
|
|
3
3
|
# Metadata Bindings for GHCR
|
|
4
4
|
LABEL org.opencontainers.image.source="https://github.com/jaehyeon-kim/odctl"
|
|
5
5
|
LABEL org.opencontainers.image.description="Custom Apache Airflow orchestrator image for the Open Data Stack. Fully loaded with pipeline execution providers for PostgreSQL, Trino, ClickHouse, Amazon S3, Flink, PySpark, and PyIceberg."
|
|
6
6
|
|
|
7
7
|
# Install Lean Open Data SDKs and Providers
|
|
8
|
-
#
|
|
8
|
+
# Pinned to the same Spark minor the spark image runs, because Iceberg
|
|
9
|
+
# publishes iceberg-spark-runtime per minor. Move it with that image, not on its
|
|
10
|
+
# own. See the renovate ceiling on apache/spark.
|
|
9
11
|
RUN --mount=type=cache,target=/home/airflow/.cache/pip \
|
|
10
12
|
pip install \
|
|
11
13
|
apache-airflow-providers-apache-flink \
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
FROM rayproject/ray:2.
|
|
1
|
+
FROM rayproject/ray:2.56.1-py312-cpu
|
|
2
2
|
|
|
3
3
|
LABEL org.opencontainers.image.source="https://github.com/jaehyeon-kim/odctl"
|
|
4
4
|
LABEL org.opencontainers.image.description="Unified Ray Serve and MLflow image for the Open Data Stack. Handles inference deployment, experiment tracking, and direct ClickHouse/Kafka connections."
|
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
# Pinned to the 4.1 series on purpose. Iceberg 1.11.0 publishes
|
|
2
|
+
# iceberg-spark-runtime for 4.0 and 4.1 only, so a 4.2 image loads a runtime
|
|
3
|
+
# built against 4.1 and every Iceberg statement dies with
|
|
4
|
+
# IncompatibleClassChangeError on connector.catalog.View. HADOOP_AWS_V in
|
|
5
|
+
# versions.env is also matched to the Hadoop this series bundles, and the
|
|
6
|
+
# airflow image pins pyspark to the same minor. Raise all four together, and
|
|
7
|
+
# only once Iceberg ships a runtime for the newer minor. See PR #29.
|
|
1
8
|
FROM apache/spark:4.1.2
|
|
2
9
|
|
|
3
10
|
# Switch to root to install system-wide packages
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""Static checks on profile wiring in the packaged compose files.
|
|
2
|
+
|
|
3
|
+
These exist because of a real failure. `odctl down --all --volumes` selected the
|
|
4
|
+
`catalog` profile on its own, since teardown picks profiles by running state and
|
|
5
|
+
by volume ownership. That service declares a `depends_on` on services gated
|
|
6
|
+
behind the `postgres` and `storage` profiles, so compose rejected the file with
|
|
7
|
+
"depends on undefined service" before contacting Docker, and teardown aborted for
|
|
8
|
+
every profile group.
|
|
9
|
+
|
|
10
|
+
Nothing here needs Docker, so it belongs in the unit suite rather than in the
|
|
11
|
+
end-to-end tests, which run only on tags and so cannot fail a pull request.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from unittest import mock
|
|
15
|
+
|
|
16
|
+
import pytest
|
|
17
|
+
import yaml
|
|
18
|
+
from typer.testing import CliRunner
|
|
19
|
+
|
|
20
|
+
from odctl import config
|
|
21
|
+
from odctl.main import app
|
|
22
|
+
from odctl.config import get_internal_resources_dir
|
|
23
|
+
from odctl.planner import (
|
|
24
|
+
build_execution_plan,
|
|
25
|
+
expand_plan_dependencies,
|
|
26
|
+
expand_same_file_dependencies,
|
|
27
|
+
)
|
|
28
|
+
from odctl.registry import load_registry
|
|
29
|
+
|
|
30
|
+
runner = CliRunner()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@pytest.fixture(autouse=True)
|
|
34
|
+
def _read_packaged_resources(monkeypatch):
|
|
35
|
+
"""Read the registry from the packaged resources, not a local workspace.
|
|
36
|
+
|
|
37
|
+
Without this the compose files come from src/odctl/resources while the
|
|
38
|
+
registry comes from ./.odctl, so an inconsistency between the two is
|
|
39
|
+
invisible to these tests.
|
|
40
|
+
"""
|
|
41
|
+
monkeypatch.setattr(config, "get_active_dir", get_internal_resources_dir)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _compose_files():
|
|
45
|
+
return sorted(get_internal_resources_dir().glob("compose-*.yml"))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _services(path):
|
|
49
|
+
return (yaml.safe_load(path.read_text()) or {}).get("services") or {}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _depends_on(service):
|
|
53
|
+
dep = service.get("depends_on")
|
|
54
|
+
if isinstance(dep, dict):
|
|
55
|
+
return list(dep.keys())
|
|
56
|
+
return list(dep or [])
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _profiles_of_file(path):
|
|
60
|
+
seen = []
|
|
61
|
+
for svc in _services(path).values():
|
|
62
|
+
for profile in svc.get("profiles") or []:
|
|
63
|
+
if profile not in seen:
|
|
64
|
+
seen.append(profile)
|
|
65
|
+
return seen
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_every_teardown_selection_yields_a_valid_project():
|
|
69
|
+
"""Selecting any single profile, then widening it the way teardown does,
|
|
70
|
+
must leave every depends_on target inside the active set.
|
|
71
|
+
|
|
72
|
+
This is the property that actually matters: teardown may select one profile
|
|
73
|
+
of a file, so after expansion the resulting project has to be well-formed.
|
|
74
|
+
"""
|
|
75
|
+
violations = []
|
|
76
|
+
for path in _compose_files():
|
|
77
|
+
services = _services(path)
|
|
78
|
+
for profile in _profiles_of_file(path):
|
|
79
|
+
active = set(
|
|
80
|
+
expand_same_file_dependencies(_profiles_of_file(path), [profile])
|
|
81
|
+
)
|
|
82
|
+
live = {
|
|
83
|
+
name
|
|
84
|
+
for name, svc in services.items()
|
|
85
|
+
if not svc.get("profiles") or (set(svc["profiles"]) & active)
|
|
86
|
+
}
|
|
87
|
+
for name in sorted(live):
|
|
88
|
+
for target in _depends_on(services[name]):
|
|
89
|
+
if target not in live:
|
|
90
|
+
violations.append(
|
|
91
|
+
f"{path.name}: selecting {profile} activates {name}, "
|
|
92
|
+
f"which depends on {target}, absent from the project"
|
|
93
|
+
)
|
|
94
|
+
assert not violations, (
|
|
95
|
+
"a teardown selection would produce an invalid compose project:\n"
|
|
96
|
+
+ "\n".join(violations)
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_expansion_pulls_in_same_file_dependencies():
|
|
101
|
+
"""Selecting `catalog` must widen to `postgres` and `storage`.
|
|
102
|
+
|
|
103
|
+
This is the concrete case that broke: `catalog` owns no volume of its own but
|
|
104
|
+
mounts the shared dependency volume, so `--volumes` selects it even when
|
|
105
|
+
nothing of it is running.
|
|
106
|
+
"""
|
|
107
|
+
expanded = expand_same_file_dependencies(
|
|
108
|
+
["postgres", "storage", "catalog"], ["catalog"]
|
|
109
|
+
)
|
|
110
|
+
assert expanded == ["postgres", "storage", "catalog"]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_expansion_stays_within_the_file():
|
|
114
|
+
"""Dependencies declared outside the file must not be added.
|
|
115
|
+
|
|
116
|
+
Each file is torn down by its own compose call, so widening across files
|
|
117
|
+
would stop shared infrastructure other profiles may still be using.
|
|
118
|
+
"""
|
|
119
|
+
registry = load_registry()
|
|
120
|
+
catalog_deps = None
|
|
121
|
+
for stack in registry.stacks.values():
|
|
122
|
+
if "catalog" in (stack.depends_on or {}):
|
|
123
|
+
catalog_deps = stack.depends_on["catalog"]
|
|
124
|
+
assert catalog_deps and "deps" in catalog_deps, "expected catalog to depend on deps"
|
|
125
|
+
|
|
126
|
+
expanded = expand_same_file_dependencies(
|
|
127
|
+
["postgres", "storage", "catalog"], ["catalog"]
|
|
128
|
+
)
|
|
129
|
+
assert "deps" not in expanded
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def test_expansion_is_a_noop_without_dependencies():
|
|
133
|
+
"""A profile with no same-file dependencies is returned unchanged."""
|
|
134
|
+
assert expand_same_file_dependencies(
|
|
135
|
+
["kafka-lite", "kafka-full"], ["kafka-lite"]
|
|
136
|
+
) == ["kafka-lite"]
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def test_named_profile_plan_is_widened():
|
|
140
|
+
"""Naming profiles directly must widen the same way `--all` does.
|
|
141
|
+
|
|
142
|
+
This is the regression the tests above could not catch. They exercise the
|
|
143
|
+
helper, while the bug was that `down` only called it inside its `--all`
|
|
144
|
+
branch, so `odctl down storage catalog trino` handed compose a project where
|
|
145
|
+
catalog depended on a postgres the profile filter had removed, and every
|
|
146
|
+
teardown of a named selection aborted.
|
|
147
|
+
"""
|
|
148
|
+
plan = build_execution_plan(
|
|
149
|
+
["storage", "catalog", "trino"], False, resolve_deps=False
|
|
150
|
+
)
|
|
151
|
+
assert plan["compose-infra.yml"] == ["storage", "catalog"], (
|
|
152
|
+
"precondition changed: the raw plan is expected to omit postgres"
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
widened = expand_plan_dependencies(plan)
|
|
156
|
+
assert widened["compose-infra.yml"] == ["postgres", "storage", "catalog"]
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def test_plan_widening_does_not_cross_files():
|
|
160
|
+
"""Widening one file must not add profiles to another.
|
|
161
|
+
|
|
162
|
+
Each file gets its own compose call, so pulling a dependency across files
|
|
163
|
+
would stop infrastructure that other profiles are still using.
|
|
164
|
+
"""
|
|
165
|
+
plan = build_execution_plan(["catalog", "trino"], False, resolve_deps=False)
|
|
166
|
+
widened = expand_plan_dependencies(plan)
|
|
167
|
+
|
|
168
|
+
assert widened["compose-analytics.yml"] == plan["compose-analytics.yml"]
|
|
169
|
+
assert "postgres" not in widened["compose-analytics.yml"]
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def test_plan_widening_is_idempotent():
|
|
173
|
+
"""Widening an already-valid plan must change nothing.
|
|
174
|
+
|
|
175
|
+
`down --all` filters by running state and then widens, so the same plan can
|
|
176
|
+
pass through expansion twice. That has to be safe.
|
|
177
|
+
"""
|
|
178
|
+
plan = build_execution_plan(["catalog"], False, resolve_deps=False)
|
|
179
|
+
once = expand_plan_dependencies(plan)
|
|
180
|
+
assert expand_plan_dependencies(once) == once
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def test_down_widens_a_named_profile():
|
|
184
|
+
"""`odctl down catalog` must plan to stop postgres as well.
|
|
185
|
+
|
|
186
|
+
This is the only test here that exercises the command rather than the
|
|
187
|
+
planner, and it is the one that fails without the fix. Every other test in
|
|
188
|
+
this file passes while `down` is broken, because they call the helper
|
|
189
|
+
directly and the bug was that `down` reached it only on its `--all` branch.
|
|
190
|
+
`--dry-run` returns before the Docker check, so this needs no daemon.
|
|
191
|
+
"""
|
|
192
|
+
result = runner.invoke(app, ["down", "catalog", "--dry-run"])
|
|
193
|
+
|
|
194
|
+
assert result.exit_code == 0, result.stdout
|
|
195
|
+
assert "compose-infra.yml" in result.stdout
|
|
196
|
+
assert "postgres" in result.stdout, (
|
|
197
|
+
"catalog depends on postgres, so a teardown that omits it hands compose "
|
|
198
|
+
"an invalid project:\n" + result.stdout
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def test_restart_widens_a_named_profile():
|
|
203
|
+
"""`odctl restart catalog` must resolve the same widened plan.
|
|
204
|
+
|
|
205
|
+
`down`, `ps`, `logs` and `restart` all build their plan with
|
|
206
|
+
`resolve_deps=False` and all pass profiles to a compose client, so all four
|
|
207
|
+
need the widening. This pins the one with no dry-run of its own.
|
|
208
|
+
"""
|
|
209
|
+
captured = {}
|
|
210
|
+
with (
|
|
211
|
+
mock.patch("odctl.main.is_docker_running", return_value=True),
|
|
212
|
+
mock.patch(
|
|
213
|
+
"odctl.main.restart_managed_containers",
|
|
214
|
+
side_effect=lambda plan: captured.update(plan),
|
|
215
|
+
),
|
|
216
|
+
):
|
|
217
|
+
result = runner.invoke(app, ["restart", "catalog"])
|
|
218
|
+
|
|
219
|
+
assert result.exit_code == 0, result.stdout
|
|
220
|
+
assert "postgres" in captured.get("compose-infra.yml", []), (
|
|
221
|
+
f"expected postgres in the restart plan, got {captured}"
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def test_every_named_selection_yields_a_valid_project():
|
|
226
|
+
"""Every single-profile plan must survive widening as a valid project.
|
|
227
|
+
|
|
228
|
+
Same property as the teardown test above, asserted one level up through the
|
|
229
|
+
plan so it covers the code path the commands actually take rather than the
|
|
230
|
+
helper in isolation.
|
|
231
|
+
"""
|
|
232
|
+
violations = []
|
|
233
|
+
for path in _compose_files():
|
|
234
|
+
services = _services(path)
|
|
235
|
+
for profile in _profiles_of_file(path):
|
|
236
|
+
plan = build_execution_plan([profile], False, resolve_deps=False)
|
|
237
|
+
active = set(expand_plan_dependencies(plan).get(path.name, []))
|
|
238
|
+
live = {
|
|
239
|
+
name
|
|
240
|
+
for name, svc in services.items()
|
|
241
|
+
if not svc.get("profiles") or (set(svc["profiles"]) & active)
|
|
242
|
+
}
|
|
243
|
+
for name in sorted(live):
|
|
244
|
+
for target in _depends_on(services[name]):
|
|
245
|
+
if target not in live:
|
|
246
|
+
violations.append(
|
|
247
|
+
f"{path.name}: naming {profile} activates {name}, "
|
|
248
|
+
f"which depends on {target}, absent from the project"
|
|
249
|
+
)
|
|
250
|
+
assert not violations, (
|
|
251
|
+
"a named selection would produce an invalid compose project:\n"
|
|
252
|
+
+ "\n".join(violations)
|
|
253
|
+
)
|
|
@@ -1,126 +0,0 @@
|
|
|
1
|
-
"""Static checks on profile wiring in the packaged compose files.
|
|
2
|
-
|
|
3
|
-
These exist because of a real failure. `odctl down --all --volumes` selected the
|
|
4
|
-
`catalog` profile on its own, since teardown picks profiles by running state and
|
|
5
|
-
by volume ownership. That service declares a `depends_on` on services gated
|
|
6
|
-
behind the `postgres` and `storage` profiles, so compose rejected the file with
|
|
7
|
-
"depends on undefined service" before contacting Docker, and teardown aborted for
|
|
8
|
-
every profile group.
|
|
9
|
-
|
|
10
|
-
Nothing here needs Docker, so it belongs in the unit suite rather than in the
|
|
11
|
-
end-to-end tests, which run only on tags and so cannot fail a pull request.
|
|
12
|
-
"""
|
|
13
|
-
|
|
14
|
-
import pytest
|
|
15
|
-
import yaml
|
|
16
|
-
|
|
17
|
-
from odctl import config
|
|
18
|
-
from odctl.config import get_internal_resources_dir
|
|
19
|
-
from odctl.planner import expand_same_file_dependencies
|
|
20
|
-
from odctl.registry import load_registry
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
@pytest.fixture(autouse=True)
|
|
24
|
-
def _read_packaged_resources(monkeypatch):
|
|
25
|
-
"""Read the registry from the packaged resources, not a local workspace.
|
|
26
|
-
|
|
27
|
-
Without this the compose files come from src/odctl/resources while the
|
|
28
|
-
registry comes from ./.odctl, so an inconsistency between the two is
|
|
29
|
-
invisible to these tests.
|
|
30
|
-
"""
|
|
31
|
-
monkeypatch.setattr(config, "get_active_dir", get_internal_resources_dir)
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
def _compose_files():
|
|
35
|
-
return sorted(get_internal_resources_dir().glob("compose-*.yml"))
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
def _services(path):
|
|
39
|
-
return (yaml.safe_load(path.read_text()) or {}).get("services") or {}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
def _depends_on(service):
|
|
43
|
-
dep = service.get("depends_on")
|
|
44
|
-
if isinstance(dep, dict):
|
|
45
|
-
return list(dep.keys())
|
|
46
|
-
return list(dep or [])
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
def _profiles_of_file(path):
|
|
50
|
-
seen = []
|
|
51
|
-
for svc in _services(path).values():
|
|
52
|
-
for profile in svc.get("profiles") or []:
|
|
53
|
-
if profile not in seen:
|
|
54
|
-
seen.append(profile)
|
|
55
|
-
return seen
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
def test_every_teardown_selection_yields_a_valid_project():
|
|
59
|
-
"""Selecting any single profile, then widening it the way teardown does,
|
|
60
|
-
must leave every depends_on target inside the active set.
|
|
61
|
-
|
|
62
|
-
This is the property that actually matters: teardown may select one profile
|
|
63
|
-
of a file, so after expansion the resulting project has to be well-formed.
|
|
64
|
-
"""
|
|
65
|
-
violations = []
|
|
66
|
-
for path in _compose_files():
|
|
67
|
-
services = _services(path)
|
|
68
|
-
for profile in _profiles_of_file(path):
|
|
69
|
-
active = set(
|
|
70
|
-
expand_same_file_dependencies(_profiles_of_file(path), [profile])
|
|
71
|
-
)
|
|
72
|
-
live = {
|
|
73
|
-
name
|
|
74
|
-
for name, svc in services.items()
|
|
75
|
-
if not svc.get("profiles") or (set(svc["profiles"]) & active)
|
|
76
|
-
}
|
|
77
|
-
for name in sorted(live):
|
|
78
|
-
for target in _depends_on(services[name]):
|
|
79
|
-
if target not in live:
|
|
80
|
-
violations.append(
|
|
81
|
-
f"{path.name}: selecting {profile} activates {name}, "
|
|
82
|
-
f"which depends on {target}, absent from the project"
|
|
83
|
-
)
|
|
84
|
-
assert not violations, (
|
|
85
|
-
"a teardown selection would produce an invalid compose project:\n"
|
|
86
|
-
+ "\n".join(violations)
|
|
87
|
-
)
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
def test_expansion_pulls_in_same_file_dependencies():
|
|
91
|
-
"""Selecting `catalog` must widen to `postgres` and `storage`.
|
|
92
|
-
|
|
93
|
-
This is the concrete case that broke: `catalog` owns no volume of its own but
|
|
94
|
-
mounts the shared dependency volume, so `--volumes` selects it even when
|
|
95
|
-
nothing of it is running.
|
|
96
|
-
"""
|
|
97
|
-
expanded = expand_same_file_dependencies(
|
|
98
|
-
["postgres", "storage", "catalog"], ["catalog"]
|
|
99
|
-
)
|
|
100
|
-
assert expanded == ["postgres", "storage", "catalog"]
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
def test_expansion_stays_within_the_file():
|
|
104
|
-
"""Dependencies declared outside the file must not be added.
|
|
105
|
-
|
|
106
|
-
Each file is torn down by its own compose call, so widening across files
|
|
107
|
-
would stop shared infrastructure other profiles may still be using.
|
|
108
|
-
"""
|
|
109
|
-
registry = load_registry()
|
|
110
|
-
catalog_deps = None
|
|
111
|
-
for stack in registry.stacks.values():
|
|
112
|
-
if "catalog" in (stack.depends_on or {}):
|
|
113
|
-
catalog_deps = stack.depends_on["catalog"]
|
|
114
|
-
assert catalog_deps and "deps" in catalog_deps, "expected catalog to depend on deps"
|
|
115
|
-
|
|
116
|
-
expanded = expand_same_file_dependencies(
|
|
117
|
-
["postgres", "storage", "catalog"], ["catalog"]
|
|
118
|
-
)
|
|
119
|
-
assert "deps" not in expanded
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
def test_expansion_is_a_noop_without_dependencies():
|
|
123
|
-
"""A profile with no same-file dependencies is returned unchanged."""
|
|
124
|
-
assert expand_same_file_dependencies(
|
|
125
|
-
["kafka-lite", "kafka-full"], ["kafka-lite"]
|
|
126
|
-
) == ["kafka-lite"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|