docker-devkit 0.4.1__tar.gz → 0.4.2__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 (45) hide show
  1. {docker_devkit-0.4.1 → docker_devkit-0.4.2}/AGENTS.md +7 -5
  2. {docker_devkit-0.4.1 → docker_devkit-0.4.2}/PKG-INFO +1 -1
  3. {docker_devkit-0.4.1 → docker_devkit-0.4.2}/pyproject.toml +1 -1
  4. {docker_devkit-0.4.1 → docker_devkit-0.4.2}/src/docker_devkit/down.py +6 -12
  5. {docker_devkit-0.4.1 → docker_devkit-0.4.2}/src/docker_devkit/lifecycle.py +7 -0
  6. docker_devkit-0.4.2/src/docker_devkit/modes.py +24 -0
  7. {docker_devkit-0.4.1 → docker_devkit-0.4.2}/src/docker_devkit/up.py +16 -7
  8. docker_devkit-0.4.2/tests/test_modes.py +49 -0
  9. docker_devkit-0.4.1/src/docker_devkit/modes.py +0 -39
  10. {docker_devkit-0.4.1 → docker_devkit-0.4.2}/.gitattributes +0 -0
  11. {docker_devkit-0.4.1 → docker_devkit-0.4.2}/.github/workflows/integrate.yml +0 -0
  12. {docker_devkit-0.4.1 → docker_devkit-0.4.2}/.github/workflows/merge-gate.yml +0 -0
  13. {docker_devkit-0.4.1 → docker_devkit-0.4.2}/.github/workflows/release.yml +0 -0
  14. {docker_devkit-0.4.1 → docker_devkit-0.4.2}/.gitignore +0 -0
  15. {docker_devkit-0.4.1 → docker_devkit-0.4.2}/.python-version +0 -0
  16. {docker_devkit-0.4.1 → docker_devkit-0.4.2}/CLAUDE.md +0 -0
  17. {docker_devkit-0.4.1 → docker_devkit-0.4.2}/LICENSE +0 -0
  18. {docker_devkit-0.4.1 → docker_devkit-0.4.2}/NOTICE +0 -0
  19. {docker_devkit-0.4.1 → docker_devkit-0.4.2}/README.md +0 -0
  20. {docker_devkit-0.4.1 → docker_devkit-0.4.2}/publish.yml +0 -0
  21. {docker_devkit-0.4.1 → docker_devkit-0.4.2}/ruff.base.toml +0 -0
  22. {docker_devkit-0.4.1 → docker_devkit-0.4.2}/ruff.toml +0 -0
  23. {docker_devkit-0.4.1 → docker_devkit-0.4.2}/scripts/preflight.py +0 -0
  24. {docker_devkit-0.4.1 → docker_devkit-0.4.2}/src/docker_devkit/__init__.py +0 -0
  25. {docker_devkit-0.4.1 → docker_devkit-0.4.2}/src/docker_devkit/build_docker.py +0 -0
  26. {docker_devkit-0.4.1 → docker_devkit-0.4.2}/src/docker_devkit/config.py +0 -0
  27. {docker_devkit-0.4.1 → docker_devkit-0.4.2}/src/docker_devkit/context_sha.py +0 -0
  28. {docker_devkit-0.4.1 → docker_devkit-0.4.2}/src/docker_devkit/detect_gpu.py +0 -0
  29. {docker_devkit-0.4.1 → docker_devkit-0.4.2}/src/docker_devkit/documents.py +0 -0
  30. {docker_devkit-0.4.1 → docker_devkit-0.4.2}/src/docker_devkit/image_refs.py +0 -0
  31. {docker_devkit-0.4.1 → docker_devkit-0.4.2}/src/docker_devkit/matrix.py +0 -0
  32. {docker_devkit-0.4.1 → docker_devkit-0.4.2}/src/docker_devkit/mirror.py +0 -0
  33. {docker_devkit-0.4.1 → docker_devkit-0.4.2}/src/docker_devkit/py.typed +0 -0
  34. {docker_devkit-0.4.1 → docker_devkit-0.4.2}/src/docker_devkit/score_generate.py +0 -0
  35. {docker_devkit-0.4.1 → docker_devkit-0.4.2}/tests/test_build_docker.py +0 -0
  36. {docker_devkit-0.4.1 → docker_devkit-0.4.2}/tests/test_config.py +0 -0
  37. {docker_devkit-0.4.1 → docker_devkit-0.4.2}/tests/test_context_sha.py +0 -0
  38. {docker_devkit-0.4.1 → docker_devkit-0.4.2}/tests/test_default_targets.py +0 -0
  39. {docker_devkit-0.4.1 → docker_devkit-0.4.2}/tests/test_documents.py +0 -0
  40. {docker_devkit-0.4.1 → docker_devkit-0.4.2}/tests/test_image_refs.py +0 -0
  41. {docker_devkit-0.4.1 → docker_devkit-0.4.2}/tests/test_lifecycle.py +0 -0
  42. {docker_devkit-0.4.1 → docker_devkit-0.4.2}/tests/test_matrix.py +0 -0
  43. {docker_devkit-0.4.1 → docker_devkit-0.4.2}/tests/test_mirror.py +0 -0
  44. {docker_devkit-0.4.1 → docker_devkit-0.4.2}/tests/test_score_generate.py +0 -0
  45. {docker_devkit-0.4.1 → docker_devkit-0.4.2}/uv.lock +0 -0
@@ -22,15 +22,15 @@ Entry points (`[project.scripts]`): `up` → `up.py:app`, `down` → `down.py:ap
22
22
 
23
23
  | Module | Role |
24
24
  |---|---|
25
- | `up.py` | `docker compose up`. Flags: `--attached`/`-a`, `--quiet-pull`/`-q`, `--build`, `--gpu auto\|cuda\|rocm\|none`, `--dev`. |
26
- | `down.py` | `docker compose down`. Flags: `--volumes`/`-v` (also removes named volumes), `--gpu`. |
25
+ | `up.py` | `docker compose up`. Flags: `--attached`/`-a`, `--quiet-pull`/`-q`, `--build`, `--gpu auto\|cuda\|rocm\|none`, `--dev`. Resolves `COMPOSE_PROFILES` (shell env over `.env`), rejects values naming profiles the compose files don't define, and forwards the selection as explicit `--profile` flags — compose ignores the variable whenever any flag is present, so forwarding is the only reliable path. |
26
+ | `down.py` | `docker compose down`. Flags: `--volumes`/`-v` (also removes named volumes), `--gpu`. Activates every profile declared across the assembly (via `config --profiles`), so teardown covers containers from a prior bring-up under a different `COMPOSE_PROFILES` selection. |
27
27
  | `build_docker.py` | `run_build()` + the `build` command — cross-builds all images per the resolved image manifest, writing the paired lock (one `NAME={prefix}/{ref}@sha256:…` entry per upstream-native `x-base-images` declaration, the mirror-prefixed form derived from `mirror.prefix`; `@digest`-pinned declarations pass through unresolved; digests resolve against the upstream reference). `--push` is the CI shape (push images + registry caches + digest-manifest shelving) and hard-requires `x-registry-cache`; the flag order mirrors the binding's run line (`--push`, `--targets`, `--gpu`), and `--targets` accepts both repeated flags and one space-joined value. Push mode reads its registry facts from the neutral `REGISTRY`/`REGISTRY_TOKEN`/`REGISTRY_USERNAME` environment — no `GITHUB_*` reads. |
28
28
  | `score_generate.py` | `generate-score` — deterministic Score-manifest generation from a consumer's `stack/score/` sources: `${ALL_CAPS}` placeholder expansion into tempdir-rendered workload copies, idempotent `score-compose`/`score-k8s` init (state preserved across runs), and a determinism suite (document sort, state-path normalisation, 0644 modes, LF newlines). Consumer policy — workloads, provisioners, patch templates, output paths, storage classes, publishes, tool versions — is declared in the consumer's root `docker-devkit.yaml` under `generate-score:` (`ScoreConfig`, model in `config.py`); `load_score_config()` reads it, `ensure_score_tools()` fetches the pinned score-compose/score-k8s release binaries (for CI staleness gates). Output and state paths resolve against the repo root: the compose half runs in `score_dir` (its `.score-compose` state stays there, gitignored), the k8s half runs in the configured `k8s_state` parent so the committed `.score-k8s` state lands in the generated tier. `--local` writes the k8s artifact to the configured gitignored local path with the local storage class. |
29
29
  | `config.py` | `DockerDevkitConfig` — the root `docker-devkit.yaml` schema: a required `requires` PEP 440 specifier plus optional `lifecycle` / `mirror` / `generate-score` sections, `extra="forbid"` on every model. `load_config(root)` is the single strictyaml entry point: parses the file (returns `None` when absent — the no-config → single-file-default path for `up`/`down`), then self-checks the installed docker-devkit version against `requires` (refuses loudly outside range; the `0.0.0.dev0` dev sentinel bypasses the check, not the field). The section models (`LifecycleConfig`, `MirrorConfig`, `ScoreConfig`) live here; `load_lifecycle_config` / `load_mirror_config` / `load_score_config` in their modules are thin wrappers over it. |
30
- | `lifecycle.py` | `LifecycleConfig` (model in `config.py`) — the `lifecycle:` section declaration (`files`: compose files in layering order with `{gpu}` templating; `dev_file`: optional dev overlay); `load_lifecycle_config()` (reads the `lifecycle:` section via `config.load_config`, `None` when the section or file is absent), `resolve_manifest()` / `require_manifest()` (the raising form) / `resolve_lock()` (the image manifest and its lock are a named pair — `workloads/images.yml` + `workloads/images.lock`), `enforce_bake_declaration()` (a manifest requires a declaration), `expand_compose_files()` (assembles the `-f` list; a `{gpu}` entry is dropped when the GPU resolves to `none`; `dev_file` appended when a dev overlay applies). |
30
+ | `lifecycle.py` | `LifecycleConfig` (model in `config.py`) — the `lifecycle:` section declaration (`files`: compose files in layering order with `{gpu}` templating; `dev_file`: optional dev overlay); `load_lifecycle_config()` (reads the `lifecycle:` section via `config.load_config`, `None` when the section or file is absent), `resolve_manifest()` / `require_manifest()` (the raising form) / `resolve_lock()` (the image manifest and its lock are a named pair — `workloads/images.yml` + `workloads/images.lock`), `enforce_bake_declaration()` (a manifest requires a declaration), `expand_compose_files()` (assembles the `-f` list; a `{gpu}` entry is dropped when the GPU resolves to `none`; `dev_file` appended when a dev overlay applies); `enumerate_profiles()` (asks compose for every profile the assembly defines — `down`'s teardown set and `up`'s existence-check source). |
31
31
  | `detect_gpu.py` | `detect_gpu()` → `cuda`/`rocm`/`none` from host devices; the `Gpu` literal. |
32
32
  | `matrix.py` | `docker-build-matrix` — emit the CI build leg list as a bare JSON array on stdout (the binding's getter pipes it through its `matrix` verb, which composes the envelope and guards the empty case): one entry per variant × cohort cell, `targets` = that cell's explicitly-buildable services with dependents grouped with the base their `additional_contexts: target:` references pull in (one leg builds a whole dependency-closure group in a single bake invocation). The leg schema — `targets` (space-joined), `variant` (`common`/`cuda`/`rocm`, from the service-name suffix) — is named contract with the binding; renaming a key breaks its fanout YAML. The cross-compile cohort (services in `x-cross-compile-targets` or declaring `platforms:`) gets its own legs — per-arch builds are the QEMU-slow ones and parallelize away from native builds; a group spanning cohort membership is a loud error. Tagless services never become explicit targets (bake rejects a tagless target under `--push`) — they ride as in-invocation dependencies; a group spanning gpu variants is a loud error. |
33
- | `modes.py` | `resolve_auth_mode()` — validates `PUBLIC_URL` + `AUTH_MODE` from `.env`, rejecting `keycloak` over cleartext `http://`. |
33
+ | `modes.py` | `resolve_compose_profiles()` — resolves `COMPOSE_PROFILES` (shell env over `.env`, comma-split, deduped) for `up` to forward; `parse_env_file()` — the shared `.env` reader. Carries no consumer-domain knowledge. |
34
34
  | `context_sha.py` | `compute_service_shas(repo_root, bake: BakeDocument)` — per-service `tree-<hash>` image tags over the `.dockerignore`-allowlisted git tree. The workload-tree root is derived as the single top-level directory containing every tagged service's Dockerfile (a bake graph spanning several top-level directories is a loud error); files under it that belong to no tagged service are excluded from every SHA rather than shared into all of them. |
35
35
  | `documents.py` | `BakeDocument` + `parse_bake(path)` — the typed bake-file model every consumer of bake data goes through (build, up, down, generate-score, the reference collectors). A field validator enforces at parse time that every `x-base-images` value is `path:tag` or `path@digest` (a `${…}` hole or a bare path is a validation error, killing the silent-unpinned `FROM` → `:latest` failure mode). `digest_pinned(reference)` is the shared tail test. |
36
36
  | `image_refs.py` | Image-reference collection, split by source. `declared_references(root)` enumerates bake `x-base-images` declarations from `workloads/images.yml` (parsed and validated via `BakeDocument`) — the single source consumer mirroring consumes. `stray_references(root, …globs)` scans Dockerfile `FROM`, Dockerfile `COPY --from=` (slash-bearing captures only, so build-stage names are skipped), and yaml `image:` lines — hand-written literal refs that bypass the declaration system — across the `workloads/` and `docker/` Dockerfile trees and the `stack/score/` + `score/` source trees; `unpinned_references(root)` polices exactly those strays (every slash-bearing reference must carry a tag, a digest, or a trailing `${build-arg}` suffix — consumer CIs fail on its result). `bake_base_image_refs(bake: BakeDocument)` adapts declarations to `ImageReference` pairs; `version_coupling_violations(root, couplings)` (each `VersionCoupling` names a pyproject-declared version and the `VersionSite` regexes that must restate it — bake `x-base-images` values or raw file text — so non-derivable couplings like uv `required-version` ↔ uv-base-image tag can't drift silently; the mechanism lives here, the registry in consumer repos' preflight) is unchanged. Remote digest resolution goes through `imagetools inspect`. Compose files are parsed by nothing in this package. |
@@ -42,11 +42,13 @@ Entry points (`[project.scripts]`): `up` → `up.py:app`, `down` → `down.py:ap
42
42
 
43
43
  placeframe's shape lives in placeframe's `docker-devkit.yaml`, not in this package: a `lifecycle` section with `files` = `compose.yml`, `compose.postgres.yml`, `compose.{gpu}.yml` (in layering order) and `dev_file` = `compose.dev.yml`.
44
44
 
45
+ Profile-gated services are selected solely by `COMPOSE_PROFILES`; the verbs hold no built-in profile names. `up` fails loudly on a selection naming undefined profiles (the silent alternative is a stack that comes up minus its gated services); `down` ignores the selection and enumerates everything defined, since teardown must cover prior bring-ups under other selections.
46
+
45
47
  `--quiet-pull` suppresses compose's per-layer progress (CUDA images carry hundreds of layers) while still surfacing pull totals. `--gpu auto` resolves to `cuda`/`rocm`/`none` from host devices and selects the matching `compose.<gpu>.yml`; override only to reproduce a CI environment or test the CPU-only path.
46
48
 
47
49
  ## File and env conventions the commands assume
48
50
 
49
- - `.env` (required) — carries `PUBLIC_URL` and `AUTH_MODE`; `modes.py` reads it and rejects `keycloak` over cleartext `http://`.
51
+ - `.env` (required) — carries `COMPOSE_PROFILES` (the sole service selector `up`/`down` consult) plus whatever variables the consumer's compose files interpolate.
50
52
  - `workloads/images.lock` (required in every repo, checked in) — third-party and base-image pins, written by `build`: one `NAME={prefix}/{ref}@sha256:…` entry per manifest declaration. `up`/`down` refuse without it; consumers satisfy it with a committed lock filling the `${…_IMAGE:?err}` holes. The lock sits beside its manifest as the resolved pair.
51
53
  - `workloads/images.yml` (where a local build graph exists, required) — the image manifest and buildx-bake description; its top-level `x-cross-compile-targets` list (optional) is the set of services excluded from the default target list because they need per-arch treatment, opt-in via `--targets`. `build` reads exactly one manifest: `workloads/images.yml`. `buildx bake` resolves the manifest's relative paths against its invocation cwd, so `build` always invokes from the repo root.
52
54
  - the compose layers declared in the `lifecycle:` section of `docker-devkit.yaml`; the GPU one is picked by `detect_gpu()` (or the `--gpu` override).
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: docker-devkit
3
- Version: 0.4.1
3
+ Version: 0.4.2
4
4
  Summary: Docker-stack lifecycle commands (up/down/build) driven by a pyproject-declared compose assembly
5
5
  License-File: LICENSE
6
6
  License-File: NOTICE
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "docker-devkit"
3
- version = "0.4.1"
3
+ version = "0.4.2"
4
4
  description = "Docker-stack lifecycle commands (up/down/build) driven by a pyproject-declared compose assembly"
5
5
  requires-python = ">=3.13"
6
6
  dependencies = [
@@ -10,12 +10,12 @@ from .context_sha import compute_service_shas
10
10
  from .documents import parse_bake
11
11
  from .lifecycle import (
12
12
  enforce_bake_declaration,
13
+ enumerate_profiles,
13
14
  expand_compose_files,
14
15
  load_lifecycle_config,
15
16
  resolve_lock,
16
17
  resolve_manifest,
17
18
  )
18
- from .modes import resolve_auth_mode
19
19
 
20
20
  ENV_FILE = Path(".env")
21
21
 
@@ -44,8 +44,6 @@ def down(
44
44
  if gpu == "auto":
45
45
  gpu = detect_gpu()
46
46
 
47
- resolve_auth_mode(ENV_FILE)
48
-
49
47
  if manifest is not None:
50
48
  os.environ.update(compute_service_shas(root, parse_bake(manifest)))
51
49
 
@@ -55,15 +53,11 @@ def down(
55
53
  raise RuntimeError("No compose files declared; a builds-only repo has no stack to tear down")
56
54
 
57
55
  files_args = " ".join(f"-f {compose_file}" for compose_file in compose_files)
58
- command = (
59
- "docker compose "
60
- f"{files_args} "
61
- f"--project-directory {root} "
62
- "--profile keycloak " # Always include so any keycloak containers from a previous AUTH_MODE=keycloak run get torn down
63
- "--env-file .env "
64
- f"--env-file {lock_file} "
65
- "down --remove-orphans"
66
- )
56
+ # Every profile declared across the assembly, so teardown covers containers from a prior
57
+ # bring-up under a different COMPOSE_PROFILES selection
58
+ compose_prefix = f"docker compose {files_args} --project-directory {root} --env-file .env --env-file {lock_file}"
59
+ profile_flags = "".join(f"--profile {profile} " for profile in enumerate_profiles(compose_prefix))
60
+ command = f"{compose_prefix} {profile_flags}down --remove-orphans"
67
61
 
68
62
  if volumes:
69
63
  command += " -v"
@@ -2,6 +2,8 @@ from __future__ import annotations
2
2
 
3
3
  from pathlib import Path
4
4
 
5
+ from bashrun.bash import bash_output
6
+
5
7
  from .config import LifecycleConfig, load_config
6
8
  from .detect_gpu import Gpu
7
9
 
@@ -48,3 +50,8 @@ def expand_compose_files(config: LifecycleConfig | None, gpu: Gpu, *, include_de
48
50
  if include_dev and config.dev_file is not None:
49
51
  files.append(Path(config.dev_file))
50
52
  return files
53
+
54
+
55
+ def enumerate_profiles(compose_prefix: str) -> list[str]:
56
+ output = bash_output(f"{compose_prefix} config --profiles")
57
+ return [line.strip() for line in output.splitlines() if line.strip()]
@@ -0,0 +1,24 @@
1
+ from os import environ
2
+ from pathlib import Path
3
+
4
+
5
+ def resolve_compose_profiles(env_file: Path) -> list[str]:
6
+ file_values = parse_env_file(env_file)
7
+ raw_profiles = environ.get("COMPOSE_PROFILES") or file_values.get("COMPOSE_PROFILES", "")
8
+ profiles: list[str] = []
9
+ for entry in raw_profiles.split(","):
10
+ profile = entry.strip()
11
+ if profile and profile not in profiles:
12
+ profiles.append(profile)
13
+ return profiles
14
+
15
+
16
+ def parse_env_file(path: Path) -> dict[str, str]:
17
+ result: dict[str, str] = {}
18
+ for raw_line in path.read_text().splitlines():
19
+ line = raw_line.strip()
20
+ if not line or line.startswith("#") or "=" not in line:
21
+ continue
22
+ key, _, value = line.partition("=")
23
+ result[key.strip()] = value.strip().strip("'\"")
24
+ return result
@@ -11,13 +11,14 @@ from .context_sha import compute_service_shas
11
11
  from .documents import parse_bake
12
12
  from .lifecycle import (
13
13
  enforce_bake_declaration,
14
+ enumerate_profiles,
14
15
  expand_compose_files,
15
16
  load_lifecycle_config,
16
17
  resolve_lock,
17
18
  resolve_manifest,
18
19
  )
19
20
  from .mirror import enforce_mirrored, load_mirror_config
20
- from .modes import parse_env_file, resolve_auth_mode
21
+ from .modes import parse_env_file, resolve_compose_profiles
21
22
 
22
23
  ENV_FILE = Path(".env")
23
24
 
@@ -66,8 +67,6 @@ def up(
66
67
  if gpu == "auto":
67
68
  gpu = detect_gpu()
68
69
 
69
- auth_mode = resolve_auth_mode(ENV_FILE)
70
-
71
70
  mirror_config = load_mirror_config(root)
72
71
  if mirror_config is not None:
73
72
  enforce_mirrored(parse_env_file(lock_file))
@@ -83,10 +82,20 @@ def up(
83
82
  raise RuntimeError("No compose files declared; a builds-only repo has no stack to bring up")
84
83
 
85
84
  files_args = " ".join(f"-f {compose_file}" for compose_file in compose_files)
86
- profile_flag = "--profile keycloak " if auth_mode == "keycloak" else ""
87
- compose_args = f"{files_args} {profile_flag}--project-directory {root} --env-file .env --env-file {lock_file}"
88
-
89
- up_command = f"docker compose {compose_args} up"
85
+ # compose ignores COMPOSE_PROFILES whenever a --profile flag is present, so the selection is
86
+ # resolved here and forwarded as explicit flags
87
+ compose_prefix = f"docker compose {files_args} --project-directory {root} --env-file .env --env-file {lock_file}"
88
+ profiles = resolve_compose_profiles(ENV_FILE)
89
+ if profiles:
90
+ defined = enumerate_profiles(compose_prefix)
91
+ unknown = [profile for profile in profiles if profile not in defined]
92
+ if unknown:
93
+ raise RuntimeError(
94
+ f"COMPOSE_PROFILES selects unknown profile(s) {unknown}; "
95
+ f"profiles defined by the compose files: {defined}"
96
+ )
97
+ profile_flags = "".join(f"--profile {profile} " for profile in profiles)
98
+ up_command = f"{compose_prefix} {profile_flags}up"
90
99
  if not build:
91
100
  # tree-<sha> tags are content-addressed and may not be pushed, so pull only what is missing locally
92
101
  up_command += " --pull missing"
@@ -0,0 +1,49 @@
1
+ from pathlib import Path
2
+
3
+ import pytest
4
+
5
+ from docker_devkit.modes import resolve_compose_profiles
6
+
7
+
8
+ class TestResolveComposeProfiles:
9
+ def test_reads_from_env_file(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
10
+ monkeypatch.delenv("COMPOSE_PROFILES", raising=False)
11
+ env_file = tmp_path / ".env"
12
+ env_file.write_text("COMPOSE_PROFILES=tunnel-ngrok\n", encoding="utf-8")
13
+
14
+ assert resolve_compose_profiles(env_file) == ["tunnel-ngrok"]
15
+
16
+ def test_shell_environment_overrides_file(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
17
+ env_file = tmp_path / ".env"
18
+ env_file.write_text("COMPOSE_PROFILES=tunnel-ngrok\n", encoding="utf-8")
19
+ monkeypatch.setenv("COMPOSE_PROFILES", "tunnel-rathole")
20
+
21
+ assert resolve_compose_profiles(env_file) == ["tunnel-rathole"]
22
+
23
+ def test_splits_commas_strips_whitespace_and_drops_empties(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
24
+ monkeypatch.delenv("COMPOSE_PROFILES", raising=False)
25
+ env_file = tmp_path / ".env"
26
+ env_file.write_text('COMPOSE_PROFILES=" keycloak , , tunnel-ngrok ,"\n', encoding="utf-8")
27
+
28
+ assert resolve_compose_profiles(env_file) == ["keycloak", "tunnel-ngrok"]
29
+
30
+ def test_deduplicates_preserving_first_occurrence(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
31
+ monkeypatch.delenv("COMPOSE_PROFILES", raising=False)
32
+ env_file = tmp_path / ".env"
33
+ env_file.write_text("COMPOSE_PROFILES=keycloak,tunnel-ngrok,keycloak\n", encoding="utf-8")
34
+
35
+ assert resolve_compose_profiles(env_file) == ["keycloak", "tunnel-ngrok"]
36
+
37
+ def test_absent_yields_empty(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
38
+ monkeypatch.delenv("COMPOSE_PROFILES", raising=False)
39
+ env_file = tmp_path / ".env"
40
+ env_file.write_text("PUBLIC_URL=https://example.dev\n", encoding="utf-8")
41
+
42
+ assert resolve_compose_profiles(env_file) == []
43
+
44
+ def test_empty_value_yields_empty(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
45
+ monkeypatch.delenv("COMPOSE_PROFILES", raising=False)
46
+ env_file = tmp_path / ".env"
47
+ env_file.write_text("COMPOSE_PROFILES=\n", encoding="utf-8")
48
+
49
+ assert resolve_compose_profiles(env_file) == []
@@ -1,39 +0,0 @@
1
- from os import environ
2
- from pathlib import Path
3
- from urllib.parse import urlparse
4
-
5
- VALID_AUTH_MODES = ("keycloak", "disabled")
6
- DEFAULT_AUTH_MODE = "keycloak"
7
-
8
-
9
- def resolve_auth_mode(env_file: Path) -> str:
10
- file_values = parse_env_file(env_file)
11
- public_url = environ.get("PUBLIC_URL") or file_values.get("PUBLIC_URL")
12
- auth_mode = environ.get("AUTH_MODE") or file_values.get("AUTH_MODE", DEFAULT_AUTH_MODE)
13
-
14
- if not public_url:
15
- raise RuntimeError("PUBLIC_URL is required; set it in .env")
16
- if auth_mode not in VALID_AUTH_MODES:
17
- raise RuntimeError(f"AUTH_MODE={auth_mode!r} is invalid; expected one of {list(VALID_AUTH_MODES)}")
18
-
19
- scheme = urlparse(public_url).scheme
20
- if scheme not in ("http", "https"):
21
- raise RuntimeError(f"PUBLIC_URL={public_url!r} must use http:// or https://")
22
- if scheme == "http" and auth_mode == "keycloak":
23
- raise RuntimeError(
24
- f"AUTH_MODE=keycloak with PUBLIC_URL={public_url!r} is rejected: OAuth credentials must "
25
- f"not flow in cleartext. Use https:// (e.g. via ngrok), or set AUTH_MODE=disabled."
26
- )
27
-
28
- return auth_mode
29
-
30
-
31
- def parse_env_file(path: Path) -> dict[str, str]:
32
- result: dict[str, str] = {}
33
- for raw_line in path.read_text().splitlines():
34
- line = raw_line.strip()
35
- if not line or line.startswith("#") or "=" not in line:
36
- continue
37
- key, _, value = line.partition("=")
38
- result[key.strip()] = value.strip().strip("'\"")
39
- return result
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes