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

+ + +

Active environments

+
+ + + +

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" ] +}