diff --git a/.github/workflows/build-images.yml b/.github/workflows/build-images.yml new file mode 100644 index 0000000..e9aeec4 --- /dev/null +++ b/.github/workflows/build-images.yml @@ -0,0 +1,176 @@ +name: Build and push images + +on: + push: + branches: + - main + paths: + - '*/*/Dockerfile' + - '*/*/variants.yaml' + - '*/*/rootfs/**' + - '*/*/.scripts/**' + - '*/*/otel/**' + - '*/latest' + workflow_dispatch: + inputs: + target: + description: 'Image/version to build (e.g. hyperf/8.3 or hyperf/latest).' + required: true + +permissions: + contents: read + +jobs: + detect: + runs-on: ubuntu-latest + outputs: + builds: ${{ steps.list.outputs.builds }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Get changed files + id: changed + if: github.event_name == 'push' + uses: tj-actions/changed-files@v44 + + - name: Build matrix + id: list + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + INPUT_TARGET: ${{ inputs.target }} + CHANGED_FILES: ${{ steps.changed.outputs.all_changed_files }} + run: | + set -euo pipefail + + declare -a versions=() + + collect_version() { + local v="$1" + [ -z "$v" ] && return + case "$v" in + [A-Za-z0-9_./-]*) ;; + *) echo "::warning::ignoring suspicious path '$v'"; return ;; + esac + local depth + depth=$(awk -F/ '{print NF}' <<< "$v") + if [ "$depth" != "2" ]; then return; fi + local resolved="$v" + if [ -L "$v" ]; then + local link_target + link_target=$(readlink "$v") + resolved="${v%/*}/${link_target}" + fi + if [ ! -f "${resolved}/Dockerfile" ]; then return; fi + for existing in "${versions[@]+"${versions[@]}"}"; do + if [ "$existing" = "$v" ]; then return; fi + done + versions+=("$v") + } + + if [ "$EVENT_NAME" = "workflow_dispatch" ]; then + collect_version "$INPUT_TARGET" + else + declare -a changed_dirs=() + for f in $CHANGED_FILES; do + dir=$(awk -F/ 'NF>=2 {print $1"/"$2}' <<< "$f") + [ -z "$dir" ] && continue + for existing in "${changed_dirs[@]+"${changed_dirs[@]}"}"; do + if [ "$existing" = "$dir" ]; then dir=""; break; fi + done + [ -n "$dir" ] && changed_dirs+=("$dir") + done + + for v in "${changed_dirs[@]+"${changed_dirs[@]}"}"; do + collect_version "$v" + done + + for symlink in */latest; do + [ -L "$symlink" ] || continue + target=$(readlink "$symlink") + parent="${symlink%/*}" + resolved="${parent}/${target}" + for changed in "${changed_dirs[@]+"${changed_dirs[@]}"}"; do + if [ "$changed" = "$resolved" ]; then + collect_version "$symlink" + break + fi + done + done + fi + + declare -a builds=() + for v in "${versions[@]+"${versions[@]}"}"; do + image="${v%%/*}" + version="${v##*/}" + ctx="$v" + if [ -L "$ctx" ]; then + link_target=$(readlink "$ctx") + ctx="${ctx%/*}/${link_target}" + fi + + manifest="${ctx}/variants.yaml" + if [ -f "$manifest" ]; then + if ! yq eval 'all_c(.target != null and .target != "" and has("suffix"))' "$manifest" | grep -qx true; then + echo "::error file=${manifest}::each entry must declare 'target' (non-empty) and 'suffix' (key required, value may be empty)" + exit 1 + fi + entries=$(yq eval -o=json "$manifest") + else + entries='[{"target":"'"$image"'","suffix":""}]' + fi + + while IFS= read -r line; do + [ -z "$line" ] && continue + builds+=("$line") + done < <(jq -c --arg img "$image" --arg ver "$version" --arg ctx "$ctx" ' + .[] | { + image: $img, + tag: ($ver + (if (.suffix // "") == "" then "" else "-" + .suffix end)), + context: $ctx, + target: .target, + build_args: ((.args // {}) | to_entries | map(.key + "=" + (.value | tostring)) | join("\n")) + } + ' <<< "$entries") + done + + if [ ${#builds[@]} -eq 0 ]; then + echo "builds=[]" >> "$GITHUB_OUTPUT" + else + joined=$(IFS=,; echo "${builds[*]}") + echo "builds=[${joined}]" >> "$GITHUB_OUTPUT" + fi + + build: + needs: detect + if: needs.detect.outputs.builds != '[]' + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + build: ${{ fromJson(needs.detect.outputs.builds) }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: ./${{ matrix.build.context }} + target: ${{ matrix.build.target }} + build-args: ${{ matrix.build.build_args }} + platforms: linux/amd64 + push: true + tags: devitools/${{ matrix.build.image }}:${{ matrix.build.tag }} + cache-from: type=gha,scope=${{ matrix.build.image }}-${{ matrix.build.tag }} + cache-to: type=gha,mode=max,scope=${{ matrix.build.image }}-${{ matrix.build.tag }} diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..da6b786 --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,102 @@ +name: Validate variants.yaml + +on: + pull_request: + branches: + - main + +permissions: + contents: read + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Get changed variants.yaml files + id: changed + uses: tj-actions/changed-files@v44 + with: + files: '*/*/variants.yaml' + + - name: Validate each manifest + if: steps.changed.outputs.any_changed == 'true' + shell: bash + env: + CHANGED_MANIFESTS: ${{ steps.changed.outputs.all_changed_files }} + run: | + set -uo pipefail + errors=0 + + for manifest in $CHANGED_MANIFESTS; do + case "$manifest" in + [A-Za-z0-9_./-]*) ;; + *) echo "::warning::skipping suspicious path '$manifest'"; continue ;; + esac + echo "::group::${manifest}" + dir="$(dirname "$manifest")" + dockerfile="${dir}/Dockerfile" + + if ! yq eval '.' "$manifest" > /dev/null 2>&1; then + echo "::error file=${manifest}::invalid YAML syntax" + errors=$((errors+1)) + echo "::endgroup::" + continue + fi + + kind=$(yq eval 'type' "$manifest") + if [ "$kind" != "!!seq" ]; then + echo "::error file=${manifest}::root must be a YAML list (got ${kind})" + errors=$((errors+1)) + echo "::endgroup::" + continue + fi + + entries=$(yq eval -o=json "$manifest") + + bad=$(jq -c '[.[] | select((.target | type) != "string" or .target == "" or (has("suffix") | not))]' <<< "$entries") + if [ "$(jq 'length' <<< "$bad")" != "0" ]; then + echo "::error file=${manifest}::entries missing required 'target' (non-empty string) or 'suffix' key:" + jq -r '.[] | " - " + (. | tostring)' <<< "$bad" + errors=$((errors+1)) + fi + + bad_args=$(jq -c '[.[] | select(has("args") and (.args | type) != "object")]' <<< "$entries") + if [ "$(jq 'length' <<< "$bad_args")" != "0" ]; then + echo "::error file=${manifest}::'args' must be a mapping when present:" + jq -r '.[] | " - " + (. | tostring)' <<< "$bad_args" + errors=$((errors+1)) + fi + + if [ ! -f "$dockerfile" ]; then + echo "::error file=${manifest}::sibling Dockerfile not found at ${dockerfile}" + errors=$((errors+1)) + else + for target in $(jq -r '.[].target' <<< "$entries" | sort -u); do + if ! grep -qE "^FROM[[:space:]]+.*[[:space:]]+AS[[:space:]]+${target}([[:space:]]|$)" "$dockerfile"; then + echo "::error file=${manifest}::target '${target}' is not declared as a stage in ${dockerfile} (expected: FROM AS ${target})" + errors=$((errors+1)) + fi + done + fi + + duplicates=$(jq -r ' + [.[] | (if (.suffix // "") == "" then "" else .suffix end)] | + group_by(.) | map(select(length > 1)) | map(.[0]) | .[] + ' <<< "$entries" | sort -u) + if [ -n "$duplicates" ]; then + echo "::error file=${manifest}::duplicate suffix(es) detected (would produce colliding tags):" + echo "$duplicates" | sed 's/^/ - /' + errors=$((errors+1)) + fi + + echo "::endgroup::" + done + + if [ $errors -gt 0 ]; then + echo "Found ${errors} error(s) across the changed manifests." + exit 1 + fi + echo "All changed variants.yaml are valid." diff --git a/hyperf/.scripts/setup-dev.sh b/hyperf/8.3/.scripts/setup-dev.sh similarity index 100% rename from hyperf/.scripts/setup-dev.sh rename to hyperf/8.3/.scripts/setup-dev.sh diff --git a/hyperf/.scripts/setup.sh b/hyperf/8.3/.scripts/setup.sh similarity index 100% rename from hyperf/.scripts/setup.sh rename to hyperf/8.3/.scripts/setup.sh diff --git a/hyperf/Dockerfile b/hyperf/8.3/Dockerfile similarity index 59% rename from hyperf/Dockerfile rename to hyperf/8.3/Dockerfile index f9011c2..9626fdb 100644 --- a/hyperf/Dockerfile +++ b/hyperf/8.3/Dockerfile @@ -1,4 +1,4 @@ -FROM alpine:3.20 +FROM alpine:3.20 AS hyperf ARG CONTEXT ARG TIMEZONE @@ -57,18 +57,39 @@ COPY --from=composer/composer:2.8.5-bin /composer /usr/local/bin/composer COPY .scripts /devitools/.scripts -# update RUN set -ex \ - # ---------- apply settings -------\ && bash /devitools/.scripts/setup.sh "$TIMEZONE" \ && bash /devitools/.scripts/setup-dev.sh "$APP_TARGET" \ - # ---------- clear works ----------\ && rm -rf /var/cache/apk/* /tmp/* /usr/share/man WORKDIR /opt/www - EXPOSE 9501 +ENTRYPOINT ["php", "/opt/www/bin/hyperf.php"] +CMD ["start"] + +# --- Variant: with OTEL Collector + pgbouncer + supervisor --- +FROM hyperf AS hyperf-otel + +ARG COLLECTOR=debug +ARG OTEL_COLLECTOR_VERSION=0.121.0 + +RUN apk add --no-cache --upgrade expat \ + && apk add --no-cache supervisor wget pgbouncer \ + && OTEL_BASE_URL="https://github.com/open-telemetry/opentelemetry-collector-releases/releases/download/v${OTEL_COLLECTOR_VERSION}" \ + && OTEL_ASSET="otelcol-contrib_${OTEL_COLLECTOR_VERSION}_linux_amd64.tar.gz" \ + && cd /tmp \ + && wget -q "${OTEL_BASE_URL}/${OTEL_ASSET}" -O "${OTEL_ASSET}" \ + && wget -q "${OTEL_BASE_URL}/opentelemetry-collector-releases_otelcol-contrib_checksums.txt" -O checksums.txt \ + && grep " ${OTEL_ASSET}$" checksums.txt | sha256sum -c - \ + && tar -xzf "${OTEL_ASSET}" -C /usr/local/bin otelcol-contrib \ + && rm "${OTEL_ASSET}" checksums.txt \ + && chmod +x /usr/local/bin/otelcol-contrib \ + && mkdir -p /etc/pgbouncer /var/log/pgbouncer /var/run/pgbouncer /etc/supervisor.d /var/run/supervisor -ENTRYPOINT [ "php", "/opt/www/bin/hyperf.php" ] +COPY otel/collectors/${COLLECTOR}.yaml /etc/otel-collector-config.yaml +COPY otel/supervisord.conf /etc/supervisord.conf +COPY otel/entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh -CMD [ "start" ] +ENTRYPOINT ["/entrypoint.sh"] +CMD [] diff --git a/hyperf/README-pt-BR.md b/hyperf/8.3/README-pt-BR.md similarity index 100% rename from hyperf/README-pt-BR.md rename to hyperf/8.3/README-pt-BR.md diff --git a/hyperf/README.md b/hyperf/8.3/README.md similarity index 100% rename from hyperf/README.md rename to hyperf/8.3/README.md diff --git a/hyperf/8.3/otel/collectors/debug.yaml b/hyperf/8.3/otel/collectors/debug.yaml new file mode 100644 index 0000000..4dc8718 --- /dev/null +++ b/hyperf/8.3/otel/collectors/debug.yaml @@ -0,0 +1,22 @@ +receivers: + zipkin: + endpoint: "0.0.0.0:9411" + +processors: + batch: + send_batch_size: 200 + timeout: 5s + memory_limiter: + check_interval: 1s + limit_mib: 256 + +exporters: + debug: + verbosity: normal + +service: + pipelines: + traces: + receivers: [zipkin] + processors: [memory_limiter, batch] + exporters: [debug] diff --git a/hyperf/8.3/otel/collectors/google.yaml b/hyperf/8.3/otel/collectors/google.yaml new file mode 100644 index 0000000..663fae1 --- /dev/null +++ b/hyperf/8.3/otel/collectors/google.yaml @@ -0,0 +1,22 @@ +receivers: + zipkin: + endpoint: "0.0.0.0:9411" + +processors: + batch: + send_batch_size: 200 + timeout: 5s + memory_limiter: + check_interval: 1s + limit_mib: 256 + +exporters: + googlecloud: + project: ${GOOGLE_CLOUD_PROJECT} + +service: + pipelines: + traces: + receivers: [zipkin] + processors: [memory_limiter, batch] + exporters: [googlecloud] diff --git a/hyperf/8.3/otel/entrypoint.sh b/hyperf/8.3/otel/entrypoint.sh new file mode 100755 index 0000000..57d6b3c --- /dev/null +++ b/hyperf/8.3/otel/entrypoint.sh @@ -0,0 +1,112 @@ +#!/bin/sh +set -e + +if [ "$PGBOUNCER_ENABLED" != "true" ]; then + exec supervisord -c /etc/supervisord.conf +fi + +PGBOUNCER_DATABASES="${PGBOUNCER_DATABASES:-default}" + +DEFAULT_POOL_SIZE="${PGBOUNCER_DEFAULT_POOL_SIZE:-5}" +MIN_POOL_SIZE="${PGBOUNCER_MIN_POOL_SIZE:-1}" +RESERVE_POOL_SIZE="${PGBOUNCER_RESERVE_POOL_SIZE:-2}" +MAX_CLIENT_CONN="${PGBOUNCER_MAX_CLIENT_CONN:-1000}" +MAX_PREPARED_STATEMENTS="${PGBOUNCER_MAX_PREPARED_STATEMENTS:-100}" +SERVER_IDLE_TIMEOUT="${PGBOUNCER_SERVER_IDLE_TIMEOUT:-300}" +SERVER_LIFETIME="${PGBOUNCER_SERVER_LIFETIME:-3600}" + +validate_alias() { + case "$1" in + *[!A-Za-z0-9_]*|"") + echo "entrypoint: invalid PGBOUNCER_DATABASES alias '$1' (allowed: [A-Za-z0-9_])" >&2 + exit 1 + ;; + esac +} + +resolve_prefix() { + if [ "$1" = "default" ]; then + echo "POSTGRES_DB" + else + echo "POSTGRES_DB_$(echo "$1" | tr '[:lower:]' '[:upper:]')" + fi +} + +echo "[databases]" > /etc/pgbouncer/pgbouncer.ini + +OLD_IFS="$IFS" +IFS=',' +for alias in $PGBOUNCER_DATABASES; do + IFS="$OLD_IFS" + alias=$(echo "$alias" | tr -d ' ') + if [ -n "$alias" ]; then + validate_alias "$alias" + prefix=$(resolve_prefix "$alias") + host=$(eval "printf '%s' \"\${${prefix}_HOST:-}\"") + port=$(eval "printf '%s' \"\${${prefix}_PORT:-5432}\"") + name=$(eval "printf '%s' \"\${${prefix}_NAME:-}\"") + user=$(eval "printf '%s' \"\${${prefix}_USERNAME:-}\"") + pass=$(eval "printf '%s' \"\${${prefix}_PASSWORD:-}\"") + + [ -z "$host" ] && { echo "entrypoint: ${prefix}_HOST is required for alias '${alias}'" >&2; exit 1; } + [ -z "$name" ] && { echo "entrypoint: ${prefix}_NAME is required for alias '${alias}'" >&2; exit 1; } + [ -z "$user" ] && { echo "entrypoint: ${prefix}_USERNAME is required for alias '${alias}'" >&2; exit 1; } + [ -z "$pass" ] && { echo "entrypoint: ${prefix}_PASSWORD is required for alias '${alias}'" >&2; exit 1; } + + echo "pgb_${alias} = host=${host} port=${port} dbname=${name} user=${user} password=${pass}" >> /etc/pgbouncer/pgbouncer.ini + fi + IFS=',' +done +IFS="$OLD_IFS" + +cat >> /etc/pgbouncer/pgbouncer.ini < /etc/supervisor.d/pgbouncer.ini <