diff --git a/.docker/Dockerfile.proxy-coordinator b/.docker/Dockerfile.proxy-coordinator
deleted file mode 100644
index fcde7fca..00000000
--- a/.docker/Dockerfile.proxy-coordinator
+++ /dev/null
@@ -1,9 +0,0 @@
-FROM docker:29-cli
-
-RUN apk add --no-cache docker-cli-compose
-
-COPY scripts/proxy-coordinator.sh /usr/local/bin/proxy-coordinator.sh
-
-RUN chmod +x /usr/local/bin/proxy-coordinator.sh
-
-ENTRYPOINT ["/usr/local/bin/proxy-coordinator.sh"]
diff --git a/.docker/database-services.yml b/.docker/database-services.yml
index 91701323..6f27bb85 100644
--- a/.docker/database-services.yml
+++ b/.docker/database-services.yml
@@ -2,14 +2,14 @@ services:
mysql:
# Pin to 8.4: mysql:latest currently resolves to 9.x, and Nextcloud autoinstall
# runs with the application user, which cannot read mysql.component there.
- image: mysql:8.4
+ image: mysql:8.4@sha256:b3b90af2a6552ae30c266fdb7d5dd55f3afb72404bb78d37fe8a23eb857fd3fb
volumes:
- ../volumes/mysql/init:/docker-entrypoint-initdb.d
- ../volumes/mysql/data:/var/lib/mysql
ports:
- target: 3306
published: "3306-3325"
- host_ip: ${IP_BIND:-127.0.0.1}
+ host_ip: ${MYSQL_IP_BIND:-127.0.0.1}
command: ["mysqld"]
environment:
- TZ=${TZ:-CET}
@@ -18,13 +18,13 @@ services:
- MYSQL_USER=${MYSQL_USER:-nextcloud}
- MYSQL_PASSWORD=${MYSQL_PASSWORD:-nextcloud}
pgsql:
- image: postgres:13-alpine
+ image: postgres:13-alpine@sha256:fb9065b6e3e213bdc07edd372a5b2a26245840b7fb65d1fd8b6700106d51805c
volumes:
- ../volumes/postgres/data:/var/lib/postgresql/data
ports:
- target: 5432
published: "5432-5451"
- host_ip: ${IP_BIND:-127.0.0.1}
+ host_ip: ${POSTGRES_IP_BIND:-127.0.0.1}
command: ["postgres", "-c", "log_statement=all"]
environment:
- POSTGRES_DB=${POSTGRES_DB:-nextcloud}
diff --git a/.docker/docker-compose.proxy.yml b/.docker/docker-compose.proxy.yml
index d0767fa7..b2e36a2b 100644
--- a/.docker/docker-compose.proxy.yml
+++ b/.docker/docker-compose.proxy.yml
@@ -1,29 +1,31 @@
services:
nginx-proxy:
- image: nginxproxy/nginx-proxy:1.11-alpine
+ image: nginxproxy/nginx-proxy:1.11.6-alpine@sha256:99376e95c8754547213d411222a2c4dca046d974e753ae2a1c9aef09b2c03364
container_name: librecode-dev-proxy
- restart: unless-stopped
labels:
coop.librecode.dev-proxy: "true"
+ working_dir: /dashboard
+ command: ["forego", "start", "-r"]
ports:
- - "80:80"
- - "443:443"
+ - "${PROXY_IP_BIND:-127.0.0.1}:80:80"
+ - "${PROXY_IP_BIND:-127.0.0.1}:443:443"
volumes:
- ${DOCKER_SOCKET:-/var/run/docker.sock}:/tmp/docker.sock:ro
- librecode-dev-proxy-vhost:/etc/nginx/vhost.d
- - librecode-dev-proxy-conf:/etc/nginx/conf.d
+ - librecode-dev-proxy-assets:/dashboard:ro
- librecode-dev-proxy-log:/etc/nginx/log
- librecode-dev-proxy-certs:/etc/nginx/certs:ro
- - librecode-dev-proxy-html:/usr/share/nginx/html
environment:
- ENABLE_IPV6=true
+ - VIRTUAL_HOST=localhost,*.localhost
+ - VIRTUAL_PORT=80
+ - SELF_SIGNED_HOST=localhost,*.localhost
networks:
- proxy
ssl-companion:
- image: sebastienheyd/self-signed-proxy-companion:latest
+ image: sebastienheyd/self-signed-proxy-companion:1.1.0@sha256:dd698341f804aaffe2974931a185aa088561d76094e040e374847f3a64004d43
container_name: librecode-dev-proxy-ssl-companion
- restart: unless-stopped
labels:
coop.librecode.dev-proxy-companion: "true"
volumes:
@@ -37,15 +39,14 @@ services:
networks:
proxy:
name: librecode-dev-proxy
+ external: true
volumes:
librecode-dev-proxy-vhost:
name: librecode-dev-proxy-vhost
- librecode-dev-proxy-conf:
- name: librecode-dev-proxy-conf
+ librecode-dev-proxy-assets:
+ name: librecode-dev-proxy-assets
librecode-dev-proxy-log:
name: librecode-dev-proxy-log
librecode-dev-proxy-certs:
name: librecode-dev-proxy-certs
- librecode-dev-proxy-html:
- name: librecode-dev-proxy-html
diff --git a/.docker/nginx-proxy/*.localhost b/.docker/nginx-proxy/*.localhost
new file mode 100644
index 00000000..b66d860e
--- /dev/null
+++ b/.docker/nginx-proxy/*.localhost
@@ -0,0 +1,6 @@
+root /usr/share/nginx/html;
+error_page 404 =404 @librecode_dashboard_404;
+
+location @librecode_dashboard_404 {
+ try_files /index.html =404;
+}
diff --git a/.docker/nginx-proxy/*.localhost_location_override b/.docker/nginx-proxy/*.localhost_location_override
new file mode 100644
index 00000000..421c127f
--- /dev/null
+++ b/.docker/nginx-proxy/*.localhost_location_override
@@ -0,0 +1 @@
+return 404;
diff --git a/.docker/nginx-proxy/Procfile b/.docker/nginx-proxy/Procfile
new file mode 100644
index 00000000..c0f1ad20
--- /dev/null
+++ b/.docker/nginx-proxy/Procfile
@@ -0,0 +1,2 @@
+dockergen: docker-gen -config /dashboard/docker-gen.cfg -event-filter event=connect -event-filter event=disconnect
+nginx: nginx -g "daemon off;"
diff --git a/.docker/nginx-proxy/dashboard.tmpl b/.docker/nginx-proxy/dashboard.tmpl
new file mode 100644
index 00000000..10246d9c
--- /dev/null
+++ b/.docker/nginx-proxy/dashboard.tmpl
@@ -0,0 +1,250 @@
+{{- $routes := list -}}
+{{- range $hostname, $containers := groupByMulti . "Env.VIRTUAL_HOST" "," -}}
+ {{- $hostname = trim $hostname -}}
+ {{- if or (eq $hostname "") (eq $hostname "localhost") (eq $hostname "*.localhost") -}}
+ {{- continue -}}
+ {{- end -}}
+
+ {{- range $container := $containers -}}
+ {{- $onProxyNetwork := false -}}
+ {{- range $network := $container.Networks -}}
+ {{- if eq $network.Name "librecode-dev-proxy" -}}
+ {{- $onProxyNetwork = true -}}
+ {{- end -}}
+ {{- end -}}
+ {{- if not $onProxyNetwork -}}
+ {{- continue -}}
+ {{- end -}}
+
+ {{- $project := index $container.Labels "com.docker.compose.project" -}}
+ {{- $service := index $container.Labels "com.docker.compose.service" -}}
+ {{- if not $project -}}
+ {{- $project = "Docker" -}}
+ {{- end -}}
+ {{- if not $service -}}
+ {{- $service = "Service" -}}
+ {{- end -}}
+
+ {{- $routes = append $routes (dict "host" $hostname "project" $project "service" $service) -}}
+ {{- break -}}
+ {{- end -}}
+{{- end -}}
+
+
+
+
+
+ LibreCode Nextcloud Development Environment
+
+
+
+
+ π LibreCode Nextcloud Development Environment
+
+ β Environment not found
+ There is no active development service for .
+ Did you mean ?
+
+
+ Active environments
+
+
+
+ Environment checks
+ Loading runtime informationβ¦
+
+
+ This page is generated from the currently active development routes.
+
+
+
+
+
+
+
+
diff --git a/.docker/nginx-proxy/docker-gen.cfg b/.docker/nginx-proxy/docker-gen.cfg
new file mode 100644
index 00000000..15161780
--- /dev/null
+++ b/.docker/nginx-proxy/docker-gen.cfg
@@ -0,0 +1,12 @@
+[[config]]
+template = "/app/nginx.tmpl"
+dest = "/etc/nginx/conf.d/default.conf"
+watch = true
+wait = "100ms:500ms"
+notifycmd = "nginx -s reload"
+
+[[config]]
+template = "/dashboard/dashboard.tmpl"
+dest = "/usr/share/nginx/html/index.html"
+watch = true
+wait = "100ms:500ms"
diff --git a/.docker/nginx-proxy/index.html b/.docker/nginx-proxy/index.html
deleted file mode 100644
index ea292373..00000000
--- a/.docker/nginx-proxy/index.html
+++ /dev/null
@@ -1,72 +0,0 @@
-
-
-
-
-
- LibreCode Development Proxy
-
-
-
-
- π LibreCode Development Proxy
-
- The shared development proxy is running.
-
-
- Open your environment using the URL shown by
- docker compose up.
-
-
-
- The hostname is usually based on the project directory name:
-
-
-
- php83-master/
- β https://php83-master.localhost
-
-
-
- Requests to *.localhost are routed automatically.
-
-
-
-
diff --git a/.docker/nginx-proxy/localhost.conf b/.docker/nginx-proxy/localhost.conf
deleted file mode 100644
index fef7b775..00000000
--- a/.docker/nginx-proxy/localhost.conf
+++ /dev/null
@@ -1,13 +0,0 @@
-server {
- listen 80;
- listen [::]:80;
-
- server_name localhost;
-
- root /usr/share/nginx/html;
- index index.html;
-
- location / {
- try_files /index.html =404;
- }
-}
diff --git a/.docker/nginx-proxy/localhost_location_override b/.docker/nginx-proxy/localhost_location_override
new file mode 100644
index 00000000..b3f4e9c6
--- /dev/null
+++ b/.docker/nginx-proxy/localhost_location_override
@@ -0,0 +1,2 @@
+root /usr/share/nginx/html;
+try_files $uri /index.html =404;
diff --git a/.docker/scripts/proxy-coordinator.sh b/.docker/scripts/proxy-coordinator.sh
old mode 100644
new mode 100755
index 8ac711c8..dad5f7cf
--- a/.docker/scripts/proxy-coordinator.sh
+++ b/.docker/scripts/proxy-coordinator.sh
@@ -2,253 +2,106 @@
set -eu
-proxy_project=librecode-dev-proxy
-proxy_network=librecode-dev-proxy
-proxy_label=coop.librecode.dev-proxy=true
-
-compose_project() {
- docker inspect \
- --format '{{ index .Config.Labels "com.docker.compose.project" }}' \
- "$(hostname)"
-}
-
-project="$(compose_project)"
-
-if [ -z "$project" ]; then
- echo 'Could not determine the Compose project from the coordinator container.' >&2
- exit 1
-fi
-
-if [ -z "${PROJECT_DIR:-}" ]; then
- echo 'The host project directory was not provided to the coordinator.' >&2
- exit 1
-fi
-
-compose() {
- docker compose \
- --project-name "$project" \
- --project-directory "$PROJECT_DIR" \
- --file "$PROJECT_DIR/docker-compose.yml" \
- "$@"
-}
-
-proxy_compose() {
- docker compose \
- --project-name "$proxy_project" \
- --project-directory "$PROJECT_DIR" \
- --file "$PROJECT_DIR/.docker/docker-compose.proxy.yml" \
- "$@"
-}
-
-container_for_published_port() {
- port="$1"
-
- docker ps \
- --filter "publish=$port" \
- --format '{{.ID}}\t{{.Names}}\t{{.Image}}\t{{.Label "coop.librecode.dev-proxy"}}'
-}
-
-port_is_in_use() {
- container_for_published_port "$1" | grep -q .
-}
-
-compatible_proxy_container() {
- docker ps \
- --filter "label=$proxy_label" \
- --format '{{.ID}}' |
- head -n 1
-}
-
-is_compatible_proxy_port_owner() {
- port="$1"
- info="$(container_for_published_port "$port" | head -n 1)"
-
- [ -n "$info" ] || return 1
-
- compatible="$(printf '%s\n' "$info" | cut -f4)"
-
- [ "$compatible" = "true" ]
-}
-
-proxy_is_ready() {
- [ -n "$(compatible_proxy_container || true)" ] &&
- is_compatible_proxy_port_owner 80 &&
- is_compatible_proxy_port_owner 443
-}
+script_dir="$(cd -- "$(dirname -- "$0")" && pwd)"
+proxy_lib_dir="${PROXY_LIB_DIR:-$script_dir/proxy}"
+release_marker=/tmp/librecode-proxy-lease-released
+
+# shellcheck source=.docker/scripts/proxy/common.sh
+. "$proxy_lib_dir/common.sh"
+
+COORDINATOR_CONTAINER="$(hostname)"
+PROJECT_NAME="$(container_project "$COORDINATOR_CONTAINER")"
+export COORDINATOR_CONTAINER PROJECT_NAME
+
+# These modules share only exported environment and common.sh accessors.
+# Keep the source directives in sync with the runtime paths so ShellCheck can
+# analyze the complete dependency graph without file-wide suppressions.
+# shellcheck source=.docker/scripts/proxy/infrastructure.sh
+. "$proxy_lib_dir/infrastructure.sh"
+# shellcheck source=.docker/scripts/proxy/assets.sh
+. "$proxy_lib_dir/assets.sh"
+# shellcheck source=.docker/scripts/proxy/diagnostics.sh
+. "$proxy_lib_dir/diagnostics.sh"
+# shellcheck source=.docker/scripts/proxy/services.sh
+. "$proxy_lib_dir/services.sh"
+# shellcheck source=.docker/scripts/proxy/lease.sh
+. "$proxy_lib_dir/lease.sh"
+
+validate_environment() {
+ if [ -z "$PROJECT_NAME" ]; then
+ echo 'Could not determine the Compose project from the coordinator container.' >&2
+ return 1
+ fi
-show_conflict() {
- port="$1"
- container_info="$(container_for_published_port "$port" | head -n 1)"
-
- printf 'ββ β Development proxy cannot start βββββββββββββββββββββ\n' >&2
- printf 'β\n' >&2
- printf 'β Port 80 or 443 is already in use by another service.\n' >&2
- printf 'β\n' >&2
- printf 'β This development environment requires:\n' >&2
- printf 'β\n' >&2
- printf 'β HTTP localhost:80\n' >&2
- printf 'β HTTPS localhost:443\n' >&2
- printf 'β\n' >&2
- printf 'β Stop the conflicting service and run:\n' >&2
- printf 'β\n' >&2
- printf 'β docker compose up\n' >&2
- printf 'β\n' >&2
-
- if [ -n "$container_info" ]; then
- name="$(printf '%s\n' "$container_info" | cut -f2)"
- image="$(printf '%s\n' "$container_info" | cut -f3)"
-
- printf 'β Conflicting container\n' >&2
- printf 'β Name %s\n' "$name" >&2
- printf 'β Image %s\n' "$image" >&2
- printf 'β Port %s\n' "$port" >&2
- else
- printf 'β Port %s is already in use by a process outside Docker.\n' "$port" >&2
+ if [ -z "${PROJECT_DIR:-}" ]; then
+ echo 'The host project directory was not provided to the coordinator.' >&2
+ return 1
fi
- printf 'β\n' >&2
- printf 'βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ\n' >&2
+ echo "Validating Compose project ${PROJECT_NAME} at ${PROJECT_DIR}."
+ compose config --quiet
}
-ensure_ports_available() {
- for port in 80 443; do
- if port_is_in_use "$port"; then
- show_conflict "$port"
- exit 1
- fi
- done
+success() {
+ case "$1" in
+ reused)
+ echo 'β
Existing LibreCode development proxy reused. Coordinator lease is active.'
+ ;;
+ started)
+ echo 'β
Development proxy started successfully. Coordinator lease is active.'
+ ;;
+ esac
}
-start_proxy() {
- if proxy_compose up --detach; then
+release() {
+ if [ -f "$release_marker" ]; then
return 0
fi
- # Another checkout may have created the shared proxy concurrently.
- if proxy_is_ready; then
+ echo 'Releasing shared development proxy lease.'
+ if release_proxy_if_unused; then
+ touch "$release_marker"
return 0
fi
- for port in 80 443; do
- if port_is_in_use "$port"; then
- show_conflict "$port"
- exit 1
- fi
- done
-
- echo 'Could not start the LibreCode development proxy.' >&2
- exit 1
-}
-
-running_services="$(compose ps --status running --services)"
-
-service_is_running() {
- printf '%s\n' "$running_services" |
- grep -qx "$1"
+ return 1
}
-container_for_service() {
- compose ps -q "$1" 2>/dev/null || true
-}
-
-connect_to_proxy_network() {
- service="$1"
- container="$(container_for_service "$service")"
-
- [ -n "$container" ] || return 0
-
- if docker inspect \
- --format '{{ json .NetworkSettings.Networks }}' \
- "$container" |
- grep -q "\"$proxy_network\""; then
- return 0
- fi
-
- docker network connect "$proxy_network" "$container"
+shutdown() {
+ trap - INT TERM HUP
+ release || true
+ exit 0
}
-connect_running_service_to_proxy_network() {
- service="$1"
+wait_for_shutdown() {
+ trap shutdown INT TERM HUP
- service_is_running "$service" || return 0
- connect_to_proxy_network "$service"
+ while :; do
+ sleep 3600 &
+ wait "$!" || true
+ done
}
-report_environment_ready() {
- set -- \
- -e ENV_NEXTCLOUD_URL="https://${project}.localhost" \
- -e ENV_ADMIN_USER="$NEXTCLOUD_ADMIN_USER" \
- -e ENV_ADMIN_PASSWORD="$NEXTCLOUD_ADMIN_PASSWORD" \
- -e ENV_NEXTCLOUD_BRANCH="$VERSION_NEXTCLOUD"
+run() {
+ validate_environment
+ ensure_proxy_network
+ install_proxy_assets
- if service_is_running mailpit; then
- set -- "$@" \
- -e ENV_MAILPIT_URL="https://${project}-mailpit.localhost"
- fi
+ proxy_state="$(ensure_proxy_running)"
+ install_runtime_diagnostics
- if service_is_running eurooffice; then
- set -- "$@" \
- -e ENV_EUROOFFICE_URL="https://${project}-eurooffice.localhost"
- fi
+ acquire_proxy_lease
+ rm -f "$release_marker"
+ trap shutdown INT TERM HUP
- if service_is_running playwright; then
- set -- "$@" \
- -e ENV_PLAYWRIGHT_URL="https://${project}-playwright.localhost"
- fi
+ connect_project_services
- if service_is_running signal-gateway; then
- set -- "$@" \
- -e ENV_SIGNAL_URL="https://${project}-signal.localhost"
+ if ! report_environment_ready; then
+ echo 'Could not print environment banner.' >&2
fi
- compose exec -T \
- "$@" \
- nextcloud sh /var/www/scripts/report-environment-ready
-}
-
-install_proxy_assets() {
- docker run --rm \
- -v librecode-dev-proxy-conf:/conf \
- -v librecode-dev-proxy-html:/html \
- -v "$PROJECT_DIR/.docker/nginx-proxy:/source:ro" \
- alpine sh -c '
- cp /source/localhost.conf /conf/librecode-localhost.conf
- cp /source/index.html /html/index.html
- '
-}
-
-success() {
- case "$1" in
- reused)
- echo 'β
Existing LibreCode development proxy reused. Coordinator exiting normally.'
- ;;
- started)
- echo 'β
Development proxy started successfully. Coordinator exiting normally.'
- ;;
- esac
+ success "$proxy_state"
+ wait_for_shutdown
}
-echo "Validating Compose project ${project} at ${PROJECT_DIR}."
-compose config --quiet
-
-install_proxy_assets
-
-if proxy_is_ready; then
- proxy_state=reused
-else
- ensure_ports_available
- start_proxy
- proxy_state=started
-fi
-
-connect_running_service_to_proxy_network nginx
-connect_running_service_to_proxy_network mailpit
-connect_running_service_to_proxy_network eurooffice
-connect_running_service_to_proxy_network playwright
-connect_running_service_to_proxy_network signal-gateway
-
-if ! report_environment_ready; then
- echo 'Could not print environment banner.' >&2
-fi
-
-success "$proxy_state"
\ No newline at end of file
+run
diff --git a/.docker/scripts/proxy/assets.sh b/.docker/scripts/proxy/assets.sh
new file mode 100644
index 00000000..58991c5a
--- /dev/null
+++ b/.docker/scripts/proxy/assets.sh
@@ -0,0 +1,54 @@
+#!/bin/sh
+
+proxy_helper_image() {
+ if [ -n "${PROXY_HELPER_IMAGE:-}" ]; then
+ printf '%s\n' "$PROXY_HELPER_IMAGE"
+ return 0
+ fi
+
+ container_image "${COORDINATOR_CONTAINER:-}"
+}
+
+copy_to_named_volume() {
+ volume="$1"
+ source="$2"
+ destination="$3"
+ helper_image="$(proxy_helper_image)"
+
+ [ -n "$helper_image" ] || return 1
+
+ Docker run --rm -i \
+ -v "$volume:/target" \
+ "$helper_image" \
+ tee "/target/$destination" \
+ < "$source" >/dev/null
+}
+
+install_proxy_assets() {
+ assets_volume="$(proxy_assets_volume_name)"
+ vhost_volume="$(proxy_vhost_volume_name)"
+ helper_image="$(proxy_helper_image)"
+
+ [ -n "$helper_image" ] || return 1
+
+ Docker volume create "$assets_volume" >/dev/null
+ Docker volume create "$vhost_volume" >/dev/null
+
+ Docker run --rm \
+ -v "$assets_volume:/target" \
+ "$helper_image" \
+ sh -c 'rm -f /target/Procfile /target/docker-gen.cfg /target/dashboard.tmpl'
+
+ copy_to_named_volume "$assets_volume" "${PROJECT_DIR:-}/.docker/nginx-proxy/Procfile" Procfile
+ copy_to_named_volume "$assets_volume" "${PROJECT_DIR:-}/.docker/nginx-proxy/docker-gen.cfg" docker-gen.cfg
+ copy_to_named_volume "$assets_volume" "${PROJECT_DIR:-}/.docker/nginx-proxy/dashboard.tmpl" dashboard.tmpl
+
+ Docker run --rm \
+ -v "$vhost_volume:/target" \
+ "$helper_image" \
+ sh -c 'rm -f /target/librecode-localhost.conf /target/localhost /target/localhost_location_override /target/\*.localhost /target/\*.localhost_location_override'
+
+ copy_to_named_volume "$vhost_volume" "${PROJECT_DIR:-}/.docker/nginx-proxy/localhost_location_override" localhost_location_override
+ copy_to_named_volume "$vhost_volume" "${PROJECT_DIR:-}/.docker/nginx-proxy/*.localhost" '*.localhost'
+ copy_to_named_volume "$vhost_volume" "${PROJECT_DIR:-}/.docker/nginx-proxy/*.localhost_location_override" '*.localhost_location_override'
+}
diff --git a/.docker/scripts/proxy/common.sh b/.docker/scripts/proxy/common.sh
new file mode 100644
index 00000000..9ccc2667
--- /dev/null
+++ b/.docker/scripts/proxy/common.sh
@@ -0,0 +1,71 @@
+#!/bin/sh
+
+proxy_project_name() {
+ printf '%s\n' "${PROXY_PROJECT:-librecode-dev-proxy}"
+}
+
+proxy_network_name() {
+ printf '%s\n' "${PROXY_NETWORK:-librecode-dev-proxy}"
+}
+
+proxy_container_label() {
+ printf '%s\n' "${PROXY_LABEL:-coop.librecode.dev-proxy=true}"
+}
+
+proxy_client_label() {
+ printf '%s\n' "${PROXY_CLIENT_LABEL:-coop.librecode.dev-proxy-client=true}"
+}
+
+proxy_assets_volume_name() {
+ printf '%s\n' "${PROXY_ASSETS_VOLUME:-librecode-dev-proxy-assets}"
+}
+
+proxy_vhost_volume_name() {
+ printf '%s\n' "${PROXY_VHOST_VOLUME:-librecode-dev-proxy-vhost}"
+}
+
+Docker() {
+ docker "$@"
+}
+
+container_project() {
+ Docker inspect \
+ --format '{{ index .Config.Labels "com.docker.compose.project" }}' \
+ "$1" 2>/dev/null || true
+}
+
+container_image() {
+ Docker inspect \
+ --format '{{ .Config.Image }}' \
+ "$1" 2>/dev/null || true
+}
+
+container_virtual_host() {
+ Docker inspect \
+ --format '{{range .Config.Env}}{{println .}}{{end}}' \
+ "$1" 2>/dev/null |
+ sed -n 's/^VIRTUAL_HOST=//p' |
+ head -n 1
+}
+
+container_networks() {
+ Docker inspect \
+ --format '{{ json .NetworkSettings.Networks }}' \
+ "$1" 2>/dev/null || true
+}
+
+compose() {
+ Docker compose \
+ --project-name "${PROJECT_NAME:-}" \
+ --project-directory "${PROJECT_DIR:-}" \
+ --file "${PROJECT_COMPOSE_FILE:-${PROJECT_DIR:-}/docker-compose.yml}" \
+ "$@"
+}
+
+proxy_compose() {
+ Docker compose \
+ --project-name "$(proxy_project_name)" \
+ --project-directory "${PROJECT_DIR:-}" \
+ --file "${PROXY_COMPOSE_FILE:-${PROJECT_DIR:-}/.docker/docker-compose.proxy.yml}" \
+ "$@"
+}
diff --git a/.docker/scripts/proxy/diagnostics.sh b/.docker/scripts/proxy/diagnostics.sh
new file mode 100644
index 00000000..aaf79ea0
--- /dev/null
+++ b/.docker/scripts/proxy/diagnostics.sh
@@ -0,0 +1,88 @@
+#!/bin/sh
+
+runtime_version() {
+ component="$1"
+
+ case "$component" in
+ docker)
+ Docker version --format '{{.Server.Version}}'
+ ;;
+ runc)
+ Docker version --format '{{range .Server.Components}}{{if eq .Name "runc"}}{{.Version}}{{end}}{{end}}'
+ ;;
+ esac
+}
+
+version_at_most() {
+ version="${1#v}"
+ maximum="${2#v}"
+
+ version="${version%%-*}"
+ maximum="${maximum%%-*}"
+
+ IFS=. read -r version_major version_minor version_patch < /usr/share/nginx/html/runtime.json'
+}
diff --git a/.docker/scripts/proxy/infrastructure.sh b/.docker/scripts/proxy/infrastructure.sh
new file mode 100644
index 00000000..b71aa085
--- /dev/null
+++ b/.docker/scripts/proxy/infrastructure.sh
@@ -0,0 +1,117 @@
+#!/bin/sh
+
+container_for_published_port() {
+ port="$1"
+
+ Docker ps \
+ --filter "publish=$port" \
+ --format '{{.ID}}\t{{.Names}}\t{{.Image}}\t{{.Label "coop.librecode.dev-proxy"}}'
+}
+
+port_is_in_use() {
+ container_for_published_port "$1" | grep -q .
+}
+
+compatible_proxy_container() {
+ Docker ps \
+ --filter "label=$(proxy_container_label)" \
+ --format '{{.ID}}' |
+ head -n 1
+}
+
+is_compatible_proxy_port_owner() {
+ port="$1"
+ info="$(container_for_published_port "$port" | head -n 1)"
+
+ [ -n "$info" ] || return 1
+ [ "$(printf '%s\n' "$info" | cut -f4)" = "true" ]
+}
+
+proxy_is_ready() {
+ [ -n "$(compatible_proxy_container || true)" ] &&
+ is_compatible_proxy_port_owner 80 &&
+ is_compatible_proxy_port_owner 443
+}
+
+show_conflict() {
+ port="$1"
+ container_info="$(container_for_published_port "$port" | head -n 1)"
+
+ printf 'ββ β Development proxy cannot start βββββββββββββββββββββ\n' >&2
+ printf 'β\n' >&2
+ printf 'β Port 80 or 443 is already in use by another service.\n' >&2
+ printf 'β\n' >&2
+ printf 'β This development environment requires:\n' >&2
+ printf 'β\n' >&2
+ printf 'β HTTP localhost:80\n' >&2
+ printf 'β HTTPS localhost:443\n' >&2
+ printf 'β\n' >&2
+ printf 'β Stop the conflicting service and run:\n' >&2
+ printf 'β\n' >&2
+ printf 'β docker compose up\n' >&2
+ printf 'β\n' >&2
+
+ if [ -n "$container_info" ]; then
+ printf 'β Conflicting container\n' >&2
+ printf 'β Name %s\n' "$(printf '%s\n' "$container_info" | cut -f2)" >&2
+ printf 'β Image %s\n' "$(printf '%s\n' "$container_info" | cut -f3)" >&2
+ printf 'β Port %s\n' "$port" >&2
+ else
+ printf 'β Port %s is already in use by a process outside Docker.\n' "$port" >&2
+ fi
+
+ printf 'β\n' >&2
+ printf 'βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ\n' >&2
+}
+
+ensure_ports_available() {
+ for port in 80 443; do
+ if port_is_in_use "$port"; then
+ show_conflict "$port"
+ return 1
+ fi
+ done
+}
+
+ensure_proxy_network() {
+ network="$(proxy_network_name)"
+
+ if Docker network inspect "$network" >/dev/null 2>&1; then
+ return 0
+ fi
+
+ if Docker network create "$network" >/dev/null 2>&1; then
+ return 0
+ fi
+
+ # Another checkout may have created it concurrently.
+ Docker network inspect "$network" >/dev/null
+}
+
+start_proxy() {
+ if proxy_compose up --detach; then
+ return 0
+ fi
+
+ # Another checkout may have started the shared proxy concurrently.
+ if proxy_is_ready; then
+ return 0
+ fi
+
+ ensure_ports_available || return 1
+
+ echo 'Could not start the LibreCode development proxy.' >&2
+ return 1
+}
+
+ensure_proxy_running() {
+ if proxy_is_ready; then
+ proxy_compose up --detach
+ printf 'reused\n'
+ return 0
+ fi
+
+ ensure_ports_available || return 1
+ start_proxy || return 1
+ printf 'started\n'
+}
diff --git a/.docker/scripts/proxy/lease.sh b/.docker/scripts/proxy/lease.sh
new file mode 100644
index 00000000..b8358a4a
--- /dev/null
+++ b/.docker/scripts/proxy/lease.sh
@@ -0,0 +1,85 @@
+#!/bin/sh
+
+acquire_proxy_lease() {
+ network="$(proxy_network_name)"
+
+ if ! container_networks "${COORDINATOR_CONTAINER:-}" | grep -q "\"$network\""; then
+ Docker network connect "$network" "${COORDINATOR_CONTAINER:-}"
+ fi
+}
+
+release_proxy_lease() {
+ network="$(proxy_network_name)"
+
+ if ! container_networks "${COORDINATOR_CONTAINER:-}" | grep -q "\"$network\""; then
+ return 0
+ fi
+
+ if ! Docker network disconnect "$network" "${COORDINATOR_CONTAINER:-}" >/dev/null 2>&1; then
+ echo 'Could not disconnect this coordinator lease from the shared proxy network; continuing with project-based lease detection.' >&2
+ fi
+}
+
+other_proxy_client_is_running() {
+ network="$(proxy_network_name)"
+
+ for container in $(Docker ps \
+ --filter "label=$(proxy_client_label)" \
+ --filter "network=$network" \
+ --format '{{.ID}}'); do
+ [ "$(container_project "$container")" = "${PROJECT_NAME:-}" ] || return 0
+ done
+
+ return 1
+}
+
+other_proxy_route_is_running() {
+ network="$(proxy_network_name)"
+ proxy_project="$(proxy_project_name)"
+
+ for container in $(Docker ps --filter "network=$network" --format '{{.ID}}'); do
+ container_project_name="$(container_project "$container")"
+
+ case "$container_project_name" in
+ "${PROJECT_NAME:-}"|"$proxy_project")
+ continue
+ ;;
+ esac
+
+ [ -z "$(container_virtual_host "$container")" ] || return 0
+ done
+
+ return 1
+}
+
+proxy_is_used_by_another_environment() {
+ other_proxy_client_is_running || other_proxy_route_is_running
+}
+
+wait_for_concurrent_lease() {
+ sleep "${PROXY_LEASE_GRACE_SECONDS:-1}"
+}
+
+release_proxy_if_unused() {
+ release_proxy_lease
+
+ if proxy_is_used_by_another_environment; then
+ echo 'β
Shared development proxy is still used by another environment.'
+ return 0
+ fi
+
+ wait_for_concurrent_lease
+
+ if proxy_is_used_by_another_environment; then
+ echo 'β
Shared development proxy is still used by another environment.'
+ return 0
+ fi
+
+ echo 'Stopping unused shared development proxy.'
+ if ! proxy_compose down \
+ --timeout "${PROXY_STOP_TIMEOUT_SECONDS:-3}" \
+ --remove-orphans; then
+ echo 'Could not stop the unused shared development proxy.' >&2
+ return 1
+ fi
+}
diff --git a/.docker/scripts/proxy/services.sh b/.docker/scripts/proxy/services.sh
new file mode 100644
index 00000000..43d76a0b
--- /dev/null
+++ b/.docker/scripts/proxy/services.sh
@@ -0,0 +1,65 @@
+#!/bin/sh
+
+service_is_running() {
+ compose ps --status running --services |
+ grep -qx "$1"
+}
+
+container_for_service() {
+ compose ps -q "$1" 2>/dev/null || true
+}
+
+connect_to_proxy_network() {
+ service="$1"
+ container="$(container_for_service "$service")"
+ network="$(proxy_network_name)"
+
+ [ -n "$container" ] || return 0
+
+ if container_networks "$container" | grep -q "\"$network\""; then
+ return 0
+ fi
+
+ Docker network connect "$network" "$container"
+}
+
+connect_running_service_to_proxy_network() {
+ service="$1"
+
+ service_is_running "$service" || return 0
+ connect_to_proxy_network "$service"
+}
+
+connect_project_services() {
+ for service in nginx mailpit eurooffice playwright signal-gateway; do
+ connect_running_service_to_proxy_network "$service"
+ done
+}
+
+report_environment_ready() {
+ set -- \
+ -e ENV_NEXTCLOUD_URL="https://${PROJECT_NAME:-}.localhost" \
+ -e ENV_ADMIN_USER="${NEXTCLOUD_ADMIN_USER:-admin}" \
+ -e ENV_ADMIN_PASSWORD="${NEXTCLOUD_ADMIN_PASSWORD:-admin}" \
+ -e ENV_NEXTCLOUD_BRANCH="${VERSION_NEXTCLOUD:-master}"
+
+ if service_is_running mailpit; then
+ set -- "$@" -e ENV_MAILPIT_URL="https://${PROJECT_NAME:-}-mailpit.localhost"
+ fi
+
+ if service_is_running eurooffice; then
+ set -- "$@" -e ENV_EUROOFFICE_URL="https://${PROJECT_NAME:-}-eurooffice.localhost"
+ fi
+
+ if service_is_running playwright; then
+ set -- "$@" -e ENV_PLAYWRIGHT_URL="https://${PROJECT_NAME:-}-playwright.localhost"
+ fi
+
+ if service_is_running signal-gateway; then
+ set -- "$@" -e ENV_SIGNAL_URL="https://${PROJECT_NAME:-}-signal.localhost"
+ fi
+
+ compose exec -T \
+ "$@" \
+ nextcloud sh /var/www/scripts/report-environment-ready
+}
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index acfb8abc..a1174c85 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -1,11 +1,19 @@
version: 2
updates:
-
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "daily"
+
- package-ecosystem: "docker"
directory: "/.docker/"
schedule:
interval: "daily"
+
+ - package-ecosystem: "docker-compose"
+ directories:
+ - "/"
+ - "/.docker/"
+ - "/tests/proxy/fixtures/"
+ schedule:
+ interval: "daily"
diff --git a/.github/workflows/docker-nginx.yml b/.github/workflows/docker-nginx.yml
index f8a5b988..ce63f9e2 100644
--- a/.github/workflows/docker-nginx.yml
+++ b/.github/workflows/docker-nginx.yml
@@ -21,20 +21,20 @@ jobs:
contents: read
steps:
- name: Check out the repo
- uses: actions/checkout@v7
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: Set up QEMU
- uses: docker/setup-qemu-action@v4
+ uses: docker/setup-qemu-action@1f40c72289eff860ee54a304f1438e3cff362e0a # v4
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v4
+ uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
- name: Cache Docker layers
- uses: actions/cache@v6
+ uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: /tmp/.buildx-cache
key: nginx-buildx-${{ github.sha }}
restore-keys: |
nginx-buildx-
- name: Log in to GitHub Container Registry
- uses: docker/login-action@v4.6.0
+ uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -47,7 +47,7 @@ jobs:
DOCKERFILE=$(echo ".docker/Dockerfile.nginx")
echo "DOCKERFILE=$DOCKERFILE" >> $GITHUB_OUTPUT
- name: Build container image
- uses: docker/build-push-action@v7
+ uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
with:
push: false
context: .docker/
@@ -58,7 +58,7 @@ jobs:
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache-new
- name: Push container image
- uses: docker/build-push-action@v7
+ uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
if: github.ref == 'refs/heads/main'
with:
push: true
@@ -74,4 +74,3 @@ jobs:
rm -rf /tmp/.buildx-cache
mv /tmp/.buildx-cache-new /tmp/.buildx-cache
-
diff --git a/.github/workflows/docker-php.yml b/.github/workflows/docker-php.yml
index c4e7f6ce..a182be0d 100644
--- a/.github/workflows/docker-php.yml
+++ b/.github/workflows/docker-php.yml
@@ -35,21 +35,21 @@ jobs:
contents: read
steps:
- name: Check out the repo
- uses: actions/checkout@v7
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: Set up QEMU
- uses: docker/setup-qemu-action@v4
+ uses: docker/setup-qemu-action@1f40c72289eff860ee54a304f1438e3cff362e0a # v4
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v4
+ uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
- name: Cache Docker layers
id: docker-cache
- uses: actions/cache@v6
+ uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: /tmp/.buildx-cache
key: ${{ matrix.container }}-buildx-${{ github.sha }}
restore-keys: |
${{ matrix.container }}-buildx-
- name: Log in to GitHub Container Registry
- uses: docker/login-action@v4.6.0
+ uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -63,7 +63,7 @@ jobs:
DOCKERFILE=$([[ -f ".docker/$NAME/Dockerfile" ]] && echo ".docker/$NAME/Dockerfile" || echo ".docker/Dockerfile.$NAME")
echo "DOCKERFILE=$DOCKERFILE" >> $GITHUB_OUTPUT
- name: Build container image
- uses: docker/build-push-action@v7
+ uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
with:
push: false
context: .docker/
@@ -74,7 +74,7 @@ jobs:
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache-new
- name: Push container image
- uses: docker/build-push-action@v7
+ uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
if: github.ref == 'refs/heads/main'
with:
push: true
@@ -94,7 +94,7 @@ jobs:
mv /tmp/.buildx-cache-new /tmp/.buildx-cache
- name: Save Docker cache
if: steps.docker-cache.outputs.cache-hit != 'true'
- uses: actions/cache@v6
+ uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: /tmp/.buildx-cache
key: ${{ matrix.container }}-buildx-${{ github.sha }}
diff --git a/.github/workflows/proxy-tests.yml b/.github/workflows/proxy-tests.yml
new file mode 100644
index 00000000..4392e6e9
--- /dev/null
+++ b/.github/workflows/proxy-tests.yml
@@ -0,0 +1,46 @@
+name: Proxy tests
+
+on:
+ pull_request:
+ push:
+ branches:
+ - main
+
+permissions:
+ contents: read
+
+jobs:
+ unit:
+ name: Proxy unit tests
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
+ - name: Setup Bats-core
+ uses: bats-core/bats-action@77d6fb60505b4d0d1d73e48bd035b55074bbfb43 # 4.0.0
+ with:
+ support-install: false
+ assert-install: false
+ detik-install: false
+ file-install: false
+ - name: Run unit tests
+ run: bats tests/proxy/lease.bats tests/proxy/infrastructure.bats tests/proxy/services.bats tests/proxy/diagnostics.bats tests/proxy/compose-policy.bats
+
+ integration:
+ name: Proxy Docker integration tests
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
+ - name: Setup Bats-core
+ uses: bats-core/bats-action@77d6fb60505b4d0d1d73e48bd035b55074bbfb43 # 4.0.0
+ with:
+ support-install: false
+ assert-install: false
+ detik-install: false
+ file-install: false
+ - name: Validate Compose files
+ run: |
+ docker compose config --quiet
+ REPO_ROOT="$GITHUB_WORKSPACE" COMPOSE_PROJECT_NAME=proxytesta docker compose --file tests/proxy/fixtures/compose.yml config --quiet
+ - name: Run Docker integration tests
+ run: bats tests/proxy/integration.bats
diff --git a/.github/workflows/shellcheck.yml b/.github/workflows/shellcheck.yml
index 247f2184..e686b670 100644
--- a/.github/workflows/shellcheck.yml
+++ b/.github/workflows/shellcheck.yml
@@ -6,13 +6,20 @@ on:
name: 'Shellcheck'
+permissions:
+ contents: read
+
jobs:
shellcheck:
name: Shellcheck
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v7
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: Run ShellCheck
- uses: ludeeus/action-shellcheck@master
+ uses: ludeeus/action-shellcheck@00cae500b08a931fb5698e11e79bfbd38e612a38 # 2.0.0
+ env:
+ SHELLCHECK_OPTS: -x
with:
+ version: v0.11.0
scandir: .docker/
+ check_together: 'true'
diff --git a/docker-compose.yml b/docker-compose.yml
index c44c5947..b892af83 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -64,15 +64,19 @@ services:
- VIRTUAL_PORT=80
- SELF_SIGNED_HOST=${COMPOSE_PROJECT_NAME}.localhost
proxy-coordinator:
- build:
- context: .docker/
- dockerfile: Dockerfile.proxy-coordinator
+ image: docker:29.8.0-cli@sha256:eccaacfeed644c7de222ff047483568cb988dde95476fbaaf10ea2d04921bb66
+ entrypoint: ["/bin/sh", "/usr/local/lib/librecode/proxy-coordinator.sh"]
+ labels:
+ coop.librecode.dev-proxy-client: "true"
volumes:
- ${DOCKER_SOCKET:-/var/run/docker.sock}:/var/run/docker.sock
+ - ./.docker/scripts:/usr/local/lib/librecode:ro
- .:${PWD}:ro
working_dir: ${PWD}
environment:
- PROJECT_DIR=${PWD}
+ - DOCKER_SOCKET=${DOCKER_SOCKET:-/var/run/docker.sock}
+ - PROXY_IP_BIND=${PROXY_IP_BIND:-127.0.0.1}
- NEXTCLOUD_ADMIN_USER=${NEXTCLOUD_ADMIN_USER:-admin}
- NEXTCLOUD_ADMIN_PASSWORD=${NEXTCLOUD_ADMIN_PASSWORD:-admin}
- VERSION_NEXTCLOUD=${VERSION_NEXTCLOUD:-master}
@@ -80,13 +84,13 @@ services:
depends_on:
- nextcloud
mailpit:
- image: axllent/mailpit
+ image: axllent/mailpit:v1.31.1@sha256:98b916bd3c8d61f7633a52d3ea2f58d00620cb01ca57ab59edde68c347a95365
environment:
- VIRTUAL_HOST=${COMPOSE_PROJECT_NAME}-mailpit.localhost
- VIRTUAL_PORT=8025
- SELF_SIGNED_HOST=${COMPOSE_PROJECT_NAME}-mailpit.localhost
redis:
- image: redis
+ image: redis:8.8.2@sha256:37227fff5638322f4ebea25d6d0dc3ee50848604e82b81426f11507b3ec7d2cc
eurooffice:
image: ghcr.io/euro-office/documentserver:latest
profiles:
@@ -155,4 +159,4 @@ services:
- WHATSAPP_WEBHOOK_EVENTS=${WHATSAPP_WEBHOOK_EVENTS:-message,message.ack,group.participants,chat_presence}
- WHATSAPP_AUTO_REPLY="Conta de sistema. Para falar com a LibreCode, mande email para contato@libresign.coop"
volumes:
- - ./volumes/go-whatsapp-web:/usr/src/app/files
+ - ./volumes/go-whatsapp-web:/usr/src/app/files
diff --git a/docs/advanced-setup.md b/docs/advanced-setup.md
index 797e6903..5aeabd46 100644
--- a/docs/advanced-setup.md
+++ b/docs/advanced-setup.md
@@ -113,16 +113,29 @@ A specific host port can still be requested explicitly:
HTTP_PORT=9000 HTTPS_PORT=9443 docker compose up
```
-By default, published services bind to `127.0.0.1` and are accessible only from the local host. To expose them on other network interfaces, set:
+By default, the shared development proxy binds to `127.0.0.1`. To expose only the proxy on other network interfaces, set:
```bash
-IP_BIND=0.0.0.0 docker compose up
+PROXY_IP_BIND=0.0.0.0 docker compose up
```
-This can make the development services accessible to other hosts on the network, subject to the host firewall and network configuration.
+MySQL and PostgreSQL use independent bind settings and also default to `127.0.0.1`:
+
+```bash
+MYSQL_IP_BIND=0.0.0.0 docker compose up
+POSTGRES_IP_BIND=0.0.0.0 DB_HOST=pgsql docker compose up
+```
+
+Expose development services only on trusted networks and with an appropriate host firewall.
HTTP and HTTPS use automatic ranges beginning at ports `80` and `443` when no override is provided. The example above forces `host:9000 -> container:80` and `host:9443 -> container:443`.
+## Docker daemon access
+
+The proxy coordinator, reverse proxy, and certificate companion access the Docker daemon as part of the development workflow. This is an intentional trust boundary: code running through these infrastructure components can interact with the local Docker daemon.
+
+Application containers do not receive the Docker socket. Use this development environment only with repository code you trust.
+
## PHP custom settings
Custom PHP settings can be added as `.ini` files in `volumes/php/`. This directory is ignored by Git and is mounted into the PHP container without replacing the default configuration bundled in the image.
diff --git a/tests/proxy/compose-policy.bats b/tests/proxy/compose-policy.bats
new file mode 100644
index 00000000..a5d151aa
--- /dev/null
+++ b/tests/proxy/compose-policy.bats
@@ -0,0 +1,83 @@
+#!/usr/bin/env bats
+
+setup() {
+ REPO_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)"
+}
+
+@test "default third-party runtime images are pinned by tag and digest" {
+ grep -Eq '^[[:space:]]+image: docker:[0-9]+\.[0-9]+\.[0-9]+-cli@sha256:[0-9a-f]{64}$' "$REPO_ROOT/docker-compose.yml"
+ grep -Eq '^[[:space:]]+image: axllent/mailpit:v[0-9]+\.[0-9]+\.[0-9]+@sha256:[0-9a-f]{64}$' "$REPO_ROOT/docker-compose.yml"
+ grep -Eq '^[[:space:]]+image: redis:[0-9]+\.[0-9]+\.[0-9]+@sha256:[0-9a-f]{64}$' "$REPO_ROOT/docker-compose.yml"
+ grep -Eq '^[[:space:]]+image: mysql:8\.4@sha256:[0-9a-f]{64}$' "$REPO_ROOT/.docker/database-services.yml"
+ grep -Eq '^[[:space:]]+image: postgres:13-alpine@sha256:[0-9a-f]{64}$' "$REPO_ROOT/.docker/database-services.yml"
+ grep -Eq '^[[:space:]]+image: nginxproxy/nginx-proxy:[0-9]+\.[0-9]+\.[0-9]+-alpine@sha256:[0-9a-f]{64}$' "$REPO_ROOT/.docker/docker-compose.proxy.yml"
+ grep -Eq '^[[:space:]]+image: sebastienheyd/self-signed-proxy-companion:[0-9]+\.[0-9]+\.[0-9]+@sha256:[0-9a-f]{64}$' "$REPO_ROOT/.docker/docker-compose.proxy.yml"
+}
+
+@test "proxy helper reuses the coordinator image instead of duplicating its version" {
+ grep -q 'container_image "${COORDINATOR_CONTAINER:-}"' "$REPO_ROOT/.docker/scripts/proxy/assets.sh"
+ ! grep -Eq 'docker:[0-9]+\.[0-9]+\.[0-9]+-cli@sha256:' "$REPO_ROOT/.docker/scripts/proxy/assets.sh"
+}
+
+@test "proxy integration fixture images are pinned by tag and digest" {
+ grep -Eq '^[[:space:]]+image: nginx:[0-9]+\.[0-9]+\.[0-9]+-alpine@sha256:[0-9a-f]{64}$' "$REPO_ROOT/tests/proxy/fixtures/compose.yml"
+ grep -Eq '^[[:space:]]+image: docker:[0-9]+\.[0-9]+\.[0-9]+-cli@sha256:[0-9a-f]{64}$' "$REPO_ROOT/tests/proxy/fixtures/compose.yml"
+}
+
+@test "compose files do not keep shutdown workarounds from runtime debugging" {
+ for file in "$REPO_ROOT/docker-compose.yml" "$REPO_ROOT/tests/proxy/fixtures/compose.yml"; do
+ ! grep -Eq '^[[:space:]]+(stop_signal|stop_grace_period|init):' "$file"
+ done
+}
+
+@test "network bind settings are scoped per infrastructure service" {
+ proxy_compose="$REPO_ROOT/.docker/docker-compose.proxy.yml"
+ database_compose="$REPO_ROOT/.docker/database-services.yml"
+
+ grep -Fq '"${PROXY_IP_BIND:-127.0.0.1}:80:80"' "$proxy_compose"
+ grep -Fq '"${PROXY_IP_BIND:-127.0.0.1}:443:443"' "$proxy_compose"
+ grep -Fq 'host_ip: ${MYSQL_IP_BIND:-127.0.0.1}' "$database_compose"
+ grep -Fq 'host_ip: ${POSTGRES_IP_BIND:-127.0.0.1}' "$database_compose"
+ ! grep -Rq '${IP_BIND' "$proxy_compose" "$database_compose" "$REPO_ROOT/docker-compose.yml"
+
+ run env PROXY_IP_BIND=0.0.0.0 docker compose --file "$proxy_compose" config
+ [ "$status" -eq 0 ]
+ printf '%s\n' "$output" | grep -q 'host_ip: 0.0.0.0'
+
+ run env MYSQL_IP_BIND=0.0.0.0 docker compose --file "$database_compose" config
+ [ "$status" -eq 0 ]
+ printf '%s\n' "$output" | grep -B5 -A5 'target: 3306' | grep -q 'host_ip: 0.0.0.0'
+ printf '%s\n' "$output" | grep -B5 -A5 'target: 5432' | grep -q 'host_ip: 127.0.0.1'
+
+ run env POSTGRES_IP_BIND=0.0.0.0 docker compose --file "$database_compose" config
+ [ "$status" -eq 0 ]
+ printf '%s\n' "$output" | grep -B5 -A5 'target: 3306' | grep -q 'host_ip: 127.0.0.1'
+ printf '%s\n' "$output" | grep -B5 -A5 'target: 5432' | grep -q 'host_ip: 0.0.0.0'
+}
+
+@test "docker socket mounts stay limited to proxy infrastructure" {
+ main_socket_mounts="$(grep -Ec '^[[:space:]]+- .*DOCKER_SOCKET.*:/var/run/docker\.sock$' "$REPO_ROOT/docker-compose.yml")"
+ [ "$main_socket_mounts" -eq 1 ]
+
+ proxy_socket_mounts="$(grep -Ec '^[[:space:]]+- .*DOCKER_SOCKET.*:/.*docker\.sock:ro$' "$REPO_ROOT/.docker/docker-compose.proxy.yml")"
+ [ "$proxy_socket_mounts" -eq 2 ]
+}
+
+@test "dashboard does not use innerHTML for Docker metadata" {
+ ! grep -q 'innerHTML' "$REPO_ROOT/.docker/nginx-proxy/dashboard.tmpl"
+ grep -q 'textContent = currentProject' "$REPO_ROOT/.docker/nginx-proxy/dashboard.tmpl"
+ grep -q 'textContent = link.href' "$REPO_ROOT/.docker/nginx-proxy/dashboard.tmpl"
+}
+
+@test "GitHub Actions are pinned to immutable commit SHAs" {
+ while IFS= read -r workflow; do
+ while IFS= read -r uses_line; do
+ ref="${uses_line#*@}"
+ ref="${ref%% *}"
+ [[ "$ref" =~ ^[0-9a-f]{40}$ ]] || {
+ printf 'mutable action reference in %s: %s\n' "$workflow" "$uses_line" >&2
+ return 1
+ }
+ done < <(grep -E '^[[:space:]]*-?[[:space:]]*uses:[[:space:]]+[^./][^[:space:]]+@' "$workflow" || true)
+ done < <(find "$REPO_ROOT/.github/workflows" -type f \( -name '*.yml' -o -name '*.yaml' \))
+}
diff --git a/tests/proxy/diagnostics.bats b/tests/proxy/diagnostics.bats
new file mode 100644
index 00000000..0caefdfb
--- /dev/null
+++ b/tests/proxy/diagnostics.bats
@@ -0,0 +1,98 @@
+#!/usr/bin/env bats
+
+setup() {
+ REPO_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)"
+
+ # shellcheck source=.docker/scripts/proxy/common.sh
+ source "$REPO_ROOT/.docker/scripts/proxy/common.sh"
+ # shellcheck source=.docker/scripts/proxy/diagnostics.sh
+ source "$REPO_ROOT/.docker/scripts/proxy/diagnostics.sh"
+}
+
+@test "known old Docker and runc combination reports shutdown risk" {
+ run runtime_has_known_shutdown_risk 25.0.2 1.1.12
+
+ [ "$status" -eq 0 ]
+}
+
+@test "new Docker runtime does not report known shutdown risk" {
+ run runtime_has_known_shutdown_risk 29.8.0 1.5.1
+
+ [ "$status" -eq 1 ]
+}
+
+@test "new runc avoids warning even with old Docker" {
+ run runtime_has_known_shutdown_risk 25.0.2 1.5.1
+
+ [ "$status" -eq 1 ]
+}
+
+@test "repository project name suggests a shorter Compose project name" {
+ run project_name_needs_shortening_hint nextcloud-docker-development
+
+ [ "$status" -eq 0 ]
+}
+
+@test "long Compose project name suggests a shorter name" {
+ run project_name_needs_shortening_hint this-is-a-very-long-project-name
+
+ [ "$status" -eq 0 ]
+}
+
+@test "short Compose project name does not suggest a shorter name" {
+ run project_name_needs_shortening_hint dev
+
+ [ "$status" -eq 1 ]
+}
+
+@test "runtime diagnostics include host versions without warning for current runtime" {
+ runtime_version() {
+ case "$1" in
+ docker) printf '%s\n' 29.8.0 ;;
+ runc) printf '%s\n' 1.5.1 ;;
+ esac
+ }
+ PROJECT_NAME=dev
+
+ run runtime_diagnostics_json
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *'"docker":"29.8.0"'* ]]
+ [[ "$output" == *'"runc":"1.5.1"'* ]]
+ [[ "$output" == *'"project":{"name":"dev"}'* ]]
+ [[ "$output" == *'"warnings":[]'* ]]
+ [[ "$output" == *'"hints":[]'* ]]
+}
+
+@test "runtime diagnostics warn for known old runtime combination" {
+ runtime_version() {
+ case "$1" in
+ docker) printf '%s\n' 25.0.2 ;;
+ runc) printf '%s\n' 1.1.12 ;;
+ esac
+ }
+ PROJECT_NAME=dev
+
+ run runtime_diagnostics_json
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *'"code":"outdated-docker-runtime"'* ]]
+ [[ "$output" == *'Update Docker Engine'* ]]
+}
+
+@test "runtime diagnostics include project-name hint with example host" {
+ runtime_version() {
+ case "$1" in
+ docker) printf '%s\n' 29.8.0 ;;
+ runc) printf '%s\n' 1.5.1 ;;
+ esac
+ }
+ PROJECT_NAME=nextcloud-docker-development
+
+ run runtime_diagnostics_json
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *'"code":"long-project-name"'* ]]
+ [[ "$output" == *'"projectName":"nextcloud-docker-development"'* ]]
+ [[ "$output" == *'"exampleHost":"nextcloud-docker-development-playwright.localhost"'* ]]
+}
diff --git a/tests/proxy/fixtures/compose.yml b/tests/proxy/fixtures/compose.yml
new file mode 100644
index 00000000..d1304ea8
--- /dev/null
+++ b/tests/proxy/fixtures/compose.yml
@@ -0,0 +1,28 @@
+services:
+ nginx:
+ image: nginx:1.31.5-alpine@sha256:72ba65eb42c10344912a84ff42408db7d34f2feb642204570ab8fc5ffd29f1d3
+ environment:
+ - VIRTUAL_HOST=${COMPOSE_PROJECT_NAME}.localhost
+ - VIRTUAL_PORT=80
+ - SELF_SIGNED_HOST=${COMPOSE_PROJECT_NAME}.localhost
+
+ proxy-coordinator:
+ image: docker:29.8.0-cli@sha256:eccaacfeed644c7de222ff047483568cb988dde95476fbaaf10ea2d04921bb66
+ entrypoint: ["/bin/sh", "/project/.docker/scripts/proxy-coordinator.sh"]
+ labels:
+ coop.librecode.dev-proxy-client: "true"
+ volumes:
+ - /var/run/docker.sock:/var/run/docker.sock
+ - ${REPO_ROOT}:/project:ro
+ working_dir: /project
+ environment:
+ - PROJECT_DIR=/project
+ - PROJECT_COMPOSE_FILE=/project/tests/proxy/fixtures/compose.yml
+ - REPO_ROOT=/project
+ - PROXY_LEASE_GRACE_SECONDS=0
+ - NEXTCLOUD_ADMIN_USER=admin
+ - NEXTCLOUD_ADMIN_PASSWORD=admin
+ - VERSION_NEXTCLOUD=master
+ restart: "no"
+ depends_on:
+ - nginx
diff --git a/tests/proxy/infrastructure.bats b/tests/proxy/infrastructure.bats
new file mode 100644
index 00000000..65aed021
--- /dev/null
+++ b/tests/proxy/infrastructure.bats
@@ -0,0 +1,69 @@
+#!/usr/bin/env bats
+
+setup() {
+ REPO_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)"
+ TEST_LOG="$BATS_TEST_TMPDIR/proxy.log"
+ : > "$TEST_LOG"
+
+ # shellcheck source=.docker/scripts/proxy/common.sh
+ source "$REPO_ROOT/.docker/scripts/proxy/common.sh"
+ # shellcheck source=.docker/scripts/proxy/infrastructure.sh
+ source "$REPO_ROOT/.docker/scripts/proxy/infrastructure.sh"
+}
+
+@test "ready proxy is reconciled and reused" {
+ proxy_is_ready() { return 0; }
+ proxy_compose() {
+ printf 'proxy-compose %s\n' "$*" >> "$TEST_LOG"
+ }
+
+ run ensure_proxy_running
+
+ [ "$status" -eq 0 ]
+ [ "$output" = reused ]
+ grep -q '^proxy-compose up --detach$' "$TEST_LOG"
+}
+
+@test "missing proxy is started" {
+ proxy_is_ready() { return 1; }
+ ensure_ports_available() { return 0; }
+ start_proxy() {
+ printf 'start-proxy\n' >> "$TEST_LOG"
+ }
+
+ run ensure_proxy_running
+
+ [ "$status" -eq 0 ]
+ [ "$output" = started ]
+ grep -q '^start-proxy$' "$TEST_LOG"
+}
+
+@test "occupied required port prevents startup" {
+ port_is_in_use() {
+ [ "$1" = 80 ]
+ }
+ show_conflict() {
+ printf 'conflict %s\n' "$1" >> "$TEST_LOG"
+ }
+
+ run ensure_ports_available
+
+ [ "$status" -eq 1 ]
+ grep -q '^conflict 80$' "$TEST_LOG"
+}
+
+@test "existing proxy network is reused" {
+ Docker() {
+ printf 'docker %s\n' "$*" >> "$TEST_LOG"
+ case "$*" in
+ "network inspect $(proxy_network_name)") return 0 ;;
+ esac
+ return 1
+ }
+
+ run ensure_proxy_network
+
+ [ "$status" -eq 0 ]
+ grep -q "^docker network inspect $(proxy_network_name)$" "$TEST_LOG"
+ ! grep -q '^docker network create' "$TEST_LOG"
+}
diff --git a/tests/proxy/integration.bats b/tests/proxy/integration.bats
new file mode 100644
index 00000000..6791b12e
--- /dev/null
+++ b/tests/proxy/integration.bats
@@ -0,0 +1,161 @@
+#!/usr/bin/env bats
+
+setup() {
+ REPO_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)"
+ FIXTURE="$REPO_ROOT/tests/proxy/fixtures/compose.yml"
+ BODY="$BATS_TEST_TMPDIR/body.html"
+ cleanup_proxy_tests
+}
+
+teardown() {
+ cleanup_proxy_tests
+}
+
+compose_test() {
+ project="$1"
+ shift
+ COMPOSE_PROJECT_NAME="$project" REPO_ROOT="$REPO_ROOT" \
+ docker compose --project-name "$project" --file "$FIXTURE" "$@"
+}
+
+cleanup_proxy_tests() {
+ for project in proxytesta proxytestb; do
+ COMPOSE_PROJECT_NAME="$project" REPO_ROOT="$REPO_ROOT" \
+ docker compose --project-name "$project" --file "$FIXTURE" down --volumes --remove-orphans >/dev/null 2>&1 || true
+ done
+
+ docker compose \
+ --project-name librecode-dev-proxy \
+ --project-directory "$REPO_ROOT" \
+ --file "$REPO_ROOT/.docker/docker-compose.proxy.yml" \
+ down --remove-orphans >/dev/null 2>&1 || true
+
+ docker network rm librecode-dev-proxy >/dev/null 2>&1 || true
+}
+
+container_is_running() {
+ docker ps --format '{{.Names}}' | grep -qx "$1"
+}
+
+wait_for_running() {
+ name="$1"
+ for _ in $(seq 1 60); do
+ container_is_running "$name" && return 0
+ sleep 0.5
+ done
+ return 1
+}
+
+wait_for_absent() {
+ name="$1"
+ for _ in $(seq 1 60); do
+ container_is_running "$name" || return 0
+ sleep 0.5
+ done
+ return 1
+}
+
+wait_for_https_path_status() {
+ host="$1"
+ path="$2"
+ expected="$3"
+
+ for _ in $(seq 1 60); do
+ status="$(curl --silent --show-error --insecure \
+ --resolve "$host:443:127.0.0.1" \
+ --output "$BODY" \
+ --write-out '%{http_code}' \
+ "https://$host$path" 2>/dev/null || true)"
+ [ "$status" = "$expected" ] && return 0
+ sleep 0.5
+ done
+ return 1
+}
+
+wait_for_https_status() {
+ wait_for_https_path_status "$1" / "$2"
+}
+
+@test "single project starts routing and releases the shared proxy" {
+ compose_test proxytesta up --detach
+
+ wait_for_running librecode-dev-proxy
+ wait_for_running librecode-dev-proxy-ssl-companion
+
+ http_host_ip="$(docker inspect --format '{{(index (index .NetworkSettings.Ports "80/tcp") 0).HostIp}}' librecode-dev-proxy)"
+ https_host_ip="$(docker inspect --format '{{(index (index .NetworkSettings.Ports "443/tcp") 0).HostIp}}' librecode-dev-proxy)"
+ [ "$http_host_ip" = "127.0.0.1" ]
+ [ "$https_host_ip" = "127.0.0.1" ]
+
+ wait_for_https_status localhost 200
+ grep -q 'LibreCode Nextcloud Development Environment' "$BODY"
+ grep -q 'Environment checks' "$BODY"
+ grep -q 'Help improve this development environment' "$BODY"
+ grep -q 'Contribute on GitHub' "$BODY"
+ grep -q 'Report an issue' "$BODY"
+ grep -q 'Star on GitHub' "$BODY"
+
+ wait_for_https_path_status localhost /runtime.json 200
+ grep -q '"docker":"' "$BODY"
+ grep -q '"runc":"' "$BODY"
+
+ wait_for_https_status proxytesta.localhost 200
+ grep -q 'Welcome to nginx' "$BODY"
+
+ wait_for_https_status something-wrong.localhost 404
+ grep -q 'Environment not found' "$BODY"
+
+ wait_for_https_path_status something-wrong.localhost /runtime.json 404
+ ! grep -q '"docker":"' "$BODY"
+ ! grep -q '"runc":"' "$BODY"
+
+ compose_test proxytesta stop
+
+ wait_for_absent librecode-dev-proxy
+ wait_for_absent librecode-dev-proxy-ssl-companion
+}
+
+@test "Ctrl+C on attached compose stops the last shared proxy promptly" {
+ log="$BATS_TEST_TMPDIR/compose-up.log"
+
+ COMPOSE_PROJECT_NAME=proxytesta REPO_ROOT="$REPO_ROOT" \
+ docker compose \
+ --project-name proxytesta \
+ --file "$FIXTURE" \
+ up >"$log" 2>&1 &
+ compose_pid=$!
+
+ wait_for_running librecode-dev-proxy
+ wait_for_running librecode-dev-proxy-ssl-companion
+ wait_for_https_status proxytesta.localhost 200
+
+ started_at="$(date +%s)"
+ kill -INT "$compose_pid"
+ wait "$compose_pid" || true
+ finished_at="$(date +%s)"
+
+ wait_for_absent librecode-dev-proxy
+ wait_for_absent librecode-dev-proxy-ssl-companion
+
+ elapsed=$((finished_at - started_at))
+ [ "$elapsed" -lt 10 ]
+}
+
+@test "shared proxy stays alive until the last project stops" {
+ compose_test proxytesta up --detach
+ compose_test proxytestb up --detach
+
+ wait_for_running librecode-dev-proxy
+ wait_for_https_status proxytesta.localhost 200
+ wait_for_https_status proxytestb.localhost 200
+
+ compose_test proxytesta stop
+
+ wait_for_running librecode-dev-proxy
+ wait_for_https_status proxytestb.localhost 200
+
+ compose_test proxytestb stop
+
+ wait_for_absent librecode-dev-proxy
+ wait_for_absent librecode-dev-proxy-ssl-companion
+}
diff --git a/tests/proxy/lease.bats b/tests/proxy/lease.bats
new file mode 100644
index 00000000..8efc6f51
--- /dev/null
+++ b/tests/proxy/lease.bats
@@ -0,0 +1,135 @@
+#!/usr/bin/env bats
+
+setup() {
+ REPO_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)"
+ TEST_LOG="$BATS_TEST_TMPDIR/docker.log"
+ : > "$TEST_LOG"
+
+ # shellcheck source=.docker/scripts/proxy/common.sh
+ source "$REPO_ROOT/.docker/scripts/proxy/common.sh"
+ # shellcheck source=.docker/scripts/proxy/lease.sh
+ source "$REPO_ROOT/.docker/scripts/proxy/lease.sh"
+
+ PROJECT_NAME=current
+ COORDINATOR_CONTAINER=current-coordinator
+ PROXY_LEASE_GRACE_SECONDS=0
+}
+
+@test "current project coordinator is not another proxy client" {
+ Docker() {
+ printf '%s\n' current-coordinator
+ }
+ container_project() {
+ printf '%s\n' current
+ }
+
+ run other_proxy_client_is_running
+
+ [ "$status" -eq 1 ]
+}
+
+@test "coordinator from another project keeps the proxy leased" {
+ Docker() {
+ printf '%s\n' current-coordinator other-coordinator
+ }
+ container_project() {
+ case "$1" in
+ current-coordinator) printf '%s\n' current ;;
+ other-coordinator) printf '%s\n' other ;;
+ esac
+ }
+
+ run other_proxy_client_is_running
+
+ [ "$status" -eq 0 ]
+}
+
+@test "route from another project keeps the proxy leased" {
+ Docker() {
+ printf '%s\n' proxy current-route other-route
+ }
+ container_project() {
+ case "$1" in
+ proxy) proxy_project_name ;;
+ current-route) printf '%s\n' current ;;
+ other-route) printf '%s\n' other ;;
+ esac
+ }
+ container_virtual_host() {
+ [ "$1" = other-route ] && printf '%s\n' other.localhost
+ }
+
+ run other_proxy_route_is_running
+
+ [ "$status" -eq 0 ]
+}
+
+@test "last lease stops the shared proxy with bounded timeout" {
+ PROXY_STOP_TIMEOUT_SECONDS=7
+ container_networks() {
+ printf '{"%s":{}}\n' "$(proxy_network_name)"
+ }
+ proxy_is_used_by_another_environment() {
+ return 1
+ }
+ Docker() {
+ printf 'docker %s\n' "$*" >> "$TEST_LOG"
+ }
+ proxy_compose() {
+ printf 'proxy-compose %s\n' "$*" >> "$TEST_LOG"
+ }
+
+ run release_proxy_if_unused
+
+ [ "$status" -eq 0 ]
+ grep -q "^docker network disconnect $(proxy_network_name) $COORDINATOR_CONTAINER$" "$TEST_LOG"
+ grep -q '^proxy-compose down --timeout 7 --remove-orphans$' "$TEST_LOG"
+}
+
+@test "another lease prevents proxy shutdown" {
+ container_networks() {
+ printf '{"%s":{}}\n' "$(proxy_network_name)"
+ }
+ proxy_is_used_by_another_environment() {
+ return 0
+ }
+ Docker() {
+ printf 'docker %s\n' "$*" >> "$TEST_LOG"
+ }
+ proxy_compose() {
+ printf 'proxy-compose %s\n' "$*" >> "$TEST_LOG"
+ }
+
+ run release_proxy_if_unused
+
+ [ "$status" -eq 0 ]
+ grep -q "^docker network disconnect $(proxy_network_name) $COORDINATOR_CONTAINER$" "$TEST_LOG"
+ ! grep -q '^proxy-compose down' "$TEST_LOG"
+}
+
+@test "release is safe when compose already disconnected the coordinator" {
+ container_networks() {
+ printf '{}\n'
+ }
+ Docker() {
+ printf 'docker %s\n' "$*" >> "$TEST_LOG"
+ }
+
+ run release_proxy_lease
+
+ [ "$status" -eq 0 ]
+ ! grep -q '^docker network disconnect' "$TEST_LOG"
+}
+
+@test "acquiring a lease connects the coordinator only when needed" {
+ container_networks() {
+ printf '{}\n'
+ }
+ Docker() {
+ printf 'docker %s\n' "$*" >> "$TEST_LOG"
+ }
+
+ acquire_proxy_lease
+
+ grep -q "^docker network connect $(proxy_network_name) $COORDINATOR_CONTAINER$" "$TEST_LOG"
+}
diff --git a/tests/proxy/services.bats b/tests/proxy/services.bats
new file mode 100644
index 00000000..74f5655f
--- /dev/null
+++ b/tests/proxy/services.bats
@@ -0,0 +1,51 @@
+#!/usr/bin/env bats
+
+setup() {
+ REPO_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)"
+ TEST_LOG="$BATS_TEST_TMPDIR/services.log"
+ : > "$TEST_LOG"
+
+ # shellcheck source=.docker/scripts/proxy/common.sh
+ source "$REPO_ROOT/.docker/scripts/proxy/common.sh"
+ # shellcheck source=.docker/scripts/proxy/services.sh
+ source "$REPO_ROOT/.docker/scripts/proxy/services.sh"
+
+ PROJECT_NAME=current
+}
+
+@test "running service is connected to proxy network" {
+ service_is_running() { return 0; }
+ container_for_service() { printf '%s\n' service-container; }
+ container_networks() { printf '{}\n'; }
+ Docker() {
+ printf 'docker %s\n' "$*" >> "$TEST_LOG"
+ }
+
+ connect_running_service_to_proxy_network nginx
+
+ grep -q "^docker network connect $(proxy_network_name) service-container$" "$TEST_LOG"
+}
+
+@test "service already on proxy network is not connected twice" {
+ service_is_running() { return 0; }
+ container_for_service() { printf '%s\n' service-container; }
+ container_networks() { printf '{\"%s\":{}}\n' "$(proxy_network_name)"; }
+ Docker() {
+ printf 'docker %s\n' "$*" >> "$TEST_LOG"
+ }
+
+ connect_running_service_to_proxy_network nginx
+
+ ! grep -q '^docker network connect' "$TEST_LOG"
+}
+
+@test "stopped service is ignored" {
+ service_is_running() { return 1; }
+ Docker() {
+ printf 'docker %s\n' "$*" >> "$TEST_LOG"
+ }
+
+ connect_running_service_to_proxy_network nginx
+
+ [ ! -s "$TEST_LOG" ]
+}