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..57df7502 --- /dev/null +++ b/.docker/nginx-proxy/dashboard.tmpl @@ -0,0 +1,258 @@ +{{- $routes := list -}} +{{- $proxyNetworks := dict -}} +{{- range $container := . -}} + {{- if eq (index $container.Labels "coop.librecode.dev-proxy") "true" -}} + {{- range $network := $container.Networks -}} + {{- $_ := set $proxyNetworks $network.Name true -}} + {{- end -}} + {{- end -}} +{{- end -}} +{{- 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 -}} + {{- $reachableFromProxy := false -}} + {{- range $network := $container.Networks -}} + {{- if index $proxyNetworks $network.Name -}} + {{- $reachableFromProxy = true -}} + {{- end -}} + {{- end -}} + {{- if not $reachableFromProxy -}} + {{- 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..72f16a01 --- a/.docker/scripts/proxy-coordinator.sh +++ b/.docker/scripts/proxy-coordinator.sh @@ -2,253 +2,107 @@ 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.' + disconnect_proxy_from_project_network + 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_proxy_to_project_network - 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..3c3b8fbe --- /dev/null +++ b/.docker/scripts/proxy/lease.sh @@ -0,0 +1,66 @@ +#!/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 +} + +proxy_is_used_by_another_environment() { + other_proxy_client_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..a2494b88 --- /dev/null +++ b/.docker/scripts/proxy/services.sh @@ -0,0 +1,66 @@ +#!/bin/sh + +service_is_running() { + compose ps --status running --services | + grep -qx "$1" +} + +project_network_name() { + printf '%s_default\n' "${PROJECT_NAME:-}" +} + +connect_proxy_to_project_network() { + proxy_container="$(compatible_proxy_container)" + network="$(project_network_name)" + + [ -n "$proxy_container" ] || return 1 + [ -n "${PROJECT_NAME:-}" ] || return 1 + + if container_networks "$proxy_container" | grep -q "\\\"$network\\\""; then + return 0 + fi + + Docker network connect "$network" "$proxy_container" +} + +disconnect_proxy_from_project_network() { + proxy_container="$(compatible_proxy_container)" + network="$(project_network_name)" + + [ -n "$proxy_container" ] || return 0 + [ -n "${PROJECT_NAME:-}" ] || return 0 + + if ! container_networks "$proxy_container" | grep -q "\\\"$network\\\""; then + return 0 + fi + + Docker network disconnect "$network" "$proxy_container" >/dev/null 2>&1 || true +} + +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/.docker/scripts/report-environment-ready b/.docker/scripts/report-environment-ready index 3ee29bca..c437affe 100644 --- a/.docker/scripts/report-environment-ready +++ b/.docker/scripts/report-environment-ready @@ -33,8 +33,8 @@ emit_banner() { [ -z "${ENV_ADMIN_USER:-}" ] || printf 'β”‚ %-16s %s\n' 'Admin user' "$ENV_ADMIN_USER" [ -z "${ENV_ADMIN_PASSWORD:-}" ] || printf 'β”‚ %-16s %s\n' 'Admin password' "$ENV_ADMIN_PASSWORD" printf 'β”‚\n' - printf 'β”‚ πŸ” HTTPS uses the LibreCode local development CA.\n' - printf 'β”‚ Trust it once if your browser shows a warning.\n' + printf 'β”‚ πŸ” HTTPS uses a local self-signed development certificate.\n' + printf 'β”‚ Your browser may show a warning until you trust the local certificate.\n' printf 'β”‚\n' printf '└──────────────────────────────────────────────────\n' } > /proc/1/fd/2 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/README.md b/README.md index 7ff52902..21d20d30 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Start the basic environment with MySQL: docker compose up ``` -For PostgreSQL, optional services, dynamic host ports, multiple environments, +For PostgreSQL, optional services, multiple environments, proxy settings, and other advanced configuration, see the [advanced setup guide](docs/advanced-setup.md). 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..677935f5 100644 --- a/docs/advanced-setup.md +++ b/docs/advanced-setup.md @@ -61,9 +61,7 @@ docker compose \ ## Multiple environments -Multiple checkouts can run at the same time. - -Docker uses the standard host ports when they are available. If a port is already in use, another available port is selected automatically. +Multiple checkouts can run at the same time. Each checkout keeps its own Compose network, while the shared development proxy connects to the active project networks. Start each environment from its own directory: @@ -77,51 +75,41 @@ cd /path/to/second-checkout docker compose up ``` -To see the ports assigned to an environment: - -```bash -docker compose ps -``` - -To check a specific port, use: - -```bash -docker compose port nginx 80 -``` - -For example, if it returns: +The Compose project name is used to build the local hostnames. With project names `first-checkout` and `second-checkout`, Nextcloud is available at: ```text -127.0.0.1:81 +https://first-checkout.localhost +https://second-checkout.localhost ``` -access Nextcloud at: - -```text -http://localhost:81 -``` +Open `https://localhost` to see the active environments and their available service URLs. -When port `80` is available, Nextcloud remains accessible normally at: +If the directory name creates a long local hostname, use a shorter Compose project name without renaming the directory: -```text -http://localhost +```bash +COMPOSE_PROJECT_NAME=dev docker compose up ``` -A specific host port can still be requested explicitly: +The shared proxy owns host ports `80` and `443` and binds to `127.0.0.1` by default. To expose only the proxy on other network interfaces, set: ```bash -HTTP_PORT=9000 HTTPS_PORT=9443 docker compose up +PROXY_IP_BIND=0.0.0.0 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: +MySQL and PostgreSQL use independent bind settings and also default to `127.0.0.1`: ```bash -IP_BIND=0.0.0.0 docker compose up +MYSQL_IP_BIND=0.0.0.0 docker compose up +POSTGRES_IP_BIND=0.0.0.0 DB_HOST=pgsql docker compose up ``` -This can make the development services accessible to other hosts on the network, subject to the host firewall and network configuration. +Expose development services only on trusted networks and with an appropriate host firewall. + +## 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. -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`. +Application containers do not receive the Docker socket. Use this development environment only with repository code you trust. ## PHP custom settings 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..65e8665f --- /dev/null +++ b/tests/proxy/integration.bats @@ -0,0 +1,187 @@ +#!/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_network() { + container="$1" + network="$2" + + for _ in $(seq 1 60); do + networks="$(docker inspect --format '{{json .NetworkSettings.Networks}}' "$container" 2>/dev/null || true)" + [[ "$networks" == *"\"$network\""* ]] && 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 \ + --connect-timeout 2 \ + --max-time 5 \ + --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" +} + +certificate_matches_host() { + host="$1" + openssl s_client -connect 127.0.0.1:443 -servername "$host" /dev/null | + openssl x509 -noout -checkhost "$host" >/dev/null +} + +@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 + + wait_for_network librecode-dev-proxy proxytesta_default + backend_networks="$(docker inspect --format '{{json .NetworkSettings.Networks}}' proxytesta-nginx-1)" + [[ "$backend_networks" != *'"librecode-dev-proxy"'* ]] + + 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 + certificate_matches_host proxytesta.localhost + 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..5a121c98 --- /dev/null +++ b/tests/proxy/lease.bats @@ -0,0 +1,115 @@ +#!/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 "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..1530630c --- /dev/null +++ b/tests/proxy/services.bats @@ -0,0 +1,50 @@ +#!/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 "proxy is connected to the current Compose project network" { + compatible_proxy_container() { printf '%s\n' librecode-dev-proxy; } + container_networks() { printf '{}\n'; } + Docker() { + printf 'docker %s\n' "$*" >> "$TEST_LOG" + } + + connect_proxy_to_project_network + + grep -q '^docker network connect current_default librecode-dev-proxy$' "$TEST_LOG" +} + +@test "proxy is not connected twice to the project network" { + compatible_proxy_container() { printf '%s\n' librecode-dev-proxy; } + container_networks() { printf '{"current_default":{}}\n'; } + Docker() { + printf 'docker %s\n' "$*" >> "$TEST_LOG" + } + + connect_proxy_to_project_network + + ! grep -q '^docker network connect' "$TEST_LOG" +} + +@test "project network is disconnected from proxy during release" { + compatible_proxy_container() { printf '%s\n' librecode-dev-proxy; } + container_networks() { printf '{"current_default":{}}\n'; } + Docker() { + printf 'docker %s\n' "$*" >> "$TEST_LOG" + } + + disconnect_proxy_from_project_network + + grep -q '^docker network disconnect current_default librecode-dev-proxy$' "$TEST_LOG" +}