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.
Files changed (89) hide show
  1. {odctl-0.4.2 → odctl-0.4.3}/.github/renovate.json +12 -6
  2. {odctl-0.4.2 → odctl-0.4.3}/.github/workflows/e2e-test.yml +11 -1
  3. {odctl-0.4.2 → odctl-0.4.3}/PKG-INFO +1 -1
  4. {odctl-0.4.2 → odctl-0.4.3}/pyproject.toml +1 -1
  5. {odctl-0.4.2 → odctl-0.4.3}/src/odctl/main.py +15 -6
  6. {odctl-0.4.2 → odctl-0.4.3}/src/odctl/planner.py +27 -0
  7. {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/compose-analytics.yml +2 -2
  8. {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/compose-infra.yml +1 -1
  9. {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/compose-kafka.yml +1 -1
  10. {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/compose-metadata.yml +3 -3
  11. {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/deps/Dockerfile +1 -1
  12. {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/deps/build.gradle +1 -1
  13. {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/deps/versions.env +4 -2
  14. {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/docker/airflow/Dockerfile +4 -2
  15. {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/docker/ray-mlops/Dockerfile +1 -1
  16. {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/docker/spark/Dockerfile +7 -0
  17. odctl-0.4.3/tests/test_compose_profiles.py +253 -0
  18. odctl-0.4.2/tests/test_compose_profiles.py +0 -126
  19. {odctl-0.4.2 → odctl-0.4.3}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  20. {odctl-0.4.2 → odctl-0.4.3}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  21. {odctl-0.4.2 → odctl-0.4.3}/.github/scripts/smoke.sh +0 -0
  22. {odctl-0.4.2 → odctl-0.4.3}/.github/workflows/build-deps.yml +0 -0
  23. {odctl-0.4.2 → odctl-0.4.3}/.github/workflows/build-platform-images.yml +0 -0
  24. {odctl-0.4.2 → odctl-0.4.3}/.github/workflows/ci.yml +0 -0
  25. {odctl-0.4.2 → odctl-0.4.3}/.github/workflows/pipeline.yml +0 -0
  26. {odctl-0.4.2 → odctl-0.4.3}/.github/workflows/publish.yml +0 -0
  27. {odctl-0.4.2 → odctl-0.4.3}/.gitignore +0 -0
  28. {odctl-0.4.2 → odctl-0.4.3}/.pre-commit-config.yaml +0 -0
  29. {odctl-0.4.2 → odctl-0.4.3}/.vscode/settings.json +0 -0
  30. {odctl-0.4.2 → odctl-0.4.3}/LICENSE +0 -0
  31. {odctl-0.4.2 → odctl-0.4.3}/README.md +0 -0
  32. {odctl-0.4.2 → odctl-0.4.3}/image/diagram.png +0 -0
  33. {odctl-0.4.2 → odctl-0.4.3}/image/diagram1.drawio +0 -0
  34. {odctl-0.4.2 → odctl-0.4.3}/image/diagram2.drawio +0 -0
  35. {odctl-0.4.2 → odctl-0.4.3}/src/odctl/__init__.py +0 -0
  36. {odctl-0.4.2 → odctl-0.4.3}/src/odctl/config.py +0 -0
  37. {odctl-0.4.2 → odctl-0.4.3}/src/odctl/docker.py +0 -0
  38. {odctl-0.4.2 → odctl-0.4.3}/src/odctl/registry.py +0 -0
  39. {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/clickhouse/config.xml +0 -0
  40. {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/clickhouse/init.sql +0 -0
  41. {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/clickhouse/keeper_config.xml +0 -0
  42. {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/clickhouse/macros-11.xml +0 -0
  43. {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/clickhouse/macros-12.xml +0 -0
  44. {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/clickhouse/macros-21.xml +0 -0
  45. {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/clickhouse/macros-22.xml +0 -0
  46. {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/clickhouse/users.xml +0 -0
  47. {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/compose-deps.yml +0 -0
  48. {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/compose-flink.yml +0 -0
  49. {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/compose-mlops.yml +0 -0
  50. {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/compose-obsv.yml +0 -0
  51. {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/compose-orch.yml +0 -0
  52. {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/compose-spark.yml +0 -0
  53. {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/compose-store.yml +0 -0
  54. {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/deps/build_connectors.sh +0 -0
  55. {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/deps/fetch_flink_dependencies.sh +0 -0
  56. {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/deps/fetch_spark_dependencies.sh +0 -0
  57. {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/deps/fetch_standalone_plugins.sh +0 -0
  58. {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/flink/flink-conf.yml +0 -0
  59. {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/grafana/prometheus.yml +0 -0
  60. {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/marquez/marquez.yml +0 -0
  61. {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/mlops/serving/lightgbm_inference.py.example +0 -0
  62. {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/mlops/serving/vector_search.py.example +0 -0
  63. {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/postgres/01-init-databases.sh +0 -0
  64. {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/prometheus/alertmanager.yml +0 -0
  65. {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/prometheus/prometheus.yml +0 -0
  66. {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/registry.yml +0 -0
  67. {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/seaweed/s3.json +0 -0
  68. {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/spark/log4j2.properties +0 -0
  69. {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/spark/spark-defaults.conf +0 -0
  70. {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/trino/access-control.properties +0 -0
  71. {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/trino/catalog/clickhouse.properties +0 -0
  72. {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/trino/catalog/iceberg.properties +0 -0
  73. {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/trino/catalog/kafka.properties +0 -0
  74. {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/trino/catalog/postgres.properties +0 -0
  75. {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/trino/catalog/valkey.properties +0 -0
  76. {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/trino/group-provider.properties +0 -0
  77. {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/trino/groups.txt +0 -0
  78. {odctl-0.4.2 → odctl-0.4.3}/src/odctl/resources/trino/rules.json +0 -0
  79. {odctl-0.4.2 → odctl-0.4.3}/src/odctl/ui.py +0 -0
  80. {odctl-0.4.2 → odctl-0.4.3}/src/odctl/workspace.py +0 -0
  81. {odctl-0.4.2 → odctl-0.4.3}/tests/conftest.py +0 -0
  82. {odctl-0.4.2 → odctl-0.4.3}/tests/test_cli.py +0 -0
  83. {odctl-0.4.2 → odctl-0.4.3}/tests/test_cli_extended.py +0 -0
  84. {odctl-0.4.2 → odctl-0.4.3}/tests/test_config.py +0 -0
  85. {odctl-0.4.2 → odctl-0.4.3}/tests/test_docker.py +0 -0
  86. {odctl-0.4.2 → odctl-0.4.3}/tests/test_planner.py +0 -0
  87. {odctl-0.4.2 → odctl-0.4.3}/tests/test_registry.py +0 -0
  88. {odctl-0.4.2 → odctl-0.4.3}/tests/test_workspace.py +0 -0
  89. {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/dml/resources/docker/.*/Dockerfile$",
11
- "^src/dml/resources/deps/Dockerfile$"
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": "Auto-merge non-breaking minor and patch updates if CI passes",
27
- "matchUpdateTypes": ["minor", "patch"],
28
- "automerge": true,
29
- "automergeType": "pr"
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
- echo "🧹 Tearing down $profile..."
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: odctl
3
- Version: 0.4.2
3
+ Version: 0.4.3
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.4.2"
3
+ version = "0.4.3"
4
4
  description = "Orchestrator for the Open Data Stack"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -17,7 +17,7 @@ from odctl.docker import (
17
17
  )
18
18
  from odctl.planner import (
19
19
  build_execution_plan,
20
- expand_same_file_dependencies,
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] = expand_same_file_dependencies(profs, active_profs)
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
- plan = build_execution_plan(profiles, all, resolve_deps=False)
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.8-alpine
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.8-alpine
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.22
56
+ image: &seaweed-image chrislusf/seaweedfs:4.40
57
57
  container_name: seaweed
58
58
  ports:
59
59
  - "8333:8333" # S3 API
@@ -163,7 +163,7 @@ services:
163
163
  # Schema Registry & UI
164
164
  # ==========================================
165
165
  karapace:
166
- image: ghcr.io/aiven-open/karapace:6.1.3
166
+ image: ghcr.io/aiven-open/karapace:6.2.2
167
167
  container_name: karapace
168
168
  entrypoint:
169
169
  - python3
@@ -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.11.4
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.12.6
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.12.6
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.6-jdk17 AS gradle-resolver
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.2.0'
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:4.1.2, which bundles Hadoop 3.4.2, and pin the AWS SDK to
25
- # the version hadoop-aws declares rather than the newest published one.
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.2.1
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
- # Pinning PySpark strictly to 4.0.x for Iceberg compatibility
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.55.1-py312-cpu
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