Skip to content

Commit e292fdb

Browse files
committed
feat(dev-container): map the paths of docker compose to the host
`docker compose` and `docker-compose` passed the compose file through unchanged, so every bind mount in it named a path in the dev-container that the daemon then looked up on the host. For up, create, run, watch and scale the file is now rendered with `config --format json`, bind sources, secret and config files and bind-driver volume devices are mapped with cuos-host-path, and compose runs on the result. `docker-compose` hands over to the docker wrapper. Two defects in the same wrapper fixed on the way: - A short option with an attached value was split letter by letter: `-p8080:80` became `-p -8 -0 ...`, `-eFOO=bar` and `-w/srv` likewise, and so did options of the command inside the container (`find -name`). Only a cluster of boolean flags ending in -v is expanded now. - cuos-host-path printed its usage text on stdout for a path that did not exist yet, and the wrapper mounted that text as the source. It now resolves with `realpath -m` and reports errors on stderr. Verified with docker.test.sh (15 passed) and shellcheck, against mocks only: not run against a real daemon or the compose version in the image. Signed-off-by: Simon Walz <contact@simonwalz.de>
1 parent 2db17f3 commit e292fdb

5 files changed

Lines changed: 178 additions & 19 deletions

File tree

‎dev-container/Dockerfile‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,4 +40,4 @@ COPY entrypoint.sh /
4040
COPY sshd_config_local.conf /etc/ssh/sshd_config.d/
4141
COPY vimrc.local /etc/vim/
4242
COPY gitconfig /etc/
43-
COPY cuos cuos-* docker /usr/local/bin/
43+
COPY cuos cuos-* docker docker-compose /usr/local/bin/

‎dev-container/cuos-host-path‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,14 @@
44
# Path to the real docker binary (avoid recursion, see ./docker script)
55
DOCKER_REAL="${DOCKER_REAL:-/usr/bin/docker}"
66

7-
# Usage: ./resolve_host_path.sh /path/inside/container
8-
container_path="$(realpath "$1")"
9-
10-
if [ -z "$container_path" ]; then
11-
echo "Usage: $0 <path-inside-container>"
7+
if [ -z "${1:-}" ]; then
8+
echo "Usage: $0 <path-inside-container>" >&2
129
exit 1
1310
fi
1411

12+
# -m: a bind source need not exist yet, docker creates it on the host.
13+
container_path="$(realpath -m -- "$1")"
14+
1515
# exclusions:
1616
case "$1" in
1717
"/root/.docker/config.json") echo "$1";exit;;
@@ -44,7 +44,7 @@ for id in $container_ids; do
4444
done
4545

4646
[ -z "$my_container_id" ] && {
47-
echo "Could not identify current container."
47+
echo "Could not identify current container." >&2
4848
exit 1
4949
}
5050

‎dev-container/docker‎

Lines changed: 110 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ map_path_or_keep() {
5252
return 0
5353
fi
5454

55-
if ! out="$("$HOST_MAP_BIN" "$in2" 2>/dev/null || true)"; then
55+
if ! out="$("$HOST_MAP_BIN" "$in2" 2>/dev/null)"; then
5656
out=""
5757
fi
5858
if [ -n "$out" ]; then
@@ -177,7 +177,6 @@ rewrite_mount_spec() {
177177
}
178178

179179
# Expand combined short options like -itv/... into -i -t -v<spec>
180-
# For non-`v` flags, we split to separate tokens (e.g., -p80:80 => "-p" "80:80")
181180
expand_short_cluster() {
182181
local arg="$1"
183182
local rest="${arg#-}"
@@ -203,6 +202,99 @@ expand_short_cluster() {
203202
printf '%s\0' "${tokens[@]}"
204203
}
205204

205+
# --- docker compose ---
206+
# The paths in a compose file are paths in this container, and the daemon
207+
# resolves them on the host. For the commands that create containers the file
208+
# is rendered, the sources the daemon mounts are mapped, and compose runs on the
209+
# result. The build context, env_file and the like are read by compose itself
210+
# and stay as they are.
211+
COMPOSE_CREATING=" up create run watch scale "
212+
213+
compose_map_sources() {
214+
local config="$1"
215+
local updates="[]" line path src
216+
while IFS= read -r line; do
217+
path="$(jq -c '.[0]' <<<"$line")"
218+
src="$(jq -r '.[1]' <<<"$line")"
219+
looks_like_path "$src" || continue
220+
updates="$(jq -c --argjson p "$path" --arg v "$(map_path_or_keep "$src")" \
221+
'. + [[$p, $v]]' <<<"$updates")"
222+
done < <(jq -c '
223+
(.services // {} | to_entries[] | .key as $s
224+
| (.value.volumes // []) | to_entries[]
225+
| select(.value.type == "bind")
226+
| [["services", $s, "volumes", .key, "source"], .value.source]),
227+
(("secrets", "configs") as $k | (.[$k] // {}) | to_entries[]
228+
| select(.value.file)
229+
| [[$k, .key, "file"], .value.file]),
230+
(.volumes // {} | to_entries[]
231+
| select((.value.driver_opts.o // "") | split(",") | index("bind"))
232+
| select(.value.driver_opts.device)
233+
| [["volumes", .key, "driver_opts", "device"], .value.driver_opts.device])
234+
' <<<"$config")
235+
236+
# The rendering already holds only the active services; their profiles go, so
237+
# that a COMPOSE_PROFILES from the project's .env still counts.
238+
jq --argjson u "$updates" '
239+
reduce $u[] as $x (.; setpath($x[0]; $x[1]))
240+
| if has("services") then .services |= map_values(del(.profiles)) else . end
241+
' <<<"$config"
242+
}
243+
244+
COMPOSE_RENDERED=""
245+
246+
run_compose() {
247+
local -a global=() rest=()
248+
local sub="" from_stdin=0
249+
while [ "$#" -gt 0 ]; do
250+
case "$1" in
251+
-f|--file|-p|--project-name|--project-directory|--env-file|--profile|--parallel|--ansi|--progress)
252+
if [[ "$1" == -f || "$1" == --file ]] && [ "${2:-}" = "-" ]; then
253+
from_stdin=1
254+
fi
255+
global+=("$1"); shift
256+
if [ "$#" -gt 0 ]; then global+=("$1"); shift; fi
257+
;;
258+
-f-|-f=-|--file=-)
259+
from_stdin=1; global+=("$1"); shift ;;
260+
-*)
261+
global+=("$1"); shift ;;
262+
*)
263+
sub="$1"; rest=("$@"); break ;;
264+
esac
265+
done
266+
267+
if [ "$from_stdin" = 1 ] || [[ "$COMPOSE_CREATING" != *" $sub "* ]]; then
268+
"$DOCKER_REAL" compose "${global[@]}" "${rest[@]}"
269+
return
270+
fi
271+
272+
# Without a valid project, compose's own error comes from the command as given.
273+
local config
274+
if ! config="$("$DOCKER_REAL" compose "${global[@]}" config --format json 2>/dev/null)"; then
275+
"$DOCKER_REAL" compose "${global[@]}" "${rest[@]}"
276+
return
277+
fi
278+
279+
local -a keep=()
280+
local i=0
281+
while [ "$i" -lt "${#global[@]}" ]; do
282+
case "${global[i]}" in
283+
-f|--file) i=$((i + 2)); continue ;;
284+
-f*|--file=*) i=$((i + 1)); continue ;;
285+
esac
286+
keep+=("${global[i]}"); i=$((i + 1))
287+
done
288+
289+
# A file, not stdin: `compose run -it` needs the terminal.
290+
COMPOSE_RENDERED="$(mktemp --suffix=.json)" || return 1
291+
trap 'rm -f "$COMPOSE_RENDERED"' EXIT
292+
compose_map_sources "$config" >"$COMPOSE_RENDERED" || return 1
293+
log_debug "Compose file rendered to $COMPOSE_RENDERED"
294+
295+
"$DOCKER_REAL" compose "${keep[@]}" -f "$COMPOSE_RENDERED" "${rest[@]}"
296+
}
297+
206298
# --- Parse args and rewrite ---
207299
OUT_ARGS=()
208300
END_OF_OPTS=0
@@ -264,11 +356,17 @@ while [ "$#" -gt 0 ]; do
264356
OUT_ARGS+=("--mount=$(rewrite_mount_spec "$mspec")")
265357
;;
266358

267-
# Combined short options: e.g., -itv/home:/mnt:ro
268-
-[!-][!-]*)
269-
mapfile -d '' -t expanded < <(expand_short_cluster "$arg")
270-
# Prepend expanded tokens back to the input stream, then continue
271-
set -- "${expanded[@]}" "$@"
359+
# Boolean flags combined with -v: -itv/home:/mnt:ro, rm -fv.
360+
# Any other letter takes a value (-p8080:80, -eFOO=bar, -w/srv) or is not
361+
# docker's at all (find -name), and the argument stays as it is.
362+
-[dfiPqt]*v*)
363+
if [[ "$arg" =~ ^-[dfiPqt]+v ]]; then
364+
mapfile -d '' -t expanded < <(expand_short_cluster "$arg")
365+
# Prepend expanded tokens back to the input stream, then continue
366+
set -- "${expanded[@]}" "$@"
367+
else
368+
OUT_ARGS+=("$arg")
369+
fi
272370
;;
273371

274372
*)
@@ -289,5 +387,10 @@ if [ "$DEBUG" = "1" ]; then
289387
printf '\n' >&2
290388
fi
291389

390+
if [ "${OUT_ARGS[0]:-}" = "compose" ]; then
391+
run_compose "${OUT_ARGS[@]:1}"
392+
exit "$?"
393+
fi
394+
292395
"$DOCKER_REAL" "${OUT_ARGS[@]}"
293396
exit "$?"

‎dev-container/docker-compose‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
#!/bin/bash
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
# The path mapping lives in the docker wrapper next to this script.
5+
exec docker compose "$@"

‎dev-container/docker.test.sh‎

Lines changed: 56 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -45,13 +45,29 @@ export -f cuos_host_path
4545

4646
# docker: mock of the real docker CLI (captured by DOCKER_REAL)
4747
# Behavior: simply echoes the final argv the wrapper execs.
48+
# `compose ... config` prints COMPOSE_CONFIG, or fails when it is empty, and a
49+
# rendered compose file is printed as "@" and its content.
4850
docker() {
49-
# Print arguments space-separated in a single line
50-
if [ "$#" -gt 0 ]; then
51-
printf '%s' "$1"; shift
52-
for a in "$@"; do printf ' %s' "$a"; done
51+
if [ "${1:-}" = "compose" ]; then
52+
local a
53+
for a in "$@"; do
54+
if [ "$a" = "config" ]; then
55+
[ -n "${COMPOSE_CONFIG:-}" ] || return 1
56+
printf '%s\n' "$COMPOSE_CONFIG"
57+
return 0
58+
fi
59+
done
5360
fi
54-
printf '\n'
61+
local out=() prev=""
62+
for a in "$@"; do
63+
if [ "$prev" = "-f" ] && [[ "$a" == *.json ]] && [ -f "$a" ]; then
64+
out+=("@$(jq -c . "$a")")
65+
else
66+
out+=("$a")
67+
fi
68+
prev="$a"
69+
done
70+
printf '%s\n' "${out[*]}"
5571
}
5672
export -f docker
5773

@@ -136,6 +152,41 @@ expect "-- passthrough" \
136152
"run -- -v /x:/y weird -args" \
137153
run -- -v /x:/y weird -args
138154

155+
# 10) Short options with an attached value are not split
156+
expect "attached values untouched" \
157+
"run -p8080:80 -eFOO=/a:/b -w/srv alpine" \
158+
run -p8080:80 -eFOO=/a:/b -w/srv alpine
159+
160+
# 11) Options of the command inside the container are not split
161+
expect "find -name untouched" \
162+
"run alpine find / -name foo" \
163+
run alpine find / -name foo
164+
165+
# 12) compose: bind sources, secret files and bind-driver volumes are mapped;
166+
# named volumes, build context and profiles of active services are not
167+
export COMPOSE_CONFIG='{"name":"p","services":{"s":{"build":{"context":"/src"},"profiles":["dev"],"volumes":[{"type":"bind","source":"/data/x","target":"/x"},{"type":"volume","source":"v","target":"/v"}]}},"secrets":{"k":{"file":"/data/k"}},"volumes":{"v":{},"b":{"driver_opts":{"type":"none","o":"bind","device":"/data/b"}}}}'
168+
expect "compose up renders mapped file" \
169+
'compose -p p -f @{"name":"p","services":{"s":{"build":{"context":"/src"},"volumes":[{"type":"bind","source":"/HOST/data/x","target":"/x"},{"type":"volume","source":"v","target":"/v"}]}},"secrets":{"k":{"file":"/HOST/data/k"}},"volumes":{"v":{},"b":{"driver_opts":{"type":"none","o":"bind","device":"/HOST/data/b"}}}} up -d' \
170+
compose -p p -f compose.yml up -d
171+
172+
# 13) compose: commands that create no container pass through
173+
expect "compose ps passthrough" \
174+
"compose -f compose.yml ps" \
175+
compose -f compose.yml ps
176+
177+
# 14) compose: -v of `compose run` is mapped like docker's
178+
export COMPOSE_CONFIG='{"name":"p","services":{"s":{}}}'
179+
expect "compose run -v" \
180+
'compose -f @{"name":"p","services":{"s":{}}} run -v /HOST/data/w:/w s' \
181+
compose run -v /data/w:/w s
182+
183+
# 15) compose: without a project the command runs as given
184+
export COMPOSE_CONFIG=""
185+
expect "compose without project" \
186+
"compose up" \
187+
compose up
188+
unset COMPOSE_CONFIG
189+
139190
printf '\nSummary: %d passed, %d failed\n' "$pass" "$fail"
140191
[ "$fail" -eq 0 ] || exit 1
141192

0 commit comments

Comments
 (0)