diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 13fb012a..0f74187d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -34,6 +34,9 @@ on: jobs: build: + env: + # renovate: datasource=docker depName=moby/buildkit + BUILDKIT_IMAGE: moby/buildkit:v0.31.0@sha256:a095b3d11ce1a9a05b6064ef515dfca0291ec5bcf2ea8178da8f6461924294e1 strategy: matrix: runner: [ubuntu-24.04, ubuntu-24.04-arm] @@ -138,14 +141,14 @@ jobs: done } - retry_with_backoff docker pull moby/buildkit:v0.31.0 + retry_with_backoff docker pull "${BUILDKIT_IMAGE}" - name: Set up Docker Buildx id: buildx uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 with: driver-opts: | - image=moby/buildkit:v0.31.0 + image=${{ env.BUILDKIT_IMAGE }} network=host - id: build diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index ac1c93cc..2a494d60 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -1,9 +1,17 @@ name: Build and Push Docker Images on: push: - branches: ["*"] + branches: ["**"] paths: + - ".github/workflows/**" + - "ci/**" + - "cmd/**" + - "go.mod" + - "go.sum" - "images/**" + - "internal/**" + - "docker-bake.hcl" + - "Makefile" workflow_dispatch: inputs: image: @@ -35,7 +43,9 @@ jobs: - uses: "actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10" # v6 - name: lint - run: shellcheck **/*.sh + run: | + mapfile -d '' scripts < <(git grep -Ilz -E '^#!.*(ba)?sh') + shellcheck "${scripts[@]}" plan: runs-on: ubuntu-24.04 @@ -63,6 +73,8 @@ jobs: sitectl_isle_libops_image_tags: ${{ steps.plan.outputs.sitectl_isle_libops_image_tags }} sitectl_isle_images: ${{ steps.plan.outputs.sitectl_isle_images }} sitectl_create_smoke_matrix: ${{ steps.plan.outputs.sitectl_create_smoke_matrix }} + sitectl_repository: ${{ steps.plan.outputs.sitectl_repository }} + sitectl_ref: ${{ steps.plan.outputs.sitectl_ref }} steps: - uses: "actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10" # v6 with: @@ -73,6 +85,9 @@ jobs: with: go-version-file: go.mod + - name: Test planner and repository contracts + run: go test -count=1 ./... + - id: plan name: Plan env: @@ -124,6 +139,9 @@ jobs: local create_args="$3" local image_tags="$4" local images="$5" + local local_plugin_repository="${6:-}" + local local_plugin_ref="${7:-main}" + local local_plugin_path="${8:-}" if [ -z "${image_tags// }" ]; then return fi @@ -134,16 +152,38 @@ jobs: --arg create_args "$create_args" \ --arg image_tags "$image_tags" \ --arg images "$images" \ + --arg local_plugin_repository "$local_plugin_repository" \ + --arg local_plugin_ref "$local_plugin_ref" \ + --arg local_plugin_path "$local_plugin_path" \ '. + [{ "plugin": $plugin, "packages": $packages, "create-args": $create_args, "image-tags": $image_tags, - "images": $images + "images": $images, + "local-plugin-repository": $local_plugin_repository, + "local-plugin-ref": $local_plugin_ref, + "local-plugin-path": $local_plugin_path }]' <<<"$smoke_matrix" )" } + coordinated_template_args() { + local repository="$1" + local branch="${GITHUB_REF_NAME}" + + if [ "${branch}" = "main" ]; then + return + fi + if [[ ! "${branch}" =~ ^[A-Za-z0-9._/-]+$ ]]; then + echo "Skipping coordinated template branch with unsafe ref name: ${branch}" >&2 + return + fi + if git ls-remote --exit-code --heads "${repository}" "refs/heads/${branch}" >/dev/null 2>&1; then + printf '%s' "--template-repo ${repository} --template-branch ${branch}" + fi + } + sitectl_wp_image_tags="$(planned_tag_assignment wp wp-php84)" sitectl_omeka_s_image_tags="$(planned_tag_assignment omeka-s omeka-s-php84)" sitectl_omeka_classic_image_tags="$(planned_tag_assignment omeka-classic omeka-classic-php84)" @@ -151,14 +191,40 @@ jobs: sitectl_drupal_image_tags="$(planned_tag_assignment drupal drupal-php84)" sitectl_isle_libops_image_tags="$(planned_tag_assignment drupal islandora-php84)" sitectl_isle_images="" + sitectl_wp_create_args="$(coordinated_template_args https://github.com/libops/wp)" + sitectl_omeka_s_create_args="$(coordinated_template_args https://github.com/libops/omeka-s)" + sitectl_omeka_classic_create_args="$(coordinated_template_args https://github.com/libops/omeka-classic)" + sitectl_ojs_create_args="$(coordinated_template_args https://github.com/libops/ojs)" + sitectl_drupal_create_args="$(coordinated_template_args https://github.com/libops/drupal)" + sitectl_isle_template_args="$(coordinated_template_args https://github.com/libops/isle)" + if [ -z "${sitectl_isle_template_args}" ]; then + sitectl_isle_template_args="--template-repo https://github.com/libops/isle" + fi + sitectl_isle_create_args="${sitectl_isle_template_args} --fcrepo off --blazegraph off --isle-file-system-uri private --iiif triplet --iiif-topology disabled --bot-mitigation off" + sitectl_repository="" + sitectl_ref="main" + if [ -n "$(coordinated_template_args https://github.com/libops/sitectl)" ]; then + sitectl_repository="libops/sitectl" + sitectl_ref="${GITHUB_REF_NAME}" + fi + sitectl_isle_packages="sitectl sitectl-isle sitectl-drupal" + sitectl_isle_plugin_repository="" + sitectl_isle_plugin_ref="main" + sitectl_isle_plugin_path="" + if [ -n "$(coordinated_template_args https://github.com/libops/sitectl-isle)" ]; then + sitectl_isle_packages="sitectl sitectl-drupal" + sitectl_isle_plugin_repository="libops/sitectl-isle" + sitectl_isle_plugin_ref="${GITHUB_REF_NAME}" + sitectl_isle_plugin_path="sitectl-isle-source" + fi smoke_matrix='[]' - add_smoke_row "wp" "sitectl sitectl-wp" "" "$sitectl_wp_image_tags" "" - add_smoke_row "omeka-s" "sitectl sitectl-omeka-s" "" "$sitectl_omeka_s_image_tags" "" - add_smoke_row "omeka-classic" "sitectl sitectl-omeka-classic" "" "$sitectl_omeka_classic_image_tags" "" - add_smoke_row "ojs" "sitectl sitectl-ojs" "" "$sitectl_ojs_image_tags" "" - add_smoke_row "drupal" "sitectl sitectl-drupal" "" "$sitectl_drupal_image_tags" "" - add_smoke_row "isle" "sitectl sitectl-isle sitectl-drupal" "--template-repo https://github.com/libops/isle --fcrepo off --blazegraph off --isle-file-system-uri private --iiif triplet --iiif-topology disabled --bot-mitigation off" "$sitectl_isle_libops_image_tags" "$sitectl_isle_images" + add_smoke_row "wp" "sitectl sitectl-wp" "$sitectl_wp_create_args" "$sitectl_wp_image_tags" "" + add_smoke_row "omeka-s" "sitectl sitectl-omeka-s" "$sitectl_omeka_s_create_args" "$sitectl_omeka_s_image_tags" "" + add_smoke_row "omeka-classic" "sitectl sitectl-omeka-classic" "$sitectl_omeka_classic_create_args" "$sitectl_omeka_classic_image_tags" "" + add_smoke_row "ojs" "sitectl sitectl-ojs" "$sitectl_ojs_create_args" "$sitectl_ojs_image_tags" "" + add_smoke_row "drupal" "sitectl sitectl-drupal" "$sitectl_drupal_create_args" "$sitectl_drupal_image_tags" "" + add_smoke_row "isle" "$sitectl_isle_packages" "$sitectl_isle_create_args" "$sitectl_isle_libops_image_tags" "$sitectl_isle_images" "$sitectl_isle_plugin_repository" "$sitectl_isle_plugin_ref" "$sitectl_isle_plugin_path" { echo "sitectl_wp_image_tags=${sitectl_wp_image_tags}" @@ -169,6 +235,8 @@ jobs: echo "sitectl_isle_libops_image_tags=${sitectl_isle_libops_image_tags}" echo "sitectl_isle_images=${sitectl_isle_images}" echo "sitectl_create_smoke_matrix=${smoke_matrix}" + echo "sitectl_repository=${sitectl_repository}" + echo "sitectl_ref=${sitectl_ref}" } >> "$GITHUB_OUTPUT" - name: Summary @@ -375,13 +443,13 @@ jobs: sitectl-create-smoke-test: if: ${{ always() && needs.plan.outputs.sitectl_create_smoke_matrix != '[]' && !cancelled() && !failure() }} - needs: [plan, test-level-4] + needs: [plan, test-level-0, test-level-1, test-level-2, test-level-3, test-level-4] strategy: fail-fast: false matrix: include: ${{ fromJson(needs.plan.outputs.sitectl_create_smoke_matrix) }} - uses: libops/.github/.github/workflows/sitectl-create-smoke-test.yaml@main + uses: libops/.github/.github/workflows/sitectl-create-smoke-test.yaml@e366f7422565ea5ee71c0c626b740e03f2e2ffcd # main with: plugin: ${{ matrix.plugin }} create-definition: default @@ -391,5 +459,10 @@ jobs: image-tags: ${{ matrix.image-tags }} images: ${{ matrix.images }} packages: ${{ matrix.packages }} + local-plugin-repository: ${{ matrix.local-plugin-repository }} + local-plugin-ref: ${{ matrix.local-plugin-ref }} + local-plugin-path: ${{ matrix.local-plugin-path }} + sitectl-repository: ${{ needs.plan.outputs.sitectl_repository }} + sitectl-ref: ${{ needs.plan.outputs.sitectl_ref }} permissions: contents: read diff --git a/Makefile b/Makefile index 5e8b642a..535c0955 100644 --- a/Makefile +++ b/Makefile @@ -31,9 +31,12 @@ endef # Used to include host-platform specific docker compose files. OS := $(shell uname -s | tr A-Z a-z) -# Used to determine set TAGS when no explicit value provided, -# as well as to fetch branch specific remote caches when building. -BRANCH = $(shell git rev-parse --abbrev-ref HEAD) +# Used to set TAGS when no explicit value is provided and to fetch branch-specific +# remote caches. Keep it aligned with the metadata helper's Docker-tag fallback +# normalization so names such as feature/foo produce valid cache references. +# Pipe the ref directly into the normalizer so branch text is never re-evaluated +# as part of a generated shell command. +BRANCH = $(shell git -c safe.directory='$(CURDIR)' rev-parse --abbrev-ref HEAD | sed -E 's/[^A-Za-z0-9_.-]+/-/g; s/^-+//; s/-+$$//') # The buildkit builder to use. BUILDER ?= default @@ -121,7 +124,7 @@ docker-buildx: | docker .SILENT: build/bake.json build/bake.json: | docker-buildx jq build folder-permissions executable-permissons set -x; \ - BRANCH=$(BRANCH) \ + BRANCH="$(BRANCH)" \ CACHE_FROM_REPOSITORY=$(CACHE_FROM_REPOSITORY) \ CACHE_TO_REPOSITORY=$(CACHE_TO_REPOSITORY) \ REPOSITORY=$(REPOSITORY) \ diff --git a/README.md b/README.md index 2321efa4..f92d9f57 100644 --- a/README.md +++ b/README.md @@ -32,9 +32,26 @@ Changes in this fork: - Dropped fcrepo file persistence - Added ArchivesSpace, OJS, Omeka, and Wordpress images. - Moved Islandora buildkit's `drupal` image to `islandora` and made a drupal-only image - - All of these images are intended to be infra scaffolding and app-specific codebase dropped into the container in local builds + - ArchivesSpace, OJS, Omeka S, and Omeka Classic include checksum-verified upstream application releases. + - Drupal and Wordpress remain infrastructure scaffolding for Composer-managed application code in downstream builds. - Tags are based on software versions - Multiple versions of the same software/image can coexist (e.g. java, solr, fcrepo, php, etc.) - `base` environment variables (e.g. `DB_NAME`) are overriden instead of `IMAGE_NAME_*` env vars - Removed Islandora multisite support - Added optional Vault-backed secret bootstrap + +## OJS dataset initialization + +Fresh OJS installations defer the install-time ROR registry and IP geolocation +downloads so that container initialization is bounded and does not require +outbound network access. OJS's built-in scheduler remains enabled and updates +the ROR registry monthly on the first day of the month and the IP database +monthly on the tenth. + +To populate either dataset immediately after installation, run the corresponding +OJS scheduler task from `/var/www/ojs` as the application user: + +```sh +php lib/pkp/tools/scheduler.php test --name='PKP\task\UpdateRorRegistryDataset' +php lib/pkp/tools/scheduler.php test --name='PKP\task\UpdateIPGeoDB' +``` diff --git a/ci/tests/update-sha-readme.sh b/ci/tests/update-sha-readme.sh new file mode 100755 index 00000000..953b8c3a --- /dev/null +++ b/ci/tests/update-sha-readme.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash + +set -euo pipefail + +root_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) +work_dir=$(mktemp -d) +trap 'rm -rf "${work_dir}"' EXIT + +# shellcheck disable=SC1090 +UPDATE_SHA_LIBRARY_ONLY=true source "${root_dir}/ci/update-sha.sh" test 1.2.3 2.0.0 ignored + +readme="${work_dir}/README.md" +printf '%s\n' 'Docker image version 1.2.3.' >"${readme}" +update_readme "${readme}" 1.2.3 2.0.0 +grep -qx 'Docker image version 2.0.0.' "${readme}" + +printf '%s\n' 'Docker image version 9.9.9.' >"${readme}" +if update_readme "${readme}" 1.2.3 2.0.0; then + echo "Missing README source version unexpectedly succeeded" >&2 + exit 1 +fi + +printf '%s\n' 'First 1.2.3.' 'Second 1.2.3.' >"${readme}" +if update_readme "${readme}" 1.2.3 2.0.0; then + echo "Ambiguous README source version unexpectedly succeeded" >&2 + exit 1 +fi diff --git a/ci/update-sha.sh b/ci/update-sha.sh index fd8a26f6..4e13299b 100755 --- a/ci/update-sha.sh +++ b/ci/update-sha.sh @@ -19,37 +19,124 @@ update_dockerfile_sha() { local ARG="$2" local DOCKERFILES=("${@:3}") local SHA - curl -fLs "$URL" -o curl.resp || echo "Request failed with exit code $?" - SHA=$(shasum -a 256 curl.resp | awk '{print $1}') + local DOWNLOAD + local dockerfile + DOWNLOAD=$(mktemp) + + if ! curl \ + --fail \ + --location \ + --retry 3 \ + --retry-all-errors \ + --connect-timeout 20 \ + --max-time 600 \ + --silent \ + --show-error \ + --output "$DOWNLOAD" \ + "$URL"; then + rm -f "$DOWNLOAD" + echo "Failed to download release artifact: $URL" >&2 + return 1 + fi - if [[ "$OSTYPE" == "darwin"* ]]; then - sed -i '' 's|^ARG '"$ARG"'=.*|ARG '"$ARG"'="'"$SHA"'"|g' "${DOCKERFILES[@]}" - else - sed -i 's|^ARG '"$ARG"'=.*|ARG '"$ARG"'="'"$SHA"'"|g' "${DOCKERFILES[@]}" + SHA=$(shasum -a 256 "$DOWNLOAD" | awk '{print $1}') + if [[ ! "$SHA" =~ ^[0-9a-f]{64}$ ]]; then + rm -f "$DOWNLOAD" + echo "Failed to calculate SHA256 for release artifact: $URL" >&2 + return 1 fi - rm curl.resp + + for dockerfile in "${DOCKERFILES[@]}"; do + if [[ $(grep -Ec "^[[:space:]]*(ARG[[:space:]]+)?${ARG}=\"?[[:xdigit:]]{64}\"?" "${dockerfile}") -ne 1 ]]; then + rm -f "$DOWNLOAD" + echo "Expected exactly one existing ${ARG} checksum in ${dockerfile}" >&2 + return 1 + fi + + if [[ "$OSTYPE" == "darwin"* ]]; then + sed -E -i '' "s|^([[:space:]]*(ARG[[:space:]]+)?${ARG}=)\"?[[:xdigit:]]{64}\"?(.*)$|\\1\"${SHA}\"\\3|" "${dockerfile}" + else + sed -E -i "s|^([[:space:]]*(ARG[[:space:]]+)?${ARG}=)\"?[[:xdigit:]]{64}\"?(.*)$|\\1\"${SHA}\"\\3|" "${dockerfile}" + fi + + if ! grep -Eq "^[[:space:]]*(ARG[[:space:]]+)?${ARG}=\"${SHA}\"" "${dockerfile}"; then + rm -f "$DOWNLOAD" + echo "Failed to update ${ARG} in ${dockerfile}" >&2 + return 1 + fi + done + rm -f "$DOWNLOAD" } update_readme() { local README="$1" local OLD_VERSION="$2" local NEW_VERSION="$3" - # update the README to specify the new version - if [ "$README" != "" ]; then - if [[ "$OSTYPE" == "darwin"* ]]; then - sed -i '' "s/${OLD_VERSION}\.$/${NEW_VERSION}\./" "$README" - else - sed -i "s/${OLD_VERSION}\.$/${NEW_VERSION}\./" "$README" - fi + local match_count + local new_match_count + local replacement + + if [ -z "$README" ]; then + return 0 + fi + if [ ! -f "$README" ]; then + echo "Configured README does not exist: $README" >&2 + return 1 + fi + + match_count=$(awk -v suffix="${OLD_VERSION}." ' + length($0) >= length(suffix) && substr($0, length($0) - length(suffix) + 1) == suffix { count++ } + END { print count + 0 } + ' "$README") + if [ "$match_count" -ne 1 ]; then + echo "Expected exactly one README line ending in ${OLD_VERSION}. in ${README}; found ${match_count}" >&2 + return 1 + fi + + replacement=$(mktemp) + if ! awk -v old_suffix="${OLD_VERSION}." -v new_suffix="${NEW_VERSION}." ' + length($0) >= length(old_suffix) && substr($0, length($0) - length(old_suffix) + 1) == old_suffix { + print substr($0, 1, length($0) - length(old_suffix)) new_suffix + next + } + { print } + ' "$README" >"$replacement"; then + rm -f "$replacement" + echo "Failed to prepare README update for ${README}" >&2 + return 1 + fi + if cmp -s "$README" "$replacement"; then + rm -f "$replacement" + echo "README replacement did not change ${README}" >&2 + return 1 + fi + command cat "$replacement" >"$README" + rm -f "$replacement" + + new_match_count=$(awk -v suffix="${NEW_VERSION}." ' + length($0) >= length(suffix) && substr($0, length($0) - length(suffix) + 1) == suffix { count++ } + END { print count + 0 } + ' "$README") + if [ "$new_match_count" -ne 1 ]; then + echo "Failed to verify README version ${NEW_VERSION} in ${README}" >&2 + return 1 fi } +if [ "${UPDATE_SHA_LIBRARY_ONLY:-false}" = "true" ]; then + if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + exit 0 + fi + return 0 +fi + echo "Updating SHA for $DEP@$NEW_VERSION" if [ "$DEP" = "alpaca" ] ; then URL="https://github.com/islandora/alpaca/archive/refs/tags/${NEW_VERSION}.tar.gz" ARG=ALPACA_SHA256 DOCKERFILES=("images/alpaca/Dockerfile") + README="images/alpaca/README.md" elif [ "$DEP" = "apache-tomcat9" ]; then URL="https://downloads.apache.org/tomcat/tomcat-9/v$NEW_VERSION/bin/apache-tomcat-$NEW_VERSION.tar.gz" @@ -109,10 +196,19 @@ elif [ "$DEP" = "fcrepo7" ]; then DOCKERFILES=("images/fcrepo7/Dockerfile") README="images/fcrepo7/README.md" -elif [ "$DEP" = "islandora-syn" ]; then +elif [ "$DEP" = "islandora-syn-fcrepo6" ]; then URL="https://github.com/Islandora/Syn/releases/download/v${NEW_VERSION}/islandora-syn-${NEW_VERSION}-all.jar" ARG="SYN_SHA256" - DOCKERFILES=("images/fcrepo6/Dockerfile" "images/fcrepo7/Dockerfile") + DOCKERFILES=("images/fcrepo6/Dockerfile") + +elif [ "$DEP" = "islandora-syn-fcrepo7" ]; then + URL="https://github.com/Islandora/Syn/releases/download/v${NEW_VERSION}/islandora-syn-${NEW_VERSION}-all.jar" + ARG="SYN_SHA256" + DOCKERFILES=("images/fcrepo7/Dockerfile") + +elif [ "$DEP" = "islandora-syn" ]; then + echo "Use islandora-syn-fcrepo6 or islandora-syn-fcrepo7 so Syn major lines remain independent" >&2 + exit 1 elif [ "$DEP" = "fcrepo-import-export" ]; then URL="https://github.com/fcrepo-exts/fcrepo-import-export/releases/download/fcrepo-import-export-${NEW_VERSION}/fcrepo-import-export-${NEW_VERSION}.jar" @@ -214,8 +310,8 @@ elif [ "$DEP" = "s6-overlay" ]; then exit 0 else - echo "DEP not found" - exit 0 + echo "Unsupported dependency: $DEP" >&2 + exit 1 fi update_dockerfile_sha "$URL" "$ARG" "${DOCKERFILES[@]}" diff --git a/docker-bake.hcl b/docker-bake.hcl index 68fa4e62..ab9969f7 100644 --- a/docker-bake.hcl +++ b/docker-bake.hcl @@ -97,8 +97,8 @@ LOCAL_TAG_SUFFIXES = { "activemq5" = "5" "activemq6" = "6" "alpaca" = "" - "archivesspace" = "4.2.0" - "archivesspace-solr" = "4.2.0" + "archivesspace" = "" + "archivesspace-solr" = "" "base" = "" "blazegraph" = "" "crayfits" = "" @@ -248,14 +248,19 @@ function "tags" { result = equal("", suffix) ? [for tag in split(" ", TAGS): "${REPOSITORY}/${PUBLISHED_IMAGES[image]}:${tagName(image, tag)}"] : [for tag in split(" ", TAGS): "${REPOSITORY}/${PUBLISHED_IMAGES[image]}:${tagName(image, tag)}-${suffix}"] } +function "normalizeTag" { + params = [value] + result = trim(regex_replace(value, "[^A-Za-z0-9_.-]+", "-"), "-") +} + function "cacheFrom" { params = [image, arch] - result = equal("", arch) ? [] : ["type=registry,ref=${CACHE_FROM_REPOSITORY}/cache:${image}-main-${arch}", notequal("", BRANCH) ? "type=registry,ref=${CACHE_FROM_REPOSITORY}/cache:${image}-${BRANCH}-${arch}" : ""] + result = equal("", arch) ? [] : ["type=registry,ref=${CACHE_FROM_REPOSITORY}/cache:${image}-main-${arch}", notequal("", normalizeTag(BRANCH)) ? "type=registry,ref=${CACHE_FROM_REPOSITORY}/cache:${image}-${normalizeTag(BRANCH)}-${arch}" : ""] } function "cacheTo" { params = [image, arch] - result = [notequal("", BRANCH) ? "type=registry,oci-mediatypes=true,mode=max,compression=estargz,compression-level=5,ref=${CACHE_TO_REPOSITORY}/cache:${image}-${BRANCH}-${arch}" : ""] + result = [notequal("", normalizeTag(BRANCH)) ? "type=registry,oci-mediatypes=true,mode=max,compression=estargz,compression-level=5,ref=${CACHE_TO_REPOSITORY}/cache:${image}-${normalizeTag(BRANCH)}-${arch}" : ""] } function "context" { diff --git a/images/activemq5/Dockerfile b/images/activemq5/Dockerfile index a0b73b3b..be9facff 100644 --- a/images/activemq5/Dockerfile +++ b/images/activemq5/Dockerfile @@ -27,10 +27,10 @@ RUN --mount=type=cache,id=activemq-downloads-${TARGETARCH},sharing=locked,target ENV \ ACTIVEMQ_AUDIT_LOG_LEVEL=INFO \ ACTIVEMQ_LOG_LEVEL=INFO \ - ACTIVEMQ_PASSWORD=password \ + ACTIVEMQ_PASSWORD= \ ACTIVEMQ_USER=admin \ ACTIVEMQ_WEB_ADMIN_NAME=admin \ - ACTIVEMQ_WEB_ADMIN_PASSWORD=password \ + ACTIVEMQ_WEB_ADMIN_PASSWORD= \ ACTIVEMQ_WEB_ADMIN_ROLES=admin COPY --link rootfs / @@ -38,8 +38,4 @@ COPY --link rootfs / RUN create-service-user.sh --name activemq && \ cleanup.sh -HEALTHCHECK CMD curl -s \ - -u admin:"$(cat /var/run/s6/container_environment/ACTIVEMQ_WEB_ADMIN_PASSWORD)" \ - -H origin:localhost \ - "http://localhost:8161/api/jolokia/read/org.apache.activemq:type=Broker,brokerName=localhost,service=Health/CurrentStatus" \ - | jq .value | grep -q Good +HEALTHCHECK CMD activemq-healthcheck.sh diff --git a/images/activemq5/README.md b/images/activemq5/README.md index 63fb4164..416740a2 100644 --- a/images/activemq5/README.md +++ b/images/activemq5/README.md @@ -6,16 +6,21 @@ Built from [libops/isle-buildkit activemq](https://github.com/libops/buildkit/tr Please refer to the [ActiveMQ Documentation] for more in-depth information. -As a quick example this will bring up an instance of ActiveMQ, and allow you to -log into the [WebConsole] on `http://localhost:8161` as the user `admin` with -the password `password`. +As a quick example this will generate separate broker and web-console +credentials, bring up ActiveMQ, and allow you to log into the [WebConsole] on +`http://localhost:8161` as `admin` using the value of +`ACTIVEMQ_WEB_ADMIN_PASSWORD`. ```bash -docker run --rm -ti -p 8161:8161 libops/activemq +export ACTIVEMQ_PASSWORD="$(openssl rand -hex 32)" +export ACTIVEMQ_WEB_ADMIN_PASSWORD="$(openssl rand -hex 32)" +docker run --rm -ti -p 8161:8161 \ + --env ACTIVEMQ_PASSWORD \ + --env ACTIVEMQ_WEB_ADMIN_PASSWORD \ + libops/activemq:5 ``` -> N.B. if no credentials are given you will not be able to log in via the -[WebConsole]. +Both credentials are required; the container fails closed when either is empty. ## Dependencies @@ -46,10 +51,10 @@ additional settings, volumes, ports, etc. | :-------------------------- | :------- | :----------------------------------------------------------------------------- | | ACTIVEMQ_AUDIT_LOG_LEVEL | INFO | Log level. Possible Values: OFF, FATAL, ERROR, WARN, INFO, DEBUG, TRACE or ALL | | ACTIVEMQ_LOG_LEVEL | INFO | Log level. Possible Values: OFF, FATAL, ERROR, WARN, INFO, DEBUG, TRACE or ALL | -| ACTIVEMQ_PASSWORD | password | See [Security]: credentials.properties | +| ACTIVEMQ_PASSWORD | | See [Security]: credentials.properties; required at startup | | ACTIVEMQ_USER | admin | See [Security]: credentials.properties | | ACTIVEMQ_WEB_ADMIN_NAME | admin | See [WebConsole]: jetty-realm.properties | -| ACTIVEMQ_WEB_ADMIN_PASSWORD | password | See [WebConsole]: jetty-realm.properties | +| ACTIVEMQ_WEB_ADMIN_PASSWORD | | See [WebConsole]: jetty-realm.properties; required at startup | | ACTIVEMQ_WEB_ADMIN_ROLES | admin | See [WebConsole]: jetty-realm.properties | Additional users/groups/etc can be defined by adding more environment variables, diff --git a/images/activemq5/rootfs/etc/confd/templates/credentials.properties.tmpl b/images/activemq5/rootfs/etc/confd/templates/credentials.properties.tmpl index 4952c702..86ddec53 100644 --- a/images/activemq5/rootfs/etc/confd/templates/credentials.properties.tmpl +++ b/images/activemq5/rootfs/etc/confd/templates/credentials.properties.tmpl @@ -1,3 +1,10 @@ +{{- define "propertiesValueEscape" -}} +{{- $backslashes := replace . "\\" "\\\\" -1 -}} +{{- $carriageReturns := replace $backslashes "\r" "\\r" -1 -}} +{{- $newlines := replace $carriageReturns "\n" "\\n" -1 -}} +{{- $tabs := replace $newlines "\t" "\\t" -1 -}} +{{- replace $tabs " " "\\ " -1 -}} +{{- end -}} # Defines credentials that will be used by components (like web console) to access the broker -activemq.username={{ getenv "ACTIVEMQ_USER" }} -activemq.password={{ getenv "ACTIVEMQ_PASSWORD" }} +activemq.username={{ template "propertiesValueEscape" (getenv "ACTIVEMQ_USER") }} +activemq.password={{ template "propertiesValueEscape" (getenv "ACTIVEMQ_PASSWORD") }} diff --git a/images/activemq5/rootfs/etc/confd/templates/groups.properties.tmpl b/images/activemq5/rootfs/etc/confd/templates/groups.properties.tmpl index 8e26887d..470cb092 100644 --- a/images/activemq5/rootfs/etc/confd/templates/groups.properties.tmpl +++ b/images/activemq5/rootfs/etc/confd/templates/groups.properties.tmpl @@ -1,4 +1,22 @@ +{{- define "propertiesKeyEscape" -}} +{{- $backslashes := replace . "\\" "\\\\" -1 -}} +{{- $carriageReturns := replace $backslashes "\r" "\\r" -1 -}} +{{- $newlines := replace $carriageReturns "\n" "\\n" -1 -}} +{{- $tabs := replace $newlines "\t" "\\t" -1 -}} +{{- $spaces := replace $tabs " " "\\ " -1 -}} +{{- $colons := replace $spaces ":" "\\:" -1 -}} +{{- $equals := replace $colons "=" "\\=" -1 -}} +{{- $hashes := replace $equals "#" "\\#" -1 -}} +{{- replace $hashes "!" "\\!" -1 -}} +{{- end -}} +{{- define "propertiesValueEscape" -}} +{{- $backslashes := replace . "\\" "\\\\" -1 -}} +{{- $carriageReturns := replace $backslashes "\r" "\\r" -1 -}} +{{- $newlines := replace $carriageReturns "\n" "\\n" -1 -}} +{{- $tabs := replace $newlines "\t" "\\t" -1 -}} +{{- replace $tabs " " "\\ " -1 -}} +{{- end -}} # Defines groups and the users that belong to them. # group=user[,user ...] -{{ range $dir := lsdir "/activemq/group" }}{{ getv (printf "/activemq/group/%s/name" $dir) }}={{ getv (printf "/activemq/group/%s/members" $dir) }} +{{ range $dir := lsdir "/activemq/group" }}{{ template "propertiesKeyEscape" (getv (printf "/activemq/group/%s/name" $dir)) }}={{ template "propertiesValueEscape" (getv (printf "/activemq/group/%s/members" $dir)) }} {{ end }} diff --git a/images/activemq5/rootfs/etc/confd/templates/jetty-realm.properties.tmpl b/images/activemq5/rootfs/etc/confd/templates/jetty-realm.properties.tmpl index 31a932a4..524995b7 100644 --- a/images/activemq5/rootfs/etc/confd/templates/jetty-realm.properties.tmpl +++ b/images/activemq5/rootfs/etc/confd/templates/jetty-realm.properties.tmpl @@ -1,5 +1,23 @@ +{{- define "propertiesKeyEscape" -}} +{{- $backslashes := replace . "\\" "\\\\" -1 -}} +{{- $carriageReturns := replace $backslashes "\r" "\\r" -1 -}} +{{- $newlines := replace $carriageReturns "\n" "\\n" -1 -}} +{{- $tabs := replace $newlines "\t" "\\t" -1 -}} +{{- $spaces := replace $tabs " " "\\ " -1 -}} +{{- $colons := replace $spaces ":" "\\:" -1 -}} +{{- $equals := replace $colons "=" "\\=" -1 -}} +{{- $hashes := replace $equals "#" "\\#" -1 -}} +{{- replace $hashes "!" "\\!" -1 -}} +{{- end -}} +{{- define "propertiesValueEscape" -}} +{{- $backslashes := replace . "\\" "\\\\" -1 -}} +{{- $carriageReturns := replace $backslashes "\r" "\\r" -1 -}} +{{- $newlines := replace $carriageReturns "\n" "\\n" -1 -}} +{{- $tabs := replace $newlines "\t" "\\t" -1 -}} +{{- replace $tabs " " "\\ " -1 -}} +{{- end -}} # Defines users that can access the web (console, demo, etc.) # username: password [,rolename ...] -{{ getenv "ACTIVEMQ_WEB_ADMIN_NAME" }}: {{ getenv "ACTIVEMQ_WEB_ADMIN_PASSWORD" }}, {{ getenv "ACTIVEMQ_WEB_ADMIN_ROLES" }} -{{ range $dir := lsdir "/activemq/web/user" }}{{ getv (printf "/activemq/web/user/%s/name" $dir) }}: {{ getv (printf "/activemq/web/user/%s/password" $dir) }}, {{ getv (printf "/activemq/web/user/%s/roles" $dir) }} +{{ template "propertiesKeyEscape" (getenv "ACTIVEMQ_WEB_ADMIN_NAME") }}: {{ template "propertiesValueEscape" (getenv "ACTIVEMQ_WEB_ADMIN_PASSWORD") }}, {{ template "propertiesValueEscape" (getenv "ACTIVEMQ_WEB_ADMIN_ROLES") }} +{{ range $dir := lsdir "/activemq/web/user" }}{{ template "propertiesKeyEscape" (getv (printf "/activemq/web/user/%s/name" $dir)) }}: {{ template "propertiesValueEscape" (getv (printf "/activemq/web/user/%s/password" $dir)) }}, {{ template "propertiesValueEscape" (getv (printf "/activemq/web/user/%s/roles" $dir)) }} {{ end }} diff --git a/images/activemq5/rootfs/etc/confd/templates/users.properties.tmpl b/images/activemq5/rootfs/etc/confd/templates/users.properties.tmpl index 669a832d..cc1d1d95 100644 --- a/images/activemq5/rootfs/etc/confd/templates/users.properties.tmpl +++ b/images/activemq5/rootfs/etc/confd/templates/users.properties.tmpl @@ -1,2 +1,20 @@ -{{ range $dir := lsdir "/activemq/user" }}{{ getv (printf "/activemq/user/%s/name" $dir) }}={{ getv (printf "/activemq/user/%s/password" $dir) }} +{{- define "propertiesKeyEscape" -}} +{{- $backslashes := replace . "\\" "\\\\" -1 -}} +{{- $carriageReturns := replace $backslashes "\r" "\\r" -1 -}} +{{- $newlines := replace $carriageReturns "\n" "\\n" -1 -}} +{{- $tabs := replace $newlines "\t" "\\t" -1 -}} +{{- $spaces := replace $tabs " " "\\ " -1 -}} +{{- $colons := replace $spaces ":" "\\:" -1 -}} +{{- $equals := replace $colons "=" "\\=" -1 -}} +{{- $hashes := replace $equals "#" "\\#" -1 -}} +{{- replace $hashes "!" "\\!" -1 -}} +{{- end -}} +{{- define "propertiesValueEscape" -}} +{{- $backslashes := replace . "\\" "\\\\" -1 -}} +{{- $carriageReturns := replace $backslashes "\r" "\\r" -1 -}} +{{- $newlines := replace $carriageReturns "\n" "\\n" -1 -}} +{{- $tabs := replace $newlines "\t" "\\t" -1 -}} +{{- replace $tabs " " "\\ " -1 -}} +{{- end -}} +{{ range $dir := lsdir "/activemq/user" }}{{ template "propertiesKeyEscape" (getv (printf "/activemq/user/%s/name" $dir)) }}={{ template "propertiesValueEscape" (getv (printf "/activemq/user/%s/password" $dir)) }} {{ end }} diff --git a/images/activemq5/rootfs/etc/s6-overlay/s6-rc.d/activemq/run b/images/activemq5/rootfs/etc/s6-overlay/s6-rc.d/activemq/run index 9ea0dc80..9ec0b40a 100755 --- a/images/activemq5/rootfs/etc/s6-overlay/s6-rc.d/activemq/run +++ b/images/activemq5/rootfs/etc/s6-overlay/s6-rc.d/activemq/run @@ -1,7 +1,16 @@ -#!/usr/bin/env bash +#!/command/with-contenv bash +# shellcheck shell=bash set -e +# shellcheck disable=SC1091 +source /usr/local/share/libops/environment.sh + +require_environment_variables ACTIVEMQ_USER ACTIVEMQ_PASSWORD \ + ACTIVEMQ_WEB_ADMIN_NAME ACTIVEMQ_WEB_ADMIN_PASSWORD + # When bind mounting we need to ensure that we # actually can write to the folder. chown activemq:activemq /opt/activemq/data +unset ACTIVEMQ_PASSWORD ACTIVEMQ_WEB_ADMIN_PASSWORD +unset DB_ROOT_PASSWORD exec s6-setuidgid activemq /opt/activemq/bin/activemq console diff --git a/images/activemq5/rootfs/usr/local/bin/activemq-healthcheck.sh b/images/activemq5/rootfs/usr/local/bin/activemq-healthcheck.sh new file mode 100755 index 00000000..92a25067 --- /dev/null +++ b/images/activemq5/rootfs/usr/local/bin/activemq-healthcheck.sh @@ -0,0 +1,25 @@ +#!/command/with-contenv bash +# shellcheck shell=bash + +set -euo pipefail + +# shellcheck disable=SC1091 +source /usr/local/share/libops/environment.sh +require_environment_variables ACTIVEMQ_WEB_ADMIN_NAME ACTIVEMQ_WEB_ADMIN_PASSWORD + +umask 077 +curl_config=$(mktemp -t activemq-healthcheck.XXXXXXXXXX) +trap 'rm -f -- "${curl_config}"' EXIT +chmod 0600 "${curl_config}" + +# Keep the credential out of curl's argv. The temporary curl configuration is +# root-only and contains only a derived Basic authorization value. +authorization=$(printf '%s:%s' "${ACTIVEMQ_WEB_ADMIN_NAME}" "${ACTIVEMQ_WEB_ADMIN_PASSWORD}" | base64 | tr -d '\n') +{ + printf '%s\n' 'fail' 'silent' 'show-error' + printf 'header = "Origin: localhost"\n' + printf 'header = "Authorization: Basic %s"\n' "${authorization}" + printf 'url = "%s"\n' 'http://localhost:8161/api/jolokia/read/org.apache.activemq:type=Broker,brokerName=localhost,service=Health/CurrentStatus' +} >"${curl_config}" + +curl --config "${curl_config}" | jq -e '.value == "Good"' >/dev/null diff --git a/images/activemq5/tests/ServiceHealthcheck/docker-compose.yml b/images/activemq5/tests/ServiceHealthcheck/docker-compose.yml index b72dd41d..7d3d45d9 100644 --- a/images/activemq5/tests/ServiceHealthcheck/docker-compose.yml +++ b/images/activemq5/tests/ServiceHealthcheck/docker-compose.yml @@ -8,6 +8,14 @@ services: activemq: <<: *common image: ${ACTIVEMQ:-libops/activemq:local} + environment: + ACTIVEMQ_PASSWORD: test-broker-password + ACTIVEMQ_USER_ESCAPE_NAME: 'broker:user=name' + ACTIVEMQ_USER_ESCAPE_PASSWORD: ' broker:p\a=ss:word' + ACTIVEMQ_GROUP_ESCAPE_NAME: 'operators:admin=team' + ACTIVEMQ_GROUP_ESCAPE_MEMBERS: 'broker:user=name' + ACTIVEMQ_WEB_ADMIN_NAME: healthcheck-user + ACTIVEMQ_WEB_ADMIN_PASSWORD: 'web:p"a\ss&+=word' volumes: - ./test.sh:/test.sh # Test to run. command: diff --git a/images/activemq5/tests/ServiceHealthcheck/test.sh b/images/activemq5/tests/ServiceHealthcheck/test.sh index dd1d6bdf..a7317ec5 100755 --- a/images/activemq5/tests/ServiceHealthcheck/test.sh +++ b/images/activemq5/tests/ServiceHealthcheck/test.sh @@ -2,13 +2,16 @@ # shellcheck shell=bash # Invoked indirectly by the SIGTERM trap below. -# shellcheck disable=SC2329 +# shellcheck disable=SC2317,SC2329 on_terminate() { echo "Termination signal received. Exiting..." exit 0 } trap 'on_terminate' SIGTERM +grep -Fqx 'broker\:user\=name=\ broker:p\\a=ss:word' /opt/activemq/conf/users.properties +grep -Fqx 'operators\:admin\=team=broker:user=name' /opt/activemq/conf/groups.properties + sleep 60 # The kotlin check should be stopping this container diff --git a/images/activemq5/tests/ServiceStartsWithDefaults/docker-compose.yml b/images/activemq5/tests/ServiceStartsWithDefaults/docker-compose.yml index ebca7ded..652d1963 100644 --- a/images/activemq5/tests/ServiceStartsWithDefaults/docker-compose.yml +++ b/images/activemq5/tests/ServiceStartsWithDefaults/docker-compose.yml @@ -11,6 +11,9 @@ services: activemq: <<: *common image: ${ACTIVEMQ:-libops/activemq:local} + environment: + ACTIVEMQ_PASSWORD: test-broker-password + ACTIVEMQ_WEB_ADMIN_PASSWORD: test-web-password volumes: - ./test.sh:/test.sh # Test to run. command: diff --git a/images/activemq6/Dockerfile b/images/activemq6/Dockerfile index e7303298..6e8364c7 100644 --- a/images/activemq6/Dockerfile +++ b/images/activemq6/Dockerfile @@ -27,10 +27,10 @@ RUN --mount=type=cache,id=activemq-downloads-${TARGETARCH},sharing=locked,target ENV \ ACTIVEMQ_AUDIT_LOG_LEVEL=INFO \ ACTIVEMQ_LOG_LEVEL=INFO \ - ACTIVEMQ_PASSWORD=password \ + ACTIVEMQ_PASSWORD= \ ACTIVEMQ_USER=admin \ ACTIVEMQ_WEB_ADMIN_NAME=admin \ - ACTIVEMQ_WEB_ADMIN_PASSWORD=password \ + ACTIVEMQ_WEB_ADMIN_PASSWORD= \ ACTIVEMQ_WEB_ADMIN_ROLES=admin COPY --link rootfs / @@ -38,8 +38,4 @@ COPY --link rootfs / RUN create-service-user.sh --name activemq && \ cleanup.sh -HEALTHCHECK CMD curl -s \ - -u admin:"$(cat /var/run/s6/container_environment/ACTIVEMQ_WEB_ADMIN_PASSWORD)" \ - -H origin:localhost \ - "http://localhost:8161/api/jolokia/read/org.apache.activemq:type=Broker,brokerName=localhost,service=Health/CurrentStatus" \ - | jq .value | grep -q Good +HEALTHCHECK CMD activemq-healthcheck.sh diff --git a/images/activemq6/README.md b/images/activemq6/README.md index 2053d037..3d8ce17b 100644 --- a/images/activemq6/README.md +++ b/images/activemq6/README.md @@ -6,16 +6,21 @@ Built from [libops/isle-buildkit activemq7](https://github.com/libops/buildkit/t Please refer to the [ActiveMQ Documentation] for more in-depth information. -As a quick example this will bring up an instance of ActiveMQ, and allow you to -log into the [WebConsole] on `http://localhost:8161` as the user `admin` with -the password `password`. +As a quick example this will generate separate broker and web-console +credentials, bring up ActiveMQ, and allow you to log into the [WebConsole] on +`http://localhost:8161` as `admin` using the value of +`ACTIVEMQ_WEB_ADMIN_PASSWORD`. ```bash -docker run --rm -ti -p 8161:8161 libops/activemq +export ACTIVEMQ_PASSWORD="$(openssl rand -hex 32)" +export ACTIVEMQ_WEB_ADMIN_PASSWORD="$(openssl rand -hex 32)" +docker run --rm -ti -p 8161:8161 \ + --env ACTIVEMQ_PASSWORD \ + --env ACTIVEMQ_WEB_ADMIN_PASSWORD \ + libops/activemq:6 ``` -> N.B. if no credentials are given you will not be able to log in via the -[WebConsole]. +Both credentials are required; the container fails closed when either is empty. ## Dependencies @@ -46,10 +51,10 @@ additional settings, volumes, ports, etc. | :-------------------------- | :------- | :----------------------------------------------------------------------------- | | ACTIVEMQ_AUDIT_LOG_LEVEL | INFO | Log level. Possible Values: OFF, FATAL, ERROR, WARN, INFO, DEBUG, TRACE or ALL | | ACTIVEMQ_LOG_LEVEL | INFO | Log level. Possible Values: OFF, FATAL, ERROR, WARN, INFO, DEBUG, TRACE or ALL | -| ACTIVEMQ_PASSWORD | password | See [Security]: credentials.properties | +| ACTIVEMQ_PASSWORD | | See [Security]: credentials.properties; required at startup | | ACTIVEMQ_USER | admin | See [Security]: credentials.properties | | ACTIVEMQ_WEB_ADMIN_NAME | admin | See [WebConsole]: jetty-realm.properties | -| ACTIVEMQ_WEB_ADMIN_PASSWORD | password | See [WebConsole]: jetty-realm.properties | +| ACTIVEMQ_WEB_ADMIN_PASSWORD | | See [WebConsole]: jetty-realm.properties; required at startup | | ACTIVEMQ_WEB_ADMIN_ROLES | admin | See [WebConsole]: jetty-realm.properties | Additional users/groups/etc can be defined by adding more environment variables, diff --git a/images/activemq6/rootfs/etc/confd/templates/credentials.properties.tmpl b/images/activemq6/rootfs/etc/confd/templates/credentials.properties.tmpl index 4952c702..86ddec53 100644 --- a/images/activemq6/rootfs/etc/confd/templates/credentials.properties.tmpl +++ b/images/activemq6/rootfs/etc/confd/templates/credentials.properties.tmpl @@ -1,3 +1,10 @@ +{{- define "propertiesValueEscape" -}} +{{- $backslashes := replace . "\\" "\\\\" -1 -}} +{{- $carriageReturns := replace $backslashes "\r" "\\r" -1 -}} +{{- $newlines := replace $carriageReturns "\n" "\\n" -1 -}} +{{- $tabs := replace $newlines "\t" "\\t" -1 -}} +{{- replace $tabs " " "\\ " -1 -}} +{{- end -}} # Defines credentials that will be used by components (like web console) to access the broker -activemq.username={{ getenv "ACTIVEMQ_USER" }} -activemq.password={{ getenv "ACTIVEMQ_PASSWORD" }} +activemq.username={{ template "propertiesValueEscape" (getenv "ACTIVEMQ_USER") }} +activemq.password={{ template "propertiesValueEscape" (getenv "ACTIVEMQ_PASSWORD") }} diff --git a/images/activemq6/rootfs/etc/confd/templates/groups.properties.tmpl b/images/activemq6/rootfs/etc/confd/templates/groups.properties.tmpl index 8e26887d..470cb092 100644 --- a/images/activemq6/rootfs/etc/confd/templates/groups.properties.tmpl +++ b/images/activemq6/rootfs/etc/confd/templates/groups.properties.tmpl @@ -1,4 +1,22 @@ +{{- define "propertiesKeyEscape" -}} +{{- $backslashes := replace . "\\" "\\\\" -1 -}} +{{- $carriageReturns := replace $backslashes "\r" "\\r" -1 -}} +{{- $newlines := replace $carriageReturns "\n" "\\n" -1 -}} +{{- $tabs := replace $newlines "\t" "\\t" -1 -}} +{{- $spaces := replace $tabs " " "\\ " -1 -}} +{{- $colons := replace $spaces ":" "\\:" -1 -}} +{{- $equals := replace $colons "=" "\\=" -1 -}} +{{- $hashes := replace $equals "#" "\\#" -1 -}} +{{- replace $hashes "!" "\\!" -1 -}} +{{- end -}} +{{- define "propertiesValueEscape" -}} +{{- $backslashes := replace . "\\" "\\\\" -1 -}} +{{- $carriageReturns := replace $backslashes "\r" "\\r" -1 -}} +{{- $newlines := replace $carriageReturns "\n" "\\n" -1 -}} +{{- $tabs := replace $newlines "\t" "\\t" -1 -}} +{{- replace $tabs " " "\\ " -1 -}} +{{- end -}} # Defines groups and the users that belong to them. # group=user[,user ...] -{{ range $dir := lsdir "/activemq/group" }}{{ getv (printf "/activemq/group/%s/name" $dir) }}={{ getv (printf "/activemq/group/%s/members" $dir) }} +{{ range $dir := lsdir "/activemq/group" }}{{ template "propertiesKeyEscape" (getv (printf "/activemq/group/%s/name" $dir)) }}={{ template "propertiesValueEscape" (getv (printf "/activemq/group/%s/members" $dir)) }} {{ end }} diff --git a/images/activemq6/rootfs/etc/confd/templates/jetty-realm.properties.tmpl b/images/activemq6/rootfs/etc/confd/templates/jetty-realm.properties.tmpl index 31a932a4..524995b7 100644 --- a/images/activemq6/rootfs/etc/confd/templates/jetty-realm.properties.tmpl +++ b/images/activemq6/rootfs/etc/confd/templates/jetty-realm.properties.tmpl @@ -1,5 +1,23 @@ +{{- define "propertiesKeyEscape" -}} +{{- $backslashes := replace . "\\" "\\\\" -1 -}} +{{- $carriageReturns := replace $backslashes "\r" "\\r" -1 -}} +{{- $newlines := replace $carriageReturns "\n" "\\n" -1 -}} +{{- $tabs := replace $newlines "\t" "\\t" -1 -}} +{{- $spaces := replace $tabs " " "\\ " -1 -}} +{{- $colons := replace $spaces ":" "\\:" -1 -}} +{{- $equals := replace $colons "=" "\\=" -1 -}} +{{- $hashes := replace $equals "#" "\\#" -1 -}} +{{- replace $hashes "!" "\\!" -1 -}} +{{- end -}} +{{- define "propertiesValueEscape" -}} +{{- $backslashes := replace . "\\" "\\\\" -1 -}} +{{- $carriageReturns := replace $backslashes "\r" "\\r" -1 -}} +{{- $newlines := replace $carriageReturns "\n" "\\n" -1 -}} +{{- $tabs := replace $newlines "\t" "\\t" -1 -}} +{{- replace $tabs " " "\\ " -1 -}} +{{- end -}} # Defines users that can access the web (console, demo, etc.) # username: password [,rolename ...] -{{ getenv "ACTIVEMQ_WEB_ADMIN_NAME" }}: {{ getenv "ACTIVEMQ_WEB_ADMIN_PASSWORD" }}, {{ getenv "ACTIVEMQ_WEB_ADMIN_ROLES" }} -{{ range $dir := lsdir "/activemq/web/user" }}{{ getv (printf "/activemq/web/user/%s/name" $dir) }}: {{ getv (printf "/activemq/web/user/%s/password" $dir) }}, {{ getv (printf "/activemq/web/user/%s/roles" $dir) }} +{{ template "propertiesKeyEscape" (getenv "ACTIVEMQ_WEB_ADMIN_NAME") }}: {{ template "propertiesValueEscape" (getenv "ACTIVEMQ_WEB_ADMIN_PASSWORD") }}, {{ template "propertiesValueEscape" (getenv "ACTIVEMQ_WEB_ADMIN_ROLES") }} +{{ range $dir := lsdir "/activemq/web/user" }}{{ template "propertiesKeyEscape" (getv (printf "/activemq/web/user/%s/name" $dir)) }}: {{ template "propertiesValueEscape" (getv (printf "/activemq/web/user/%s/password" $dir)) }}, {{ template "propertiesValueEscape" (getv (printf "/activemq/web/user/%s/roles" $dir)) }} {{ end }} diff --git a/images/activemq6/rootfs/etc/confd/templates/users.properties.tmpl b/images/activemq6/rootfs/etc/confd/templates/users.properties.tmpl index 669a832d..cc1d1d95 100644 --- a/images/activemq6/rootfs/etc/confd/templates/users.properties.tmpl +++ b/images/activemq6/rootfs/etc/confd/templates/users.properties.tmpl @@ -1,2 +1,20 @@ -{{ range $dir := lsdir "/activemq/user" }}{{ getv (printf "/activemq/user/%s/name" $dir) }}={{ getv (printf "/activemq/user/%s/password" $dir) }} +{{- define "propertiesKeyEscape" -}} +{{- $backslashes := replace . "\\" "\\\\" -1 -}} +{{- $carriageReturns := replace $backslashes "\r" "\\r" -1 -}} +{{- $newlines := replace $carriageReturns "\n" "\\n" -1 -}} +{{- $tabs := replace $newlines "\t" "\\t" -1 -}} +{{- $spaces := replace $tabs " " "\\ " -1 -}} +{{- $colons := replace $spaces ":" "\\:" -1 -}} +{{- $equals := replace $colons "=" "\\=" -1 -}} +{{- $hashes := replace $equals "#" "\\#" -1 -}} +{{- replace $hashes "!" "\\!" -1 -}} +{{- end -}} +{{- define "propertiesValueEscape" -}} +{{- $backslashes := replace . "\\" "\\\\" -1 -}} +{{- $carriageReturns := replace $backslashes "\r" "\\r" -1 -}} +{{- $newlines := replace $carriageReturns "\n" "\\n" -1 -}} +{{- $tabs := replace $newlines "\t" "\\t" -1 -}} +{{- replace $tabs " " "\\ " -1 -}} +{{- end -}} +{{ range $dir := lsdir "/activemq/user" }}{{ template "propertiesKeyEscape" (getv (printf "/activemq/user/%s/name" $dir)) }}={{ template "propertiesValueEscape" (getv (printf "/activemq/user/%s/password" $dir)) }} {{ end }} diff --git a/images/activemq6/rootfs/etc/s6-overlay/s6-rc.d/activemq/run b/images/activemq6/rootfs/etc/s6-overlay/s6-rc.d/activemq/run index 9ea0dc80..9ec0b40a 100755 --- a/images/activemq6/rootfs/etc/s6-overlay/s6-rc.d/activemq/run +++ b/images/activemq6/rootfs/etc/s6-overlay/s6-rc.d/activemq/run @@ -1,7 +1,16 @@ -#!/usr/bin/env bash +#!/command/with-contenv bash +# shellcheck shell=bash set -e +# shellcheck disable=SC1091 +source /usr/local/share/libops/environment.sh + +require_environment_variables ACTIVEMQ_USER ACTIVEMQ_PASSWORD \ + ACTIVEMQ_WEB_ADMIN_NAME ACTIVEMQ_WEB_ADMIN_PASSWORD + # When bind mounting we need to ensure that we # actually can write to the folder. chown activemq:activemq /opt/activemq/data +unset ACTIVEMQ_PASSWORD ACTIVEMQ_WEB_ADMIN_PASSWORD +unset DB_ROOT_PASSWORD exec s6-setuidgid activemq /opt/activemq/bin/activemq console diff --git a/images/activemq6/rootfs/usr/local/bin/activemq-healthcheck.sh b/images/activemq6/rootfs/usr/local/bin/activemq-healthcheck.sh new file mode 100755 index 00000000..92a25067 --- /dev/null +++ b/images/activemq6/rootfs/usr/local/bin/activemq-healthcheck.sh @@ -0,0 +1,25 @@ +#!/command/with-contenv bash +# shellcheck shell=bash + +set -euo pipefail + +# shellcheck disable=SC1091 +source /usr/local/share/libops/environment.sh +require_environment_variables ACTIVEMQ_WEB_ADMIN_NAME ACTIVEMQ_WEB_ADMIN_PASSWORD + +umask 077 +curl_config=$(mktemp -t activemq-healthcheck.XXXXXXXXXX) +trap 'rm -f -- "${curl_config}"' EXIT +chmod 0600 "${curl_config}" + +# Keep the credential out of curl's argv. The temporary curl configuration is +# root-only and contains only a derived Basic authorization value. +authorization=$(printf '%s:%s' "${ACTIVEMQ_WEB_ADMIN_NAME}" "${ACTIVEMQ_WEB_ADMIN_PASSWORD}" | base64 | tr -d '\n') +{ + printf '%s\n' 'fail' 'silent' 'show-error' + printf 'header = "Origin: localhost"\n' + printf 'header = "Authorization: Basic %s"\n' "${authorization}" + printf 'url = "%s"\n' 'http://localhost:8161/api/jolokia/read/org.apache.activemq:type=Broker,brokerName=localhost,service=Health/CurrentStatus' +} >"${curl_config}" + +curl --config "${curl_config}" | jq -e '.value == "Good"' >/dev/null diff --git a/images/activemq6/tests/ServiceHealthcheck/docker-compose.yml b/images/activemq6/tests/ServiceHealthcheck/docker-compose.yml index b72dd41d..7d3d45d9 100644 --- a/images/activemq6/tests/ServiceHealthcheck/docker-compose.yml +++ b/images/activemq6/tests/ServiceHealthcheck/docker-compose.yml @@ -8,6 +8,14 @@ services: activemq: <<: *common image: ${ACTIVEMQ:-libops/activemq:local} + environment: + ACTIVEMQ_PASSWORD: test-broker-password + ACTIVEMQ_USER_ESCAPE_NAME: 'broker:user=name' + ACTIVEMQ_USER_ESCAPE_PASSWORD: ' broker:p\a=ss:word' + ACTIVEMQ_GROUP_ESCAPE_NAME: 'operators:admin=team' + ACTIVEMQ_GROUP_ESCAPE_MEMBERS: 'broker:user=name' + ACTIVEMQ_WEB_ADMIN_NAME: healthcheck-user + ACTIVEMQ_WEB_ADMIN_PASSWORD: 'web:p"a\ss&+=word' volumes: - ./test.sh:/test.sh # Test to run. command: diff --git a/images/activemq6/tests/ServiceHealthcheck/test.sh b/images/activemq6/tests/ServiceHealthcheck/test.sh index dd1d6bdf..a7317ec5 100755 --- a/images/activemq6/tests/ServiceHealthcheck/test.sh +++ b/images/activemq6/tests/ServiceHealthcheck/test.sh @@ -2,13 +2,16 @@ # shellcheck shell=bash # Invoked indirectly by the SIGTERM trap below. -# shellcheck disable=SC2329 +# shellcheck disable=SC2317,SC2329 on_terminate() { echo "Termination signal received. Exiting..." exit 0 } trap 'on_terminate' SIGTERM +grep -Fqx 'broker\:user\=name=\ broker:p\\a=ss:word' /opt/activemq/conf/users.properties +grep -Fqx 'operators\:admin\=team=broker:user=name' /opt/activemq/conf/groups.properties + sleep 60 # The kotlin check should be stopping this container diff --git a/images/activemq6/tests/ServiceStartsWithDefaults/docker-compose.yml b/images/activemq6/tests/ServiceStartsWithDefaults/docker-compose.yml index ebca7ded..652d1963 100644 --- a/images/activemq6/tests/ServiceStartsWithDefaults/docker-compose.yml +++ b/images/activemq6/tests/ServiceStartsWithDefaults/docker-compose.yml @@ -11,6 +11,9 @@ services: activemq: <<: *common image: ${ACTIVEMQ:-libops/activemq:local} + environment: + ACTIVEMQ_PASSWORD: test-broker-password + ACTIVEMQ_WEB_ADMIN_PASSWORD: test-web-password volumes: - ./test.sh:/test.sh # Test to run. command: diff --git a/images/alpaca/Dockerfile b/images/alpaca/Dockerfile index deb46686..26056c00 100644 --- a/images/alpaca/Dockerfile +++ b/images/alpaca/Dockerfile @@ -90,7 +90,7 @@ ENV \ ALPACA_JAVA_OPTS= \ ALPACA_JMS_CONNECTIONS=10 \ ALPACA_JMS_CONSUMERS=1 \ - ALPACA_JMS_PASSWORD=password \ + ALPACA_JMS_PASSWORD= \ ALPACA_JMS_URL=tcp://activemq:61616 \ ALPACA_JMS_USER=admin \ ALPACA_MAX_REDELIVERIES=5 \ diff --git a/images/alpaca/README.md b/images/alpaca/README.md index 65a6a4b5..83bc8de4 100644 --- a/images/alpaca/README.md +++ b/images/alpaca/README.md @@ -1,6 +1,6 @@ # Alpaca -Docker image for [Alpaca] version 2.2.0. +Docker image for [Alpaca] version 2.4.0. Built from [libops/isle-buildkit alpaca](https://github.com/libops/buildkit/tree/main/images/alpaca) @@ -64,7 +64,7 @@ additional settings, volumes, ports, etc. | ALPACA_JAVA_OPTS | | | | ALPACA_JMS_CONNECTIONS | 10 | | | ALPACA_JMS_CONSUMERS | 1 | | -| ALPACA_JMS_PASSWORD | password | Password to authenticate with | +| ALPACA_JMS_PASSWORD | | Password to authenticate with; required at startup | | ALPACA_JMS_URL | tcp://activemq:61616 | The url for connecting to the ActiveMQ broker, shared by all components | | ALPACA_JMS_USER | admin | User to authenticate as | | ALPACA_MAX_REDELIVERIES | 5 | Number of attempts to redeliver if an exception occurs | diff --git a/images/alpaca/rootfs/etc/confd/conf.d/alpaca.properties.toml b/images/alpaca/rootfs/etc/confd/conf.d/alpaca.properties.toml index 3958ca18..2d84a064 100644 --- a/images/alpaca/rootfs/etc/confd/conf.d/alpaca.properties.toml +++ b/images/alpaca/rootfs/etc/confd/conf.d/alpaca.properties.toml @@ -3,5 +3,5 @@ src = "alpaca.properties.tmpl" dest = "/opt/alpaca/alpaca.properties" uid = 100 gid = 1000 -mode = "0644" +mode = "0640" keys = [ "/" ] diff --git a/images/alpaca/rootfs/etc/s6-overlay/s6-rc.d/alpaca/run b/images/alpaca/rootfs/etc/s6-overlay/s6-rc.d/alpaca/run index 20904782..9bfc18b7 100755 --- a/images/alpaca/rootfs/etc/s6-overlay/s6-rc.d/alpaca/run +++ b/images/alpaca/rootfs/etc/s6-overlay/s6-rc.d/alpaca/run @@ -2,12 +2,19 @@ # shellcheck shell=bash set -e +# shellcheck disable=SC1091 +source /usr/local/share/libops/environment.sh + +require_environment_variables ALPACA_JMS_USER ALPACA_JMS_PASSWORD + alpaca_java_opts=() if [ -n "${ALPACA_JAVA_OPTS:-}" ]; then read -r -a alpaca_java_opts <<<"$ALPACA_JAVA_OPTS" fi +unset ALPACA_JMS_PASSWORD +unset DB_ROOT_PASSWORD exec s6-setuidgid alpaca java "${alpaca_java_opts[@]}" \ -jar /opt/alpaca/alpaca.jar \ -c /opt/alpaca/alpaca.properties diff --git a/images/alpaca/tests/ServiceStartsWithDefaults/docker-compose.yml b/images/alpaca/tests/ServiceStartsWithDefaults/docker-compose.yml index e6619be0..d6d016e4 100644 --- a/images/alpaca/tests/ServiceStartsWithDefaults/docker-compose.yml +++ b/images/alpaca/tests/ServiceStartsWithDefaults/docker-compose.yml @@ -8,8 +8,13 @@ services: alpaca: <<: *common image: ${ALPACA:-libops/alpaca:local} + environment: + ALPACA_JMS_PASSWORD: test-broker-password depends_on: - activemq activemq: <<: *common image: ${ACTIVEMQ:-libops/activemq:local} + environment: + ACTIVEMQ_PASSWORD: test-broker-password + ACTIVEMQ_WEB_ADMIN_PASSWORD: test-web-password diff --git a/images/archivesspace-solr/Dockerfile b/images/archivesspace-solr/Dockerfile index 92616191..a1f6b296 100644 --- a/images/archivesspace-solr/Dockerfile +++ b/images/archivesspace-solr/Dockerfile @@ -1,6 +1,8 @@ # syntax=docker/dockerfile:1.20.0 FROM solr9 +LABEL org.opencontainers.image.licenses="Apache-2.0 AND ECL-2.0" + ARG TARGETARCH # renovate: datasource=github-releases depName=archivesspace packageName=archivesspace/archivesspace @@ -17,6 +19,8 @@ RUN --mount=type=cache,id=archivesspace-solr-downloads-${TARGETARCH},sharing=loc && \ mkdir -p /opt/solr/server/solr/configsets/archivesspace/conf && \ cp -R /tmp/archivesspace-release/archivesspace/solr/. /opt/solr/server/solr/configsets/archivesspace/conf/ && \ + mkdir -p /usr/share/licenses/archivesspace-solr && \ + cp /tmp/archivesspace-release/archivesspace/COPYING /usr/share/licenses/archivesspace-solr/COPYING && \ mkdir -p /opt/solr/server/solr/archivesspace && \ cp -R /opt/solr/server/solr/configsets/archivesspace/. /opt/solr/server/solr/archivesspace/ && \ printf 'name=archivesspace\n' > /opt/solr/server/solr/archivesspace/core.properties && \ diff --git a/images/archivesspace/Dockerfile b/images/archivesspace/Dockerfile index cec599c4..e051cd0f 100644 --- a/images/archivesspace/Dockerfile +++ b/images/archivesspace/Dockerfile @@ -1,6 +1,8 @@ # syntax=docker/dockerfile:1.20.0 FROM java17 +LABEL org.opencontainers.image.licenses="ECL-2.0" + ARG TARGETARCH ARG \ @@ -62,6 +64,8 @@ RUN --mount=type=cache,id=archivesspace-downloads-${TARGETARCH},sharing=locked,t --sha256 "${MYSQL_CONNECTOR_SHA256}" \ --dest /archivesspace/lib \ && \ + mkdir -p /usr/share/licenses/archivesspace && \ + cp /archivesspace/COPYING /usr/share/licenses/archivesspace/COPYING && \ chmod +x /archivesspace/archivesspace.sh /archivesspace/scripts/*.sh && \ chown -R archivesspace:archivesspace /archivesspace && \ cleanup.sh @@ -74,7 +78,7 @@ ENV \ ASPACE_JAVA_XMX=-Xmx2048m \ DB_HOST=mariadb \ DB_NAME=archivesspace \ - DB_PASSWORD=changeme \ + DB_PASSWORD= \ DB_PORT=3306 \ DB_USER=archivesspace \ INGRESS_HOSTNAMES=localhost \ diff --git a/images/archivesspace/rootfs/etc/confd/templates/archivesspace.config.rb.tmpl b/images/archivesspace/rootfs/etc/confd/templates/archivesspace.config.rb.tmpl index eec1bd6f..b541d5c7 100644 --- a/images/archivesspace/rootfs/etc/confd/templates/archivesspace.config.rb.tmpl +++ b/images/archivesspace/rootfs/etc/confd/templates/archivesspace.config.rb.tmpl @@ -10,7 +10,11 @@ {{ $appConfigSolrURL := getenv "APPCONFIG_SOLR_URL" }} {{ $smtpFrom := getenv "SMTP_FROM" }} {{ $smtpHost := getenv "SMTP_HOST" }} -AppConfig[:db_url] = 'jdbc:mysql://{{ template "sqlEscape" $dbHost }}:{{ template "sqlEscape" $dbPort }}/{{ template "sqlEscape" $dbName }}?useUnicode=true&characterEncoding=UTF-8&user={{ template "sqlEscape" $dbUser }}&password={{ template "sqlEscape" $dbPassword }}&useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC' +require 'uri' + +db_user = URI.encode_www_form_component('{{ template "sqlEscape" $dbUser }}') +db_password = URI.encode_www_form_component('{{ template "sqlEscape" $dbPassword }}') +AppConfig[:db_url] = 'jdbc:mysql://{{ template "sqlEscape" $dbHost }}:{{ template "sqlEscape" $dbPort }}/{{ template "sqlEscape" $dbName }}?useUnicode=true&characterEncoding=UTF-8&user=' + db_user + '&password=' + db_password + '&useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC' {{ if ne (getenv "APPCONFIG_SOLR_URL") "" -}} AppConfig[:solr_url] = '{{ template "sqlEscape" $appConfigSolrURL }}' {{ else -}} diff --git a/images/archivesspace/rootfs/etc/s6-overlay/s6-rc.d/archivesspace/run b/images/archivesspace/rootfs/etc/s6-overlay/s6-rc.d/archivesspace/run index b2d1e63a..f754f7cb 100755 --- a/images/archivesspace/rootfs/etc/s6-overlay/s6-rc.d/archivesspace/run +++ b/images/archivesspace/rootfs/etc/s6-overlay/s6-rc.d/archivesspace/run @@ -16,4 +16,5 @@ trap terminate TERM setsid /usr/local/bin/archivesspace-startup.sh & child=$! +unset DB_ROOT_PASSWORD wait "${child}" diff --git a/images/archivesspace/rootfs/usr/local/bin/archivesspace-startup.sh b/images/archivesspace/rootfs/usr/local/bin/archivesspace-startup.sh index 15d41b48..e7b73616 100755 --- a/images/archivesspace/rootfs/usr/local/bin/archivesspace-startup.sh +++ b/images/archivesspace/rootfs/usr/local/bin/archivesspace-startup.sh @@ -2,26 +2,21 @@ # shellcheck shell=bash set -euo pipefail +# shellcheck disable=SC1091 +source /usr/local/share/libops/database.sh +# shellcheck disable=SC1091 +source /usr/local/share/libops/environment.sh + +require_environment_variables DB_HOST DB_NAME DB_USER DB_PASSWORD + data_dir="${APPCONFIG_DATA_DIR:-/archivesspace/data}" mkdir -p "${data_dir}/tmp" chown -R archivesspace:archivesspace "${data_dir}" /archivesspace/config -function root_password { - printf '%s' "${DB_ROOT_PASSWORD:-}" -} - function create_archivesspace_database { - local root_password_value - root_password_value="$(root_password)" - cat <<-SQL | DB_ROOT_PASSWORD="${root_password_value}" create-database.sh -CREATE DATABASE IF NOT EXISTS ${DB_NAME} CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; - -CREATE USER IF NOT EXISTS '${DB_USER}'@'%' IDENTIFIED BY '${DB_PASSWORD}'; -GRANT ALL PRIVILEGES ON ${DB_NAME}.* to '${DB_USER}'@'%'; -FLUSH PRIVILEGES; - -SET PASSWORD FOR '${DB_USER}'@'%' = PASSWORD('${DB_PASSWORD}'); -SQL + DB_CHARACTER_SET=utf8mb4 \ + DB_COLLATION=utf8mb4_unicode_ci \ + render-database-bootstrap-sql.sh | create-database.sh } if [ -n "${ASPACE_INITIALIZE_PLUGINS:-}" ]; then @@ -34,10 +29,9 @@ fi rm -rf "${data_dir:?}/tmp"/* if [ "${ASPACE_DB_MIGRATE:-true}" = "true" ]; then - if [ -n "$(root_password)" ]; then - create_archivesspace_database - fi + database_bootstrap_if_enabled create_archivesspace_database s6-setuidgid archivesspace /archivesspace/scripts/setup-database.sh fi +unset DB_ROOT_PASSWORD exec s6-setuidgid archivesspace /archivesspace/archivesspace.sh diff --git a/images/archivesspace/tests/ServiceHealthcheck/docker-compose.yml b/images/archivesspace/tests/ServiceHealthcheck/docker-compose.yml index bdac72ec..6410d5ff 100644 --- a/images/archivesspace/tests/ServiceHealthcheck/docker-compose.yml +++ b/images/archivesspace/tests/ServiceHealthcheck/docker-compose.yml @@ -5,8 +5,7 @@ services: image: ${ARCHIVESSPACE:-libops/archivesspace:local} environment: ASPACE_DB_MIGRATE: "true" - DB_ROOT_PASSWORD: root - DB_PASSWORD: password + DB_PASSWORD: "archive&=space%#password" APPCONFIG_SOLR_URL: http://solr:8983/solr/archivesspace depends_on: mariadb: @@ -19,6 +18,6 @@ services: DB_ROOT_PASSWORD: root DB_NAME: archivesspace DB_USER: archivesspace - DB_PASSWORD: password + DB_PASSWORD: "archive&=space%#password" solr: image: ${ARCHIVESSPACE_SOLR:-libops/archivesspace-solr:local} diff --git a/images/base/Dockerfile b/images/base/Dockerfile index 5c4c1203..113412d7 100644 --- a/images/base/Dockerfile +++ b/images/base/Dockerfile @@ -140,15 +140,16 @@ ENV \ # environment variables via ENV. ENV \ APP_UID=100 \ + DB_BOOTSTRAP_ENABLED=false \ DB_HOST=mariadb \ DB_NAME=default \ - DB_PASSWORD=password \ + DB_PASSWORD= \ DB_PORT=3306 \ - DB_ROOT_PASSWORD=password \ + DB_ROOT_PASSWORD= \ DB_ROOT_USER=root \ DB_USER=default \ DEVELOPMENT_ENVIRONMENT=false \ - JWT_ADMIN_TOKEN=islandora \ + JWT_ADMIN_TOKEN= \ LIBOPS_PROJECT_ID= \ LIBOPS_SITE_ID= \ UID= \ diff --git a/images/base/README.md b/images/base/README.md index a1bd09c1..97abbe8e 100644 --- a/images/base/README.md +++ b/images/base/README.md @@ -69,7 +69,7 @@ duplication. | Environment Variable | Default | Description | | :------------------- | :-------------------------------------------- | :----------------------------------------------------------------------------------------------------------- | -| JWT_ADMIN_TOKEN | islandora | Used for [bearer authentication] (Only use with HTTPS or over private networks) | +| JWT_ADMIN_TOKEN | | Used for [bearer authentication]; supply a secret for services that enable Syn | | JWT_PRIVATE_KEY | @see base/rootfs/etc/defaults/JWT_PRIVATE_KEY | Private key for JWT authentication, RSA PEM Format is expected (should only be used in the Drupal container) | | JWT_PUBLIC_KEY | @see base/rootfs/etc/defaults/JWT_PUBLIC_KEY | Public key for JWT authentication | @@ -91,15 +91,16 @@ add `jwt` as a secondary group to your service user. Database settings target MariaDB. -| Environment Variable | Default | Description | -| :------------------- | :--------- | :---------------------------------------------------------------------------------------------- | -| DB_HOST | mariadb | The database host to use | -| DB_NAME | default | The name of the default database if no other is specified | -| DB_PASSWORD | password | The password of the user used by the service (e.g. Drupal) to connect to the database | -| DB_PORT | 3306 | The database port to use | -| DB_ROOT_PASSWORD | password | The root user password | -| DB_ROOT_USER | root | The root user, which is used only on startup to create database / user in the chosen backend | -| DB_USER | default | The user used by the service (e.g. Drupal) to connect to the database | +| Environment Variable | Default | Description | +| :-------------------- | :------ | :----------------------------------------------------------------------------------------------------------- | +| DB_BOOTSTRAP_ENABLED | false | Explicitly allow an application setup service to create its database and user with root credentials | +| DB_HOST | mariadb | The database host to use | +| DB_NAME | default | The name of the default database if no other is specified | +| DB_PASSWORD | | The password of the scoped user used by the service; required by database-backed applications | +| DB_PORT | 3306 | The database port to use | +| DB_ROOT_PASSWORD | | The root password; required only when `DB_BOOTSTRAP_ENABLED=true` and preferably supplied as a file secret | +| DB_ROOT_USER | root | The root user, used only by an explicitly enabled database bootstrap | +| DB_USER | default | The user used by the service (e.g. Drupal) to connect to the database | ### Development Settings diff --git a/images/base/rootfs/etc/s6-overlay/s6-rc.d/confd/run b/images/base/rootfs/etc/s6-overlay/s6-rc.d/confd/run index 7c942dd3..79896310 100755 --- a/images/base/rootfs/etc/s6-overlay/s6-rc.d/confd/run +++ b/images/base/rootfs/etc/s6-overlay/s6-rc.d/confd/run @@ -2,6 +2,10 @@ # shellcheck shell=bash set -e +# This long-running renderer only needs the scoped application environment. +# Do not retain the database administrator credential after initialization. +unset DB_ROOT_PASSWORD + # Only run the service if explicitly told to do so. if [[ "${CONFD_ENABLE_SERVICE}" == "true" ]]; then exec confd-render-templates.sh -- -interval "${CONFD_POLLING_INTERVAL}" diff --git a/images/base/rootfs/etc/s6-overlay/scripts/container-environment.sh b/images/base/rootfs/etc/s6-overlay/scripts/container-environment.sh index 94984d63..3bce05ea 100755 --- a/images/base/rootfs/etc/s6-overlay/scripts/container-environment.sh +++ b/images/base/rootfs/etc/s6-overlay/scripts/container-environment.sh @@ -1,5 +1,6 @@ #!/usr/bin/env bash set -e +umask 077 # Sets container environment variables in order of precedence depending on the # source: @@ -63,9 +64,17 @@ fi # Normalize the container environment through confd's env backend. This can only # import values that are already defined in the environment. { + # shellcheck disable=SC2016 # Literal Go template; shell expansion is unwanted. + printf '%s\n' '{{ define "execlineEscape" -}}{{- $escaped := replace . "\\" "\\\\" -1 -}}{{- replace $escaped "\"" "\\\"" -1 -}}{{- end -}}' for file in /var/run/s6/container_environment/*; do VAR=$(basename "${file}") KEY=$(echo "${VAR}" | tr '[:upper:]' '[:lower:]' | tr '_' '/') - echo "${VAR}=\"{{ getv \"/${KEY}\" (getenv \"${VAR}\") }}\"" + echo "${VAR}=\"{{ template \"execlineEscape\" (getv \"/${KEY}\" (getenv \"${VAR}\")) }}\"" done } | /usr/local/bin/confd-import-environment.sh + +# These files can include database administrator and application credentials. +# Startup scripts run as root and import them before dropping privileges, so +# they do not need to be readable by application service users. +chmod 0700 /var/run/s6/container_environment +find /var/run/s6/container_environment -maxdepth 1 -type f -exec chmod 0600 {} + diff --git a/images/base/rootfs/etc/s6-overlay/scripts/vault-secrets.sh b/images/base/rootfs/etc/s6-overlay/scripts/vault-secrets.sh index ef07f8bb..f228381b 100755 --- a/images/base/rootfs/etc/s6-overlay/scripts/vault-secrets.sh +++ b/images/base/rootfs/etc/s6-overlay/scripts/vault-secrets.sh @@ -341,7 +341,11 @@ write_secret_file() { chmod 0400 "${tmp}" if [[ "$(id -u)" = "0" ]]; then - chown "${app_uid}:0" "${tmp}" + if [[ "${name}" = "DB_ROOT_PASSWORD" ]]; then + chown 0:0 "${tmp}" + else + chown "${app_uid}:0" "${tmp}" + fi fi mv -f "${tmp}" "${target}" } diff --git a/images/base/rootfs/usr/local/bin/cleanup.sh b/images/base/rootfs/usr/local/bin/cleanup.sh index 27c1a9c7..7557c474 100755 --- a/images/base/rootfs/usr/local/bin/cleanup.sh +++ b/images/base/rootfs/usr/local/bin/cleanup.sh @@ -22,36 +22,25 @@ EOF } function cmdline { - local arg= - for arg; do - local delim="" - case "$arg" in - # Translate --gnu-long-options to -g (short options) - --help) args="${args}-h " ;; - --debug) args="${args}-x " ;; - # Pass through anything else - *) - [[ "${arg:0:1}" == "-" ]] || delim="\"" - args="${args}${delim}${arg}${delim} " - ;; - esac - done - - # Reset the positional parameters to the short options - eval set -- "${args}" - - while getopts "hx" OPTION; do - case $OPTION in - h) + while (($# > 0)); do + case "$1" in + -h|--help) usage exit 0 ;; - x) + -x|--debug) set -x + shift + ;; + --) + shift + if (($# > 0)); then + echo "cleanup.sh does not accept positional arguments" >&2 + exit 1 + fi ;; *) - echo "Invalid Option: $OPTION" >&2 - usage + echo "Invalid option or argument: $1" >&2 exit 1 ;; esac @@ -63,7 +52,9 @@ function cmdline { function main { cmdline "${ARGS[@]}" for file in /etc/cleanup.d/*; do - $file + if [[ -f "${file}" && -x "${file}" ]]; then + "${file}" + fi done } main diff --git a/images/base/rootfs/usr/local/bin/confd-import-environment.sh b/images/base/rootfs/usr/local/bin/confd-import-environment.sh index 7782a049..a06c3ee7 100755 --- a/images/base/rootfs/usr/local/bin/confd-import-environment.sh +++ b/images/base/rootfs/usr/local/bin/confd-import-environment.sh @@ -29,36 +29,25 @@ EOF } function cmdline() { - local arg= - for arg; do - local delim="" - case "$arg" in - # Translate --gnu-long-options to -g (short options) - --help) args="${args}-h " ;; - --debug) args="${args}-x " ;; - # Pass through anything else - *) - [[ "${arg:0:1}" == "-" ]] || delim="\"" - args="${args}${delim}${arg}${delim} " - ;; - esac - done - - # Reset the positional parameters to the short options - eval set -- "${args}" - - while getopts "hx" OPTION; do - case $OPTION in - h) + while (($# > 0)); do + case "$1" in + -h|--help) usage exit 0 ;; - x) + -x|--debug) set -x + shift + ;; + --) + shift + if (($# > 0)); then + echo "confd-import-environment.sh does not accept positional arguments" >&2 + exit 1 + fi ;; *) - echo "Invalid Option: $OPTION" >&2 - usage + echo "Invalid option or argument: $1" >&2 exit 1 ;; esac @@ -68,12 +57,12 @@ function cmdline() { } function main { - local tmp_dir cmdline "${ARGS[@]}" # Temporary directory to deposit generated confd configuration templates and # output, etc. tmp_dir="$(mktemp -d -t confd-XXXXXXXXXX)" + trap 'rm -rf -- "${tmp_dir}"' EXIT HUP INT TERM mkdir -p "${tmp_dir}/conf.d" "${tmp_dir}/templates" "${tmp_dir}/out" # Generate template script that will update the container environment with @@ -104,7 +93,5 @@ EOF # Import the variables from confd. execlineb -P "${tmp_dir}/import.sh" - # Remove temporary files. - rm -fr "${tmp_dir}" } main diff --git a/images/base/rootfs/usr/local/bin/confd-render-templates.sh b/images/base/rootfs/usr/local/bin/confd-render-templates.sh index 7c8baf0e..902478d7 100755 --- a/images/base/rootfs/usr/local/bin/confd-render-templates.sh +++ b/images/base/rootfs/usr/local/bin/confd-render-templates.sh @@ -26,50 +26,31 @@ EOF } function cmdline { - local arg= - for arg; do - local delim="" - case "$arg" in - # Translate --gnu-long-options to -g (short options) - --help) args="${args}-h " ;; - --debug) args="${args}-x " ;; - # Pass through anything else - *) - [[ "${arg:0:1}" == "-" ]] || delim="\"" - args="${args}${delim}${arg}${delim} " - ;; - esac - done + OPTIONS=() - # Reset the positional parameters to the short options - eval set -- "${args}" - - while getopts "hx" OPTION; do - case $OPTION in - h) + while (($# > 0)); do + case "$1" in + -h|--help) usage exit 0 ;; - x) + -x|--debug) set -x + shift + ;; + --) + shift + OPTIONS+=("$@") + break ;; *) - echo "Invalid Option: $OPTION" >&2 - usage - exit 1 + OPTIONS+=("$1") + shift ;; esac done - shift $((OPTIND - 1)) - - # Remaining options to be passed onto the client, preceeded by '--'. - if [ "$#" -gt 0 ]; then - readonly OPTIONS=("${@}") - shift $# - else - readonly OPTIONS=() - fi + readonly OPTIONS return 0 } diff --git a/images/base/rootfs/usr/local/bin/create-database.sh b/images/base/rootfs/usr/local/bin/create-database.sh index 0a957259..c62646db 100755 --- a/images/base/rootfs/usr/local/bin/create-database.sh +++ b/images/base/rootfs/usr/local/bin/create-database.sh @@ -51,104 +51,95 @@ function fallback { } function cmdline { - local arg= - for arg; do - local delim="" - case "$arg" in - # Translate --gnu-long-options to -g (short options) - --host) args="${args}-b " ;; - --port) args="${args}-c " ;; - --user) args="${args}-d " ;; - --password) args="${args}-e " ;; - --database) args="${args}-f " ;; - --help) args="${args}-h " ;; - --debug) args="${args}-x " ;; - # Pass through anything else - *) - [[ "${arg:0:1}" == "-" ]] || delim="\"" - args="${args}${delim}${arg}${delim} " - ;; - esac - done - - # Reset the positional parameters to the short options - eval set -- "${args}" - - while getopts "b:c:d:e:f:hx" OPTION; do - case $OPTION in - b) - readonly HOST=${OPTARG} - ;; - c) - readonly PORT=${OPTARG} - ;; - d) - readonly USER=${OPTARG} + HOST= + PORT= + USER= + PASSWORD= + DATABASE_NAME= + + while (($# > 0)); do + case "$1" in + --host|-b|--port|-c|--user|-d|--password|-e|--database|-f) + if (($# < 2)); then + echo "Option $1 requires a value" >&2 + exit 1 + fi + case "$1" in + --host|-b) HOST=$2 ;; + --port|-c) PORT=$2 ;; + --user|-d) USER=$2 ;; + --password|-e) PASSWORD=$2 ;; + --database|-f) DATABASE_NAME=$2 ;; + esac + shift 2 ;; - e) - readonly PASSWORD=${OPTARG} - ;; - f) - readonly DATABASE_NAME=${OPTARG} - ;; - h) + --host=*) HOST=${1#*=}; shift ;; + --port=*) PORT=${1#*=}; shift ;; + --user=*) USER=${1#*=}; shift ;; + --password=*) PASSWORD=${1#*=}; shift ;; + --database=*) DATABASE_NAME=${1#*=}; shift ;; + -h|--help) usage exit 0 ;; - x) + -x|--debug) set -x + shift ;; - *) - echo "Invalid Option: $OPTION" >&2 + --) shift; break ;; + -*) + echo "Invalid option: $1" >&2 usage exit 1 ;; + *) + break + ;; esac done if fallback "--database" "DATABASE_NAME" "DB_NAME"; then - readonly DATABASE_NAME=${DB_NAME} + DATABASE_NAME=${DB_NAME} fi if fallback "--user" "USER" "DB_ROOT_USER"; then - readonly USER=${DB_ROOT_USER} + USER=${DB_ROOT_USER} fi if fallback "--password" "PASSWORD" "DB_ROOT_PASSWORD"; then - readonly PASSWORD=${DB_ROOT_PASSWORD} + PASSWORD=${DB_ROOT_PASSWORD} fi if fallback "--host" "HOST" "DB_HOST"; then - readonly HOST=${DB_HOST} + HOST=${DB_HOST} fi if fallback "--port" "PORT" "DB_PORT"; then - readonly PORT=${DB_PORT} + PORT=${DB_PORT} fi - shift $((OPTIND - 1)) - # Allow either passing in a file/pipe or reading from stdin by specifiying "-" or # ommiting completely. - if [[ -f "${1}" || -p "${1}" ]]; then - readonly FILE="${1}" + if [[ -n "${1:-}" && ( -f "$1" || -p "$1" ) ]]; then + FILE=$1 shift - elif [[ "${1}" == "-" ]]; then - readonly FILE=/dev/stdin + elif [[ "${1:-}" == "-" ]]; then + FILE=/dev/stdin shift else - readonly FILE=/dev/stdin + FILE=/dev/stdin fi + readonly HOST PORT USER PASSWORD DATABASE_NAME FILE + return 0 } function execute_sql_file { - execute-sql-file.sh \ + LIBOPS_DATABASE_PASSWORD="${PASSWORD}" execute-sql-file.sh \ --host "${HOST}" \ --port "${PORT}" \ --user "${USER}" \ - --password "${PASSWORD}" \ "${@}" } diff --git a/images/base/rootfs/usr/local/bin/create-service-user.sh b/images/base/rootfs/usr/local/bin/create-service-user.sh index fe8e39d8..712b9e3f 100755 --- a/images/base/rootfs/usr/local/bin/create-service-user.sh +++ b/images/base/rootfs/usr/local/bin/create-service-user.sh @@ -28,58 +28,55 @@ EOF } function cmdline() { - local arg= - for arg; do - local delim="" - case "$arg" in - # Translate --gnu-long-options to -g (short options) - --name) args="${args}-n " ;; - --group) args="${args}-g " ;; - --help) args="${args}-h " ;; - --debug) args="${args}-x " ;; - # Pass through anything else - *) - [[ "${arg:0:1}" == "-" ]] || delim="\"" - args="${args}${delim}${arg}${delim} " - ;; - esac - done - - # Reset the positional parameters to the short options - eval set -- "${args}" + NAME= + GROUP= + DIRECTORIES=() - while getopts "n:g:hx" OPTION; do - case $OPTION in - n) - readonly NAME=${OPTARG} - ;; - g) - readonly GROUP=${OPTARG} + while (($# > 0)); do + case "$1" in + -n|--name|-g|--group) + if (($# < 2)); then + echo "Option $1 requires a value" >&2 + exit 1 + fi + case "$1" in + -n|--name) NAME=$2 ;; + -g|--group) GROUP=$2 ;; + esac + shift 2 ;; - h) + --name=*) NAME=${1#*=}; shift ;; + --group=*) GROUP=${1#*=}; shift ;; + -h|--help) usage exit 0 ;; - x) + -x|--debug) set -x + shift ;; - *) - echo "Invalid Option: $OPTION" >&2 - usage + --) + shift + DIRECTORIES+=("$@") + break + ;; + -*) + echo "Invalid option: $1" >&2 exit 1 ;; + *) + DIRECTORIES+=("$1") + shift + ;; esac done - if [[ ! -v NAME ]]; then + if [[ -z ${NAME} ]]; then echo "Missing one or more required options: --name" >&2 exit 1 fi - # All remaning parameters are directories to be created. - shift $((OPTIND - 1)) - DIRECTORIES=("$@") - readonly DIRECTORIES + readonly NAME GROUP DIRECTORIES return 0 } @@ -104,7 +101,7 @@ function main { addgroup "${user}" tty fi # Optional secondary group. - if [[ -v GROUP ]]; then + if [[ -n "${GROUP}" ]]; then if ! id -nG "${user}" | tr ' ' '\n' | grep -qx "${GROUP}"; then addgroup "${NAME}" "${GROUP}" fi diff --git a/images/base/rootfs/usr/local/bin/download.sh b/images/base/rootfs/usr/local/bin/download.sh index 413a1bed..f6a5317a 100755 --- a/images/base/rootfs/usr/local/bin/download.sh +++ b/images/base/rootfs/usr/local/bin/download.sh @@ -34,73 +34,73 @@ EOF } function cmdline { - local arg= - local args= - for arg; do - local delim="" - case "$arg" in - # Translate --gnu-long-options to -g (short options) - --url) args="${args}-u " ;; - --sha256) args="${args}-c " ;; - --dest) args="${args}-d " ;; - --strip) args="${args}-s " ;; - --help) args="${args}-h " ;; - --debug) args="${args}-x " ;; - # Pass through anything else - *) - [[ "${arg:0:1}" == "-" ]] || delim="\"" - args="${args}${delim}${arg}${delim} " - ;; - esac - done - - # Reset the positional parameters to the short options - eval set -- "${args}" - - while getopts "u:c:d:shx" OPTION; do - case $OPTION in - u) - readonly URL=${OPTARG} - ;; - c) - readonly CHECKSUM=${OPTARG} + URL= + CHECKSUM= + DEST= + STRIP=false + REMOVE=() + + while (($# > 0)); do + case "$1" in + -u|--url|-c|--sha256|-d|--dest) + if (($# < 2)); then + echo "Option $1 requires a value" >&2 + exit 1 + fi + case "$1" in + -u|--url) URL=$2 ;; + -c|--sha256) CHECKSUM=$2 ;; + -d|--dest) DEST=$2 ;; + esac + shift 2 ;; - d) - readonly DEST=${OPTARG} + --url=*) URL=${1#*=}; shift ;; + --sha256=*) CHECKSUM=${1#*=}; shift ;; + --dest=*) DEST=${1#*=}; shift ;; + -s|--strip) + STRIP=true + shift ;; - s) - readonly STRIP=true - ;; - h) + -h|--help) usage exit 0 ;; - x) + -x|--debug) set -x + shift ;; - *) - echo "Invalid Option: $OPTION" >&2 - usage + --) + shift + REMOVE+=("$@") + break + ;; + -*) + echo "Invalid option: $1" >&2 exit 1 ;; + *) + REMOVE+=("$1") + shift + ;; esac done - if [[ -z $URL || -z $CHECKSUM ]]; then - echo "Missing one or more required options: --url --sha256" + if [[ -z ${URL} || -z ${CHECKSUM} ]]; then + echo "Missing one or more required options: --url --sha256" >&2 exit 1 fi - # All remaning parameters are files to be removed from the installation. - shift $((OPTIND-1)) - readonly REMOVE=("$@") + readonly URL CHECKSUM DEST STRIP REMOVE return 0 } function validate { local file=${1} - sha256sum "${file}" | cut -f1 -d' ' | xargs test "${CHECKSUM}" == + local actual + actual=$(sha256sum <"${file}") + actual=${actual%% *} + [[ "${actual}" == "${CHECKSUM}" ]] } function unpack { @@ -109,7 +109,7 @@ function unpack { local args=() local filename= mkdir -p "${dest}" - if [[ -v STRIP ]]; then + if [[ "${STRIP}" == "true" ]]; then args+=("--strip-components" "1") fi filename=$(basename "${file}") @@ -124,15 +124,22 @@ function unpack { gunzip "${file}" -f -c > "${dest}/${filename%.*}" ;; *.zip | *.war) - if [[ -v STRIP ]]; then - mkdir -p /tmp/unpack - unzip "${file}" -d /tmp/unpack - local unpack_root - unpack_root="$(find /tmp/unpack/ -type d -mindepth 1 -maxdepth 1)" - shopt -s dotglob nullglob - mv "${unpack_root}"/* "${dest}" - shopt -u dotglob nullglob - rm -fr /tmp/unpack + if [[ "${STRIP}" == "true" ]]; then + ( + local unpack_dir unpack_root + local top_level=() + unpack_dir=$(mktemp -d -t download-unpack.XXXXXXXXXX) + trap 'rm -rf -- "${unpack_dir}"' EXIT HUP INT TERM + unzip "${file}" -d "${unpack_dir}" + mapfile -d '' top_level < <(find "${unpack_dir}" -mindepth 1 -maxdepth 1 -print0) + if ((${#top_level[@]} != 1)) || [[ ! -d "${top_level[0]}" ]]; then + echo "ZIP strip requires exactly one top-level directory: ${file}" >&2 + exit 1 + fi + unpack_root=${top_level[0]} + shopt -s dotglob nullglob + mv "${unpack_root}"/* "${dest}" + ) else unzip "${file}" -d "${dest}" fi @@ -163,8 +170,11 @@ function main { wget -N -P "${DOWNLOAD_CACHE_DIRECTORY}" "${URL}" # Return non-zero if the checksum does not match the downloaded file. validate "${file}" - if [[ -v DEST ]]; then + if [[ -n "${DEST}" ]]; then unpack "${file}" "${DEST}" fi } -main + +if [[ "${DOWNLOAD_LIBRARY_ONLY:-false}" != "true" ]]; then + main +fi diff --git a/images/base/rootfs/usr/local/bin/execute-sql-file.sh b/images/base/rootfs/usr/local/bin/execute-sql-file.sh index dc7dcad2..3e19b479 100755 --- a/images/base/rootfs/usr/local/bin/execute-sql-file.sh +++ b/images/base/rootfs/usr/local/bin/execute-sql-file.sh @@ -4,6 +4,12 @@ ARGS=("$@") PROGNAME=$(basename "$0") readonly ARGS PROGNAME +# shellcheck source=images/base/rootfs/usr/local/share/libops/database.sh +source "${LIBOPS_DATABASE_LIBRARY:-/usr/local/share/libops/database.sh}" + +CLIENT_DEFAULTS_FILE= +REMOVE_CLIENT_DEFAULTS_FILE=false + function usage { cat <<-EOF usage: $PROGNAME options FILE @@ -23,6 +29,7 @@ function usage { --port The database port. --user The user to connect as. --password The password to use for the user. + --defaults-extra-file Existing MariaDB client option file containing the password. --database The database to run the sql command against. (Optional) -h --help Show this help. -x --debug Debug this script. @@ -54,58 +61,54 @@ function fallback { } function cmdline { - local arg= - for arg; do - local delim="" - case "$arg" in - # Translate --gnu-long-options to -g (short options) - --host) args="${args}-b " ;; - --port) args="${args}-c " ;; - --user) args="${args}-d " ;; - --password) args="${args}-e " ;; - --database) args="${args}-f " ;; - --help) args="${args}-h " ;; - --debug) args="${args}-x " ;; - # Pass through anything else - *) - [[ "${arg:0:1}" == "-" ]] || delim="\"" - args="${args}${delim}${arg}${delim} " + HOST= + PORT= + USER= + PASSWORD= + DATABASE= + DEFAULTS_EXTRA_FILE= + + while (($# > 0)); do + case "$1" in + --host|-b|--port|-c|--user|-d|--password|-e|--database|-f|--defaults-extra-file) + if (($# < 2)); then + echo "Option $1 requires a value" >&2 + exit 1 + fi + case "$1" in + --host|-b) HOST=$2 ;; + --port|-c) PORT=$2 ;; + --user|-d) USER=$2 ;; + --password|-e) PASSWORD=$2 ;; + --database|-f) DATABASE=$2 ;; + --defaults-extra-file) DEFAULTS_EXTRA_FILE=$2 ;; + esac + shift 2 ;; - esac - done - - # Reset the positional parameters to the short options - eval set -- "${args}" - - while getopts "b:c:d:e:f:hx" OPTION; do - case $OPTION in - b) - HOST=${OPTARG} - ;; - c) - PORT=${OPTARG} - ;; - d) - USER=${OPTARG} - ;; - e) - PASSWORD=${OPTARG} - ;; - f) - DATABASE=${OPTARG} - ;; - h) + --host=*) HOST=${1#*=}; shift ;; + --port=*) PORT=${1#*=}; shift ;; + --user=*) USER=${1#*=}; shift ;; + --password=*) PASSWORD=${1#*=}; shift ;; + --database=*) DATABASE=${1#*=}; shift ;; + --defaults-extra-file=*) DEFAULTS_EXTRA_FILE=${1#*=}; shift ;; + -h|--help) usage exit 0 ;; - x) + -x|--debug) set -x + shift ;; - *) - echo "Invalid Option: $OPTION" >&2 + --) shift; break ;; + -) break ;; + -*) + echo "Invalid option: $1" >&2 usage exit 1 ;; + *) + break + ;; esac done @@ -113,7 +116,9 @@ function cmdline { USER=${DB_ROOT_USER} fi - if fallback "--password" "PASSWORD" "DB_ROOT_PASSWORD"; then + if [[ -z "${PASSWORD}" && -n "${LIBOPS_DATABASE_PASSWORD:-}" ]]; then + PASSWORD=${LIBOPS_DATABASE_PASSWORD} + elif [[ -z "${PASSWORD}" && -z "${DEFAULTS_EXTRA_FILE}" ]] && fallback "--password" "PASSWORD" "DB_ROOT_PASSWORD"; then PASSWORD=${DB_ROOT_PASSWORD} fi @@ -125,14 +130,12 @@ function cmdline { PORT=${DB_PORT} fi - shift $((OPTIND - 1)) - # Allow either passing in a file or reading from stdin by specifiying "-" or # ommiting completely. - if [[ -f "${1}" || -p "${1}" ]]; then - FILE="${1}" + if [[ -n "${1:-}" && ( -f "$1" || -p "$1" ) ]]; then + FILE=$1 shift - elif [[ "${1}" == "-" ]]; then + elif [[ "${1:-}" == "-" ]]; then FILE=/dev/stdin shift else @@ -140,7 +143,7 @@ function cmdline { fi # Remaining options to be passed onto the client, preceeded by '--'. - if [[ "${1}" == "--" ]]; then + if [[ "${1:-}" == "--" ]]; then shift fi @@ -151,7 +154,12 @@ function cmdline { OPTIONS=() fi - readonly HOST PORT USER PASSWORD DATABASE FILE OPTIONS + if [[ -n "${DEFAULTS_EXTRA_FILE}" && ! -r "${DEFAULTS_EXTRA_FILE}" ]]; then + echo "MariaDB client option file is not readable: ${DEFAULTS_EXTRA_FILE}" >&2 + exit 1 + fi + + readonly HOST PORT USER PASSWORD DATABASE DEFAULTS_EXTRA_FILE FILE OPTIONS return 0 } @@ -163,33 +171,52 @@ function wait_for_access { --host "${HOST}" \ --port "${PORT}" \ --user "${USER}" \ - --password "${PASSWORD}" >&2 + --defaults-extra-file "${CLIENT_DEFAULTS_FILE}" >&2 } function mysql_execute_sql_file { - local database_arg= + local database_args=() if [[ -n "${DATABASE}" ]]; then - database_arg="--database=${DATABASE}" + database_args+=("--database=${DATABASE}") fi mariadb \ + --defaults-extra-file="${CLIENT_DEFAULTS_FILE}" \ --host="${HOST}" \ --port="${PORT}" \ --user="${USER}" \ - --password="${PASSWORD}" \ --protocol=tcp \ - "${database_arg}" \ + "${database_args[@]}" \ "${OPTIONS[@]}" \ <"${FILE}" } +function prepare_client_defaults_file { + if [[ -n "${DEFAULTS_EXTRA_FILE}" ]]; then + CLIENT_DEFAULTS_FILE=${DEFAULTS_EXTRA_FILE} + return 0 + fi + + CLIENT_DEFAULTS_FILE=$(mktemp -t mariadb-client.XXXXXXXXXX) + database_write_client_defaults_file "${CLIENT_DEFAULTS_FILE}" "${PASSWORD}" + REMOVE_CLIENT_DEFAULTS_FILE=true +} + +function cleanup_client_defaults_file { + if [[ "${REMOVE_CLIENT_DEFAULTS_FILE}" = "true" && -n "${CLIENT_DEFAULTS_FILE}" ]]; then + rm -f -- "${CLIENT_DEFAULTS_FILE}" + fi +} + function execute_sql_file { mysql_execute_sql_file } function main { cmdline "${ARGS[@]}" + prepare_client_defaults_file + trap cleanup_client_defaults_file EXIT wait_for_access execute_sql_file } diff --git a/images/base/rootfs/usr/local/bin/render-database-bootstrap-sql.sh b/images/base/rootfs/usr/local/bin/render-database-bootstrap-sql.sh new file mode 100755 index 00000000..0a1aaf8d --- /dev/null +++ b/images/base/rootfs/usr/local/bin/render-database-bootstrap-sql.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +set -euo pipefail + +# shellcheck source=images/base/rootfs/usr/local/share/libops/database.sh +source "${LIBOPS_DATABASE_LIBRARY:-/usr/local/share/libops/database.sh}" + +: "${DB_NAME:?DB_NAME is required}" +: "${DB_USER:?DB_USER is required}" +: "${DB_PASSWORD:?DB_PASSWORD is required}" + +DB_CHARACTER_SET=${DB_CHARACTER_SET:-utf8mb4} +DB_COLLATION=${DB_COLLATION:-utf8mb4_unicode_ci} + +database_validate_identifier DB_NAME "${DB_NAME}" +database_validate_identifier DB_USER "${DB_USER}" +database_validate_identifier DB_CHARACTER_SET "${DB_CHARACTER_SET}" +database_validate_identifier DB_COLLATION "${DB_COLLATION}" + +db_user_sql=$(database_escape_sql_literal "${DB_USER}") +db_password_sql=$(database_escape_sql_literal "${DB_PASSWORD}") + +cat <<-SQL +CREATE DATABASE IF NOT EXISTS \`${DB_NAME}\` CHARACTER SET ${DB_CHARACTER_SET} COLLATE ${DB_COLLATION}; +CREATE USER IF NOT EXISTS '${db_user_sql}'@'%' IDENTIFIED BY '${db_password_sql}'; +GRANT ALL PRIVILEGES ON \`${DB_NAME}\`.* TO '${db_user_sql}'@'%'; +SET PASSWORD FOR '${db_user_sql}'@'%' = PASSWORD('${db_password_sql}'); +FLUSH PRIVILEGES; +SQL diff --git a/images/base/rootfs/usr/local/bin/wait-for-database.sh b/images/base/rootfs/usr/local/bin/wait-for-database.sh index 70120f14..353c1c91 100755 --- a/images/base/rootfs/usr/local/bin/wait-for-database.sh +++ b/images/base/rootfs/usr/local/bin/wait-for-database.sh @@ -5,6 +5,12 @@ ARGS=("$@") PROGNAME=$(basename "$0") readonly ARGS PROGNAME +# shellcheck source=images/base/rootfs/usr/local/share/libops/database.sh +source "${LIBOPS_DATABASE_LIBRARY:-/usr/local/share/libops/database.sh}" + +CLIENT_DEFAULTS_FILE= +REMOVE_CLIENT_DEFAULTS_FILE=false + function usage { cat <<-EOF usage: $PROGNAME options @@ -19,6 +25,7 @@ function usage { --port The database port. --user The user to connect as. --password The password to use for the user. + --defaults-extra-file Existing MariaDB client option file containing the password. --timeout Time to wait for a connection to the database, defaults to 5 minutes. (Optional) -h --help Show this help. -x --debug Debug this script. @@ -34,77 +41,89 @@ EOF } function cmdline { - local arg= - for arg; do - local delim="" - case "$arg" in - # Translate --gnu-long-options to -g (short options) - --host) args="${args}-b " ;; - --port) args="${args}-c " ;; - --user) args="${args}-d " ;; - --password) args="${args}-e " ;; - --help) args="${args}-h " ;; - --debug) args="${args}-x " ;; - # Pass through anything else - *) - [[ "${arg:0:1}" == "-" ]] || delim="\"" - args="${args}${delim}${arg}${delim} " - ;; - esac - done - - # Reset the positional parameters to the short options - eval set -- "${args}" - - while getopts "b:c:d:e:hx" OPTION; do - case $OPTION in - b) - readonly HOST=${OPTARG} + HOST= + PORT= + USER= + PASSWORD= + DEFAULTS_EXTRA_FILE= + TIMEOUT=300 + + while (($# > 0)); do + case "$1" in + --host|-b|--port|-c|--user|-d|--password|-e|--timeout|-t|--defaults-extra-file) + if (($# < 2)); then + echo "Option $1 requires a value" >&2 + exit 1 + fi + case "$1" in + --host|-b) HOST=$2 ;; + --port|-c) PORT=$2 ;; + --user|-d) USER=$2 ;; + --password|-e) PASSWORD=$2 ;; + --timeout|-t) TIMEOUT=$2 ;; + --defaults-extra-file) DEFAULTS_EXTRA_FILE=$2 ;; + esac + shift 2 ;; - c) - readonly PORT=${OPTARG} - ;; - d) - readonly USER=${OPTARG} - ;; - e) - readonly PASSWORD=${OPTARG} - ;; - h) + --host=*) HOST=${1#*=}; shift ;; + --port=*) PORT=${1#*=}; shift ;; + --user=*) USER=${1#*=}; shift ;; + --password=*) PASSWORD=${1#*=}; shift ;; + --timeout=*) TIMEOUT=${1#*=}; shift ;; + --defaults-extra-file=*) DEFAULTS_EXTRA_FILE=${1#*=}; shift ;; + -h|--help) usage exit 0 ;; - x) + -x|--debug) set -x + shift ;; - *) - echo "Invalid Option: $OPTION" >&2 + --) shift; break ;; + -*) + echo "Invalid option: $1" >&2 usage exit 1 ;; + *) + break + ;; esac done - if [[ -z $HOST || -z $PORT || -z $USER || -z $PASSWORD ]]; then - echo "Missing one of required options: --host --port --user --password" >&2 + if [[ -z "${PASSWORD}" && -n "${LIBOPS_DATABASE_PASSWORD:-}" ]]; then + PASSWORD=${LIBOPS_DATABASE_PASSWORD} + elif [[ -z "${PASSWORD}" && -n "${DB_ROOT_PASSWORD:-}" ]]; then + PASSWORD=${DB_ROOT_PASSWORD} + fi + + if [[ -z $HOST || -z $PORT || -z $USER || ( -z $PASSWORD && -z $DEFAULTS_EXTRA_FILE ) ]]; then + echo "Missing one of required options: --host --port --user and --password/--defaults-extra-file" >&2 + exit 1 + fi + + if [[ -n "${DEFAULTS_EXTRA_FILE}" && ! -r "${DEFAULTS_EXTRA_FILE}" ]]; then + echo "MariaDB client option file is not readable: ${DEFAULTS_EXTRA_FILE}" >&2 exit 1 fi + readonly HOST PORT USER PASSWORD DEFAULTS_EXTRA_FILE TIMEOUT + return 0 } function wait_for_connection { - local duration=${TIMEOUT:-300} + local duration=${TIMEOUT} echo "Waiting for up to ${duration} seconds to connect to Database ${HOST}:${PORT}" >&2 timeout "${duration}" wait-for-open-port.sh "${HOST}" "${PORT}" } function mysql_validate_credentials { mariadb \ + --defaults-extra-file="${CLIENT_DEFAULTS_FILE}" \ --batch \ --skip-column-names \ --user="${USER}" \ - --password="${PASSWORD}" \ --host="${HOST}" \ --port="${PORT}" \ --protocol=tcp \ @@ -112,6 +131,23 @@ function mysql_validate_credentials { >/dev/null } +function prepare_client_defaults_file { + if [[ -n "${DEFAULTS_EXTRA_FILE}" ]]; then + CLIENT_DEFAULTS_FILE=${DEFAULTS_EXTRA_FILE} + return 0 + fi + + CLIENT_DEFAULTS_FILE=$(mktemp -t mariadb-client.XXXXXXXXXX) + database_write_client_defaults_file "${CLIENT_DEFAULTS_FILE}" "${PASSWORD}" + REMOVE_CLIENT_DEFAULTS_FILE=true +} + +function cleanup_client_defaults_file { + if [[ "${REMOVE_CLIENT_DEFAULTS_FILE}" = "true" && -n "${CLIENT_DEFAULTS_FILE}" ]]; then + rm -f -- "${CLIENT_DEFAULTS_FILE}" + fi +} + function validate_credentials { echo "Validating Database credentials" >&2 mysql_validate_credentials @@ -119,20 +155,22 @@ function validate_credentials { function main { cmdline "${ARGS[@]}" + prepare_client_defaults_file + trap cleanup_client_defaults_file EXIT if wait_for_connection; then echo "Database found" >&2 else echo "Timed out waiting for database connection" >&2 - exit 1 + return 1 fi if validate_credentials; then echo "Credentials are valid" >&2 - exit 0 + return 0 else echo "Credentials are invalid" >&2 - exit 1 + return 1 fi } main diff --git a/images/base/rootfs/usr/local/bin/wait-for-open-port.sh b/images/base/rootfs/usr/local/bin/wait-for-open-port.sh index ee427c55..26c32b76 100755 --- a/images/base/rootfs/usr/local/bin/wait-for-open-port.sh +++ b/images/base/rootfs/usr/local/bin/wait-for-open-port.sh @@ -24,52 +24,43 @@ EOF } function cmdline { - local arg= - for arg; do - local delim="" - case "$arg" in - # Translate --gnu-long-options to -g (short options) - --help) args="${args}-h " ;; - --debug) args="${args}-x " ;; - # Pass through anything else - *) - [[ "${arg:0:1}" == "-" ]] || delim="\"" - args="${args}${delim}${arg}${delim} " - ;; - esac - done - - # Reset the positional parameters to the short options - eval set -- "${args}" + local positional=() - while getopts "hx" OPTION; do - case $OPTION in - h) + while (($# > 0)); do + case "$1" in + -h|--help) usage exit 0 ;; - x) + -x|--debug) set -x + shift ;; - *) - echo "Invalid Option: $OPTION" >&2 - usage + --) + shift + positional+=("$@") + break + ;; + -*) + echo "Invalid option: $1" >&2 exit 1 ;; + *) + positional+=("$1") + shift + ;; esac done - shift $((OPTIND - 1)) - - if [ "$#" -ne 2 ]; then + if [ "${#positional[@]}" -ne 2 ]; then echo "Illegal number of parameters" >&2 usage return 1 fi - readonly HOST=${1} - shift - readonly PORT=${1} + HOST=${positional[0]} + PORT=${positional[1]} + readonly HOST PORT return 0 } diff --git a/images/base/rootfs/usr/local/share/libops/database.sh b/images/base/rootfs/usr/local/share/libops/database.sh new file mode 100755 index 00000000..b4f0e52f --- /dev/null +++ b/images/base/rootfs/usr/local/share/libops/database.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +# shellcheck shell=bash + +function database_validate_identifier { + local name=$1 + local value=$2 + + if [[ ! "${value}" =~ ^[A-Za-z0-9_]+$ ]]; then + echo "${name} must contain only letters, numbers, and underscores" >&2 + return 1 + fi +} + +function database_escape_sql_literal { + local value=$1 + + value=${value//\\/\\\\} + value=${value//\'/\'\'} + printf '%s' "${value}" +} + +function database_escape_option_file_value { + local value=$1 + + value=${value//\\/\\\\} + value=${value//$'\n'/\\n} + value=${value//$'\r'/\\r} + value=${value//$'\t'/\\t} + value=${value//\"/\\\"} + printf '%s' "${value}" +} + +function database_write_client_defaults_file { + local file=$1 + local password=$2 + local password_escaped + + password_escaped=$(database_escape_option_file_value "${password}") + ( + umask 077 + printf '[client]\npassword="%s"\n' "${password_escaped}" >"${file}" + chmod 0600 "${file}" + ) +} + +function database_mariadb_with_password { + local password=$1 + shift + + ( + local client_defaults_file + client_defaults_file=$(mktemp -t mariadb-client.XXXXXXXXXX) + trap 'rm -f -- "${client_defaults_file}"' EXIT + database_write_client_defaults_file "${client_defaults_file}" "${password}" + command mariadb --defaults-extra-file="${client_defaults_file}" "$@" + ) +} + +function database_require_root_credentials { + if [ -z "${DB_ROOT_USER:-}" ]; then + echo "DB_ROOT_USER must not be empty" >&2 + return 1 + fi + if [ -z "${DB_ROOT_PASSWORD:-}" ]; then + echo "DB_ROOT_PASSWORD must not be empty" >&2 + return 1 + fi +} + +# Return 0 when application-side database bootstrap was explicitly enabled, +# 1 when it is disabled, and 2 for an unsafe or invalid configuration. +function database_bootstrap_enabled { + case "${DB_BOOTSTRAP_ENABLED:-false}" in + 1 | [Tt][Rr][Uu][Ee] | [Yy][Ee][Ss] | [Oo][Nn]) + if ! database_require_root_credentials; then + echo "Root credentials are required when DB_BOOTSTRAP_ENABLED=true" >&2 + return 2 + fi + return 0 + ;; + '' | 0 | [Ff][Aa][Ll][Ss][Ee] | [Nn][Oo] | [Oo][Ff][Ff]) + return 1 + ;; + *) + echo "DB_BOOTSTRAP_ENABLED must be true or false" >&2 + return 2 + ;; + esac +} + +function database_bootstrap_if_enabled { + local status=0 + + database_bootstrap_enabled || status=$? + case "${status}" in + 0) + "$@" + ;; + 1) + return 0 + ;; + *) + return "${status}" + ;; + esac +} diff --git a/images/base/rootfs/usr/local/share/libops/environment.sh b/images/base/rootfs/usr/local/share/libops/environment.sh new file mode 100755 index 00000000..d5505902 --- /dev/null +++ b/images/base/rootfs/usr/local/share/libops/environment.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# shellcheck shell=bash + +function require_environment_variables { + local name value missing=false + + for name in "$@"; do + value=${!name-} + if [ -z "${value}" ]; then + echo "${name} must not be empty" >&2 + missing=true + fi + done + if [ "${missing}" = "true" ]; then + return 1 + fi +} diff --git a/images/base/tests/DatabaseHelperArgumentSafety/docker-compose.yml b/images/base/tests/DatabaseHelperArgumentSafety/docker-compose.yml new file mode 100644 index 00000000..581a6f2b --- /dev/null +++ b/images/base/tests/DatabaseHelperArgumentSafety/docker-compose.yml @@ -0,0 +1,9 @@ +--- +name: base-databasehelperargumentsafety +services: + base: + image: ${BASE:-libops/base:local} + volumes: + - ./test.sh:/test.sh:ro + command: + - /test.sh diff --git a/images/base/tests/DatabaseHelperArgumentSafety/test.sh b/images/base/tests/DatabaseHelperArgumentSafety/test.sh new file mode 100755 index 00000000..76fd5b48 --- /dev/null +++ b/images/base/tests/DatabaseHelperArgumentSafety/test.sh @@ -0,0 +1,239 @@ +#!/command/with-contenv bash +# shellcheck shell=bash + +set -euo pipefail + +stub_dir="$(mktemp -d)" +capture="$(mktemp)" +marker=/tmp/database-helper-injected +# shellcheck disable=SC2016 # Literal command substitution is an injection payload. +password='space "$(touch /tmp/database-helper-injected)" quote'"'"' semicolon; back\slash end' + +cleanup() { + rm -rf "${stub_dir}" "${capture}" "${marker}" +} +trap cleanup EXIT + +cat >"${stub_dir}/mariadb" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +for argument in "$@"; do + if [[ "${argument}" == --password* ]]; then + echo "Password was exposed in MariaDB argv" >&2 + exit 1 + fi + if [[ "${argument}" == --defaults-extra-file=* ]]; then + defaults_file=${argument#*=} + test -r "${defaults_file}" + test "$(stat -c '%a' "${defaults_file}")" = 600 + fi +done +printf '%s\n' "$@" >>"${DATABASE_HELPER_CAPTURE:?}" +EOF + +cat >"${stub_dir}/wait-for-open-port.sh" <<'EOF' +#!/usr/bin/env bash +exit 0 +EOF + +cat >"${stub_dir}/wget" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +destination= +url=${!#} +while (($# > 0)); do + case "$1" in + -P) destination=$2; shift 2 ;; + *) shift ;; + esac +done +printf '%s' "${url}" >"${DOWNLOAD_ARGUMENT_CAPTURE:?}" +mkdir -p "${destination}" +: >"${destination}/$(basename "${url}")" +EOF + +cat >"${stub_dir}/nc" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +printf '%s\n' "$@" >"${PORT_ARGUMENT_CAPTURE:?}" +EOF + +cat >"${stub_dir}/confd" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +printf '%s\n' "$@" >"${CONFD_ARGUMENT_CAPTURE:?}" +EOF + +chmod +x "${stub_dir}/mariadb" "${stub_dir}/wait-for-open-port.sh" \ + "${stub_dir}/wget" "${stub_dir}/nc" "${stub_dir}/confd" +export DATABASE_HELPER_CAPTURE="${capture}" +export PATH="${stub_dir}:${PATH}" +export LIBOPS_DATABASE_LIBRARY=${LIBOPS_DATABASE_LIBRARY:-/usr/local/share/libops/database.sh} +export LIBOPS_BIN_DIRECTORY=${LIBOPS_BIN_DIRECTORY:-/usr/local/bin} +# shellcheck disable=SC1090 +source "${LIBOPS_DATABASE_LIBRARY}" +# shellcheck disable=SC1090 +source "${LIBOPS_ENVIRONMENT_LIBRARY:-/usr/local/share/libops/environment.sh}" + +client_defaults_file=$(mktemp) +database_write_client_defaults_file "${client_defaults_file}" "${password}" +test "$(stat -c '%a' "${client_defaults_file}")" = 600 +expected_client_defaults=$(cat <<'EOF' +[client] +password="space \"$(touch /tmp/database-helper-injected)\" quote' semicolon; back\\slash end" +EOF +) +test "$(cat "${client_defaults_file}")" = "${expected_client_defaults}" +rm -f "${client_defaults_file}" + +if REQUIRED_TEST_VALUE='' require_environment_variables REQUIRED_TEST_VALUE 2>/dev/null; then + echo "Empty required environment value was accepted" >&2 + exit 1 +fi +REQUIRED_TEST_VALUE=present require_environment_variables REQUIRED_TEST_VALUE + +if DB_ROOT_USER=root DB_ROOT_PASSWORD='' database_require_root_credentials 2>/dev/null; then + echo "Empty database root password was accepted" >&2 + exit 1 +fi +if DB_ROOT_USER='' DB_ROOT_PASSWORD=root database_require_root_credentials 2>/dev/null; then + echo "Empty database root user was accepted" >&2 + exit 1 +fi + +bootstrap_marker="${stub_dir}/bootstrap-called" +export DB_ROOT_USER=root +record_bootstrap() { + touch "${bootstrap_marker}" +} + +DB_BOOTSTRAP_ENABLED=false \ + DB_ROOT_PASSWORD=password \ + database_bootstrap_if_enabled record_bootstrap +test ! -e "${bootstrap_marker}" + +if DB_BOOTSTRAP_ENABLED=true \ + DB_ROOT_PASSWORD='' \ + database_bootstrap_if_enabled record_bootstrap 2>/dev/null; then + echo "Database bootstrap accepted an empty root password" >&2 + exit 1 +fi +test ! -e "${bootstrap_marker}" + +if DB_BOOTSTRAP_ENABLED=invalid \ + DB_ROOT_PASSWORD=root \ + database_bootstrap_if_enabled record_bootstrap 2>/dev/null; then + echo "Database bootstrap accepted an invalid enable flag" >&2 + exit 1 +fi +test ! -e "${bootstrap_marker}" + +DB_BOOTSTRAP_ENABLED=true \ + DB_ROOT_PASSWORD=root \ + database_bootstrap_if_enabled record_bootstrap +test -e "${bootstrap_marker}" +rm "${bootstrap_marker}" + +# shellcheck disable=SC2016 # Literal command substitution is an injection payload. +malicious_argument='space "$(touch /tmp/database-helper-injected)" quote'"'"' and back\slash' +download_capture="${stub_dir}/download-argument" +port_capture="${stub_dir}/port-argument" +confd_capture="${stub_dir}/confd-argument" +export DOWNLOAD_ARGUMENT_CAPTURE="${download_capture}" +export PORT_ARGUMENT_CAPTURE="${port_capture}" +export CONFD_ARGUMENT_CAPTURE="${confd_capture}" + +DOWNLOAD_CACHE_DIRECTORY="${stub_dir}/downloads" \ + download.sh \ + --url "https://example.invalid/${malicious_argument}" \ + --sha256 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 +test "$(cat "${download_capture}")" = "https://example.invalid/${malicious_argument}" + +"${LIBOPS_BIN_DIRECTORY}/wait-for-open-port.sh" "${malicious_argument}" "33 06" +mapfile -t port_args <"${port_capture}" +test "${port_args[2]}" = "${malicious_argument}" +test "${port_args[3]}" = "33 06" + +CONFD_LOG_LEVEL=error confd-render-templates.sh -- -config-file "${malicious_argument}" +mapfile -t confd_args <"${confd_capture}" +test "${confd_args[5]}" = "${malicious_argument}" + +for helper in create-service-user.sh cleanup.sh confd-import-environment.sh; do + "${helper}" --help "${malicious_argument}" >/dev/null +done +test ! -e "${marker}" + +sql=$( + DB_NAME="database_name" \ + DB_USER="database_user" \ + DB_PASSWORD="${password}" \ + render-database-bootstrap-sql.sh +) +expected_sql=$(cat <<'EOF' +CREATE DATABASE IF NOT EXISTS `database_name` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +CREATE USER IF NOT EXISTS 'database_user'@'%' IDENTIFIED BY 'space "$(touch /tmp/database-helper-injected)" quote'' semicolon; back\\slash end'; +GRANT ALL PRIVILEGES ON `database_name`.* TO 'database_user'@'%'; +SET PASSWORD FOR 'database_user'@'%' = PASSWORD('space "$(touch /tmp/database-helper-injected)" quote'' semicolon; back\\slash end'); +FLUSH PRIVILEGES; +EOF +) +test "${sql}" = "${expected_sql}" + +if DB_NAME="invalid-name" \ + DB_USER="database_user" \ + DB_PASSWORD="${password}" \ + render-database-bootstrap-sql.sh >/dev/null 2>&1; then + echo "Invalid database identifier was accepted" >&2 + exit 1 +fi + +wait-for-database.sh \ + --host "database host" \ + --port 3306 \ + --user "database user" \ + --password "${password}" \ + --timeout 1 + +grep -Fqx -- "--host=database host" "${capture}" +grep -Fqx -- "--user=database user" "${capture}" +if grep -Eq '^--password(=|$)' "${capture}"; then + echo "Database password was exposed in client argv" >&2 + exit 1 +fi + +printf 'SELECT 1;\n' | execute-sql-file.sh \ + --host "database host" \ + --port 3306 \ + --user "database user" \ + --password "${password}" \ + --database "database name" \ + -- - --skip-column-names + +grep -Fqx -- "--database=database name" "${capture}" +grep -Fqx -- "--skip-column-names" "${capture}" + +# A lone dash is also the documented FILE argument and must not be rejected as +# an unknown option when callers do not use an option delimiter before it. +printf 'SELECT 1;\n' | execute-sql-file.sh \ + --host "database host" \ + --port 3306 \ + --user "database user" \ + --password "${password}" \ + --database "database name" \ + - -- --batch + +grep -Fqx -- "--batch" "${capture}" + +printf 'SELECT 1;\n' | \ + DB_HOST="database host" \ + DB_PORT=3306 \ + DB_NAME="database name" \ + DB_ROOT_USER="database user" \ + DB_ROOT_PASSWORD="${password}" \ + create-database.sh + +if grep -Eq '^--password(=|$)' "${capture}"; then + echo "Database password was exposed in client argv" >&2 + exit 1 +fi +test ! -e "${marker}" diff --git a/images/base/tests/EnvironmentPrecedence/secrets/DB_PASSWORD b/images/base/tests/EnvironmentPrecedence/secrets/DB_PASSWORD index ab0bcb16..1fe4d989 100644 --- a/images/base/tests/EnvironmentPrecedence/secrets/DB_PASSWORD +++ b/images/base/tests/EnvironmentPrecedence/secrets/DB_PASSWORD @@ -1 +1 @@ -DB_PASSWORD secret value \ No newline at end of file +DB_PASSWORD secret "quote" \ value diff --git a/images/base/tests/EnvironmentPrecedence/test.sh b/images/base/tests/EnvironmentPrecedence/test.sh index d10163ad..46fe7a96 100755 --- a/images/base/tests/EnvironmentPrecedence/test.sh +++ b/images/base/tests/EnvironmentPrecedence/test.sh @@ -18,7 +18,7 @@ source /usr/local/share/isle/utilities.sh # For ease of reading overridden values follow the format: # ENV_VAR_NAME="ENV_VAR_NAME SOURCE value" expect "JWT_ADMIN_TOKEN" "JWT_ADMIN_TOKEN secret value" # Secret should take precedence -expect "DB_PASSWORD" "DB_PASSWORD secret value" # Secret should take precedence +expect "DB_PASSWORD" 'DB_PASSWORD secret "quote" \ value' # Secret should take precedence and preserve special characters expect "GOOGLE_APPLICATION_CREDENTIALS" "/run/secrets/GOOGLE_APPLICATION_CREDENTIALS" # Google credentials should remain file based expect "DB_NAME" "DB_NAME passed in value" # Environment passed into the container should take precedence expect "DB_USER" "default" # Environment variables defined in Dockerfile should take precedence diff --git a/images/drupal/Dockerfile b/images/drupal/Dockerfile index 58b053d6..611489a1 100644 --- a/images/drupal/Dockerfile +++ b/images/drupal/Dockerfile @@ -2,6 +2,8 @@ ARG PHP_BASE=nginx-php83 FROM ${PHP_BASE} +LABEL org.opencontainers.image.licenses="GPL-2.0-or-later" + ARG PHP_FPM_SOCKET=/run/php-fpm/php-fpm.sock EXPOSE 80 @@ -28,17 +30,17 @@ ENV \ DB_PORT=3306 \ DB_NAME=drupal \ DB_USER=drupal \ - DB_PASSWORD=changeme \ + DB_PASSWORD= \ DRUPAL_DEFAULT_ACCOUNT_EMAIL=webmaster@localhost.com \ DRUPAL_DEFAULT_ACCOUNT_NAME=admin \ - DRUPAL_DEFAULT_ACCOUNT_PASSWORD=password \ + DRUPAL_DEFAULT_ACCOUNT_PASSWORD= \ DRUPAL_DEFAULT_CONFIGDIR=/var/www/drupal/config/sync \ DRUPAL_DEFAULT_INSTALL_EXISTING_CONFIG=false \ DRUPAL_DEFAULT_EMAIL=webmaster@localhost.com \ DRUPAL_DEFAULT_LOCALE=en \ DRUPAL_DEFAULT_NAME=Drupal \ DRUPAL_DEFAULT_PROFILE=standard \ - DRUPAL_DEFAULT_SALT=9PPaL0CxZAIcq6h5wxgDGlCZrp7JdT_x7v9gVzpdbUjMt1PqDz3uD0Zy-i16DuJ1-Htfshhqeg \ + DRUPAL_DEFAULT_SALT= \ DRUPAL_DEFAULT_SOLR_CORE=default \ DRUPAL_DEFAULT_SOLR_HOST=solr \ DRUPAL_DEFAULT_SOLR_PORT=8983 \ @@ -78,4 +80,4 @@ COPY --link rootfs / RUN chown -R nginx:nginx /var/www/drupal -HEALTHCHECK CMD { [ ! -f /var/www/drupal/web/core/lib/Drupal.php ] || [ -f /installed ]; } && curl -s http://localhost/status | grep -q pool +HEALTHCHECK CMD { [ ! -f /var/www/drupal/web/core/lib/Drupal.php ] || [ -f /installed ]; } && curl -fsS http://localhost/status | grep -q pool diff --git a/images/drupal/rootfs/etc/confd/conf.d/drupal.libops.settings.toml b/images/drupal/rootfs/etc/confd/conf.d/drupal.libops.settings.toml index f9aa0770..b69bee30 100644 --- a/images/drupal/rootfs/etc/confd/conf.d/drupal.libops.settings.toml +++ b/images/drupal/rootfs/etc/confd/conf.d/drupal.libops.settings.toml @@ -2,6 +2,6 @@ src = "drupal.libops.settings.php.tmpl" dest = "/etc/drupal/libops.settings.php" uid = 0 -gid = 0 -mode = "0644" +gid = 101 +mode = "0640" keys = [ "/" ] diff --git a/images/drupal/rootfs/etc/s6-overlay/scripts/install.sh b/images/drupal/rootfs/etc/s6-overlay/scripts/install.sh index fbd4305c..ef3a774b 100755 --- a/images/drupal/rootfs/etc/s6-overlay/scripts/install.sh +++ b/images/drupal/rootfs/etc/s6-overlay/scripts/install.sh @@ -4,29 +4,33 @@ set -euo pipefail export PATH="/var/www/drupal/vendor/bin:${PATH}" -function mysql_create_database { - cat <<-SQL | create-database.sh -CREATE DATABASE IF NOT EXISTS ${DB_NAME} CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; - -CREATE USER IF NOT EXISTS '${DB_USER}'@'%' IDENTIFIED BY '${DB_PASSWORD}'; -GRANT ALL PRIVILEGES ON ${DB_NAME}.* to '${DB_USER}'@'%'; -FLUSH PRIVILEGES; +# shellcheck source=images/base/rootfs/usr/local/share/libops/database.sh +source "${LIBOPS_DATABASE_LIBRARY:-/usr/local/share/libops/database.sh}" +# shellcheck source=images/base/rootfs/usr/local/share/libops/environment.sh +source "${LIBOPS_ENVIRONMENT_LIBRARY:-/usr/local/share/libops/environment.sh}" -SET PASSWORD FOR ${DB_USER}@'%' = PASSWORD('${DB_PASSWORD}'); -SQL +function mysql_create_database { + DB_CHARACTER_SET=utf8mb4 \ + DB_COLLATION=utf8mb4_unicode_ci \ + render-database-bootstrap-sql.sh | create-database.sh } function mysql_count_query { cat <<-SQL SELECT COUNT(DISTINCT table_name) FROM information_schema.columns -WHERE table_schema = '${DB_NAME}'; +WHERE table_schema = DATABASE(); SQL } function installed { local count - count=$(execute-sql-file.sh <(mysql_count_query) -- -N 2>/dev/null) || return 1 + count=$(LIBOPS_DATABASE_PASSWORD="${DB_PASSWORD}" execute-sql-file.sh \ + --host "${DB_HOST}" \ + --port "${DB_PORT}" \ + --user "${DB_USER}" \ + --database "${DB_NAME}" \ + <(mysql_count_query) -- -N 2>/dev/null) || return 1 [[ ${count:-0} -ne 0 ]] } @@ -53,7 +57,7 @@ function install_site { existing_config_arg=("--existing-config") fi - drush \ + DRUSH_COMMAND_SITE_INSTALL_OPTIONS_ACCOUNT_PASS="${DRUPAL_DEFAULT_ACCOUNT_PASSWORD}" drush \ -n \ -r /var/www/drupal/web \ site:install "${DRUPAL_DEFAULT_PROFILE}" \ @@ -62,10 +66,8 @@ function install_site { --site-name="${DRUPAL_DEFAULT_NAME}" \ --site-mail="${DRUPAL_DEFAULT_EMAIL}" \ --account-name="${DRUPAL_DEFAULT_ACCOUNT_NAME}" \ - --account-pass="${DRUPAL_DEFAULT_ACCOUNT_PASSWORD}" \ --account-mail="${DRUPAL_DEFAULT_ACCOUNT_EMAIL}" \ - --locale="${DRUPAL_DEFAULT_LOCALE}" \ - --db-url="mysql://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}" + --locale="${DRUPAL_DEFAULT_LOCALE}" } function run_install_hooks { @@ -127,14 +129,19 @@ function main { return 0 fi + require_environment_variables DB_HOST DB_NAME DB_USER DB_PASSWORD DRUPAL_DEFAULT_SALT + cd /var/www/drupal drush_cache_setup setup_directories - mysql_create_database + if [ "${DB_HOST}" = "mariadb" ]; then + database_bootstrap_if_enabled mysql_create_database + fi if installed; then echo "Already Installed" else + require_environment_variables DRUPAL_DEFAULT_ACCOUNT_PASSWORD echo "Installing" install_site run_install_hooks @@ -142,4 +149,7 @@ function main { fi finished } -main + +if [ "${DRUPAL_SETUP_LIBRARY_ONLY:-false}" != "true" ]; then + main +fi diff --git a/images/drupal/rootfs/usr/local/bin/install-drupal-site.sh b/images/drupal/rootfs/usr/local/bin/install-drupal-site.sh index 78c10639..d4b5a273 100755 --- a/images/drupal/rootfs/usr/local/bin/install-drupal-site.sh +++ b/images/drupal/rootfs/usr/local/bin/install-drupal-site.sh @@ -14,8 +14,7 @@ function usage { OPTIONS: --host The database host. --port The database port. - --db-user The user to connect as. - --db-password The password to use for the user. + --db-user The user to connect as. --db-name The name of the database to install into. -h --help Show this help. @@ -23,90 +22,84 @@ function usage { Examples: Install default Drupal site: - $PROGNAME \\ + LIBOPS_DRUPAL_INSTALL_DB_PASSWORD=password $PROGNAME \\ --host "mariadb" \\ --port "3306" \\ --db-user "root" \\ - --db-password "password" \\ --db-name "drupal_default" \\ standard --sites-subdir=default --site-name=Islandora EOF } function cmdline { - local arg= - for arg; do - local delim="" - case "$arg" in - # Translate --gnu-long-options to -g (short options) - --host) args="${args}-b " ;; - --port) args="${args}-c " ;; - --db-user) args="${args}-d " ;; - --db-password) args="${args}-e " ;; - --db-name) args="${args}-f " ;; - --help) args="${args}-h " ;; - --debug) args="${args}-x " ;; - # Pass through anything else - *) - [[ "${arg:0:1}" == "-" ]] || delim="\"" - args="${args}${delim}${arg}${delim} " - ;; - esac - done - - # Reset the positional parameters to the short options - eval set -- "${args}" - - while getopts "b:c:d:e:f:hx" OPTION; do - case $OPTION in - b) - readonly HOST=${OPTARG} - ;; - c) - readonly PORT=${OPTARG} - ;; - d) - readonly DB_USER=${OPTARG} - ;; - e) - readonly DB_PASSWORD=${OPTARG} + HOST= + PORT= + DB_USER= + DB_PASSWORD=${LIBOPS_DRUPAL_INSTALL_DB_PASSWORD:-} + DB_NAME= + DRUSH_ARGS=() + + while (($# > 0)); do + case "$1" in + -b|--host|-c|--port|-d|--db-user|-f|--db-name) + if (($# < 2)); then + echo "Option $1 requires a value" >&2 + exit 1 + fi + case "$1" in + -b|--host) HOST=$2 ;; + -c|--port) PORT=$2 ;; + -d|--db-user) DB_USER=$2 ;; + -f|--db-name) DB_NAME=$2 ;; + esac + shift 2 ;; - f) - readonly DB_NAME=${OPTARG} - ;; - h) + --host=*) HOST=${1#*=}; shift ;; + --port=*) PORT=${1#*=}; shift ;; + --db-user=*) DB_USER=${1#*=}; shift ;; + --db-name=*) DB_NAME=${1#*=}; shift ;; + -h|--help) usage exit 0 ;; - x) + -x|--debug) set -x + shift + ;; + --) + shift + DRUSH_ARGS+=("$@") + break + ;; + -*) + # The first unrecognized option and all following arguments belong + # to drush, allowing its own option syntax to pass through intact. + DRUSH_ARGS+=("$@") + break ;; *) - echo "Invalid Option: $OPTION" >&2 - usage - exit 1 + DRUSH_ARGS+=("$1") + shift ;; esac done if [[ -z $HOST || -z $PORT || -z $DB_USER || -z $DB_PASSWORD || -z $DB_NAME ]]; then - echo "Missing one of required options: --host --port --db-user --db-password --db-name" + echo "Missing one of required settings: --host --port --db-user --db-name LIBOPS_DRUPAL_INSTALL_DB_PASSWORD" exit 1 fi - # All remaning parameters are passed to 'drush site-install'. - shift $((OPTIND - 1)) - readonly DRUSH_ARGS=("$@") + readonly HOST PORT DB_USER DB_PASSWORD DB_NAME DRUSH_ARGS return 0 } function execute_sql_file { - execute-sql-file.sh \ + LIBOPS_DATABASE_PASSWORD="${DB_PASSWORD}" execute-sql-file.sh \ --host "${HOST}" \ --port "${PORT}" \ --user "${DB_USER}" \ - --password "${DB_PASSWORD}" \ + --database "${DB_NAME}" \ "${@}" } @@ -114,7 +107,7 @@ function mysql_count_query { cat <<-EOF SELECT COUNT(DISTINCT table_name) FROM information_schema.columns -WHERE table_schema = '${DB_NAME}'; +WHERE table_schema = DATABASE(); EOF } @@ -122,6 +115,19 @@ function mysql_count { execute_sql_file <(mysql_count_query) -- -N 2>/dev/null } +function uri_encode { + LIBOPS_DRUPAL_URI_COMPONENT="$1" php -r 'echo rawurlencode((string) getenv("LIBOPS_DRUPAL_URI_COMPONENT"));' +} + +function database_url { + local encoded_name encoded_password encoded_user + encoded_user=$(uri_encode "${DB_USER}") + encoded_password=$(uri_encode "${DB_PASSWORD}") + encoded_name=$(uri_encode "${DB_NAME}") + printf 'mysql://%s:%s@%s:%s/%s' \ + "${encoded_user}" "${encoded_password}" "${HOST}" "${PORT}" "${encoded_name}" +} + # Check the number of tables to determine if it has already been installed. function installed { local count= @@ -130,15 +136,18 @@ function installed { } function main { + local drush_database_url cmdline "${ARGS[@]}" if installed; then echo "Site already is installed." return 0 fi echo "Installing site." - drush \ + drush_database_url=$(database_url) + DRUSH_COMMAND_SITE_INSTALL_OPTIONS_ACCOUNT_PASS="${LIBOPS_DRUPAL_INSTALL_ACCOUNT_PASSWORD:-}" \ + DRUSH_COMMAND_SITE_INSTALL_OPTIONS_DB_URL="${drush_database_url}" \ + drush \ -n \ - si "${DRUSH_ARGS[@]}" \ - --db-url="mysql://${DB_USER}:${DB_PASSWORD}@${HOST}:${PORT}/${DB_NAME}" + si "${DRUSH_ARGS[@]}" } main diff --git a/images/fcrepo6/Dockerfile b/images/fcrepo6/Dockerfile index e58165c0..eae0882b 100644 --- a/images/fcrepo6/Dockerfile +++ b/images/fcrepo6/Dockerfile @@ -8,6 +8,7 @@ ARG FCREPO_FILE=fcrepo-webapp-${SOFTWARE_VERSION}.war ARG FCREPO_URL=https://github.com/fcrepo/fcrepo/releases/download/fcrepo-${SOFTWARE_VERSION}/${FCREPO_FILE} ARG FCREPO_SHA256="5091550fe31e7693f36396e0ea3231c990648f14d914597124b84971a9daaae5" +# renovate: datasource=github-releases depName=islandora-syn-fcrepo6 packageName=Islandora/Syn ARG SYN_VERSION=1.1.1 ARG SYN_FILE=islandora-syn-${SYN_VERSION}-all.jar ARG SYN_URL=https://github.com/islandora/Syn/releases/download/v${SYN_VERSION}/${SYN_FILE} @@ -51,7 +52,7 @@ RUN --mount=type=cache,id=fcrepo6-downloads-${TARGETARCH},sharing=locked,target= ENV \ DB_NAME=fcrepo \ - DB_PASSWORD=password \ + DB_PASSWORD= \ DB_USER=fcrepo \ FCREPO_ACTIVEMQ_BROKER=tcp://activemq:61616 \ FCREPO_ACTIVEMQ_QUEUE=fedora \ @@ -64,4 +65,4 @@ COPY --link rootfs / RUN chown -R tomcat:tomcat /opt/tomcat -HEALTHCHECK CMD curl -s http://localhost:8080/fcrepo/rest/fcr:systeminfo | jq -e .version +HEALTHCHECK CMD curl -fsS http://localhost:8080/fcrepo/rest/fcr:systeminfo | jq -e .version diff --git a/images/fcrepo6/README.md b/images/fcrepo6/README.md index 7c23bb0d..d44c8b38 100644 --- a/images/fcrepo6/README.md +++ b/images/fcrepo6/README.md @@ -74,14 +74,7 @@ Change `SOFTWARE_VERSION` and then generate the `FCREPO_SHA256` with the followi commands: ```bash -SOFTWARE_VERSION=$(cat fcrepo6/Dockerfile | grep -o 'SOFTWARE_VERSION=.*' | cut -f2 -d=) -FCREPO_FILE=$(cat fcrepo6/Dockerfile | grep -o 'FCREPO_FILE=.*' | cut -f2 -d=) -FCREPO_URL=$(cat fcrepo6/Dockerfile | grep -o 'FCREPO_URL=.*' | cut -f2 -d=) -FCREPO_FILE=$(eval "echo $FCREPO_FILE") -FCREPO_URL=$(eval "echo $FCREPO_URL") -wget --quiet "${FCREPO_URL}" -shasum -a 256 "${FCREPO_FILE}" | cut -f1 -d' ' -rm "${FCREPO_FILE}" +./ci/update-sha.sh fcrepo6 OLD_VERSION NEW_VERSION '' ``` You can change the version used for [syn] by modifying the build argument @@ -91,14 +84,7 @@ Change `SYN_VERSION` and then generate the `SYN_SHA256` with the following commands: ```bash -SYN_VERSION=$(cat fcrepo6/Dockerfile | grep -o 'SYN_VERSION=.*' | cut -f2 -d=) -SYN_FILE=$(cat fcrepo6/Dockerfile | grep -o 'SYN_FILE=.*' | cut -f2 -d=) -SYN_URL=$(cat fcrepo6/Dockerfile | grep -o 'SYN_URL=.*' | cut -f2 -d=) -SYN_FILE=$(eval "echo $SYN_FILE") -SYN_URL=$(eval "echo $SYN_URL") -wget --quiet "${SYN_URL}" -shasum -a 256 "${SYN_FILE}" | cut -f1 -d' ' -rm "${SYN_FILE}" +./ci/update-sha.sh islandora-syn-fcrepo6 OLD_VERSION NEW_VERSION '' ``` You can change the version used for [fcrepo-import-export] by modifying the @@ -109,14 +95,7 @@ Change `IMPORT_EXPORT_VERSION` and then generate the `IMPORT_EXPORT_SHA256` with the following commands: ```bash -IMPORT_EXPORT_VERSION=$(cat fcrepo6/Dockerfile | grep -o 'IMPORT_EXPORT_VERSION=.*' | cut -f2 -d=) -IMPORT_EXPORT_FILE=$(cat fcrepo6/Dockerfile | grep -o 'IMPORT_EXPORT_FILE=.*' | cut -f2 -d=) -IMPORT_EXPORT_URL=$(cat fcrepo6/Dockerfile | grep -o 'IMPORT_EXPORT_URL=.*' | cut -f2 -d=) -IMPORT_EXPORT_FILE=$(eval "echo $IMPORT_EXPORT_FILE") -IMPORT_EXPORT_URL=$(eval "echo $IMPORT_EXPORT_URL") -wget --quiet "${IMPORT_EXPORT_URL}" -shasum -a 256 "${IMPORT_EXPORT_FILE}" | cut -f1 -d' ' -rm "${IMPORT_EXPORT_FILE}" +./ci/update-sha.sh fcrepo-import-export OLD_VERSION NEW_VERSION '' ``` You can change the version used for [fcrepo-upgrade-utils] by modifying the @@ -127,14 +106,7 @@ Change `UPGRADE_UTILS_VERSION` and then generate the `UPGRADE_UTILS_SHA256` with the following commands: ```bash -UPGRADE_UTILS_VERSION=$(cat fcrepo6/Dockerfile | grep -o 'UPGRADE_UTILS_VERSION=.*' | cut -f2 -d=) -UPGRADE_UTILS_FILE=$(cat fcrepo6/Dockerfile | grep -o 'UPGRADE_UTILS_FILE=.*' | cut -f2 -d=) -UPGRADE_UTILS_URL=$(cat fcrepo6/Dockerfile | grep -o 'UPGRADE_UTILS_URL=.*' | cut -f2 -d=) -UPGRADE_UTILS_FILE=$(eval "echo $UPGRADE_UTILS_FILE") -UPGRADE_UTILS_URL=$(eval "echo $UPGRADE_UTILS_URL") -wget --quiet "${UPGRADE_UTILS_URL}" -shasum -a 256 "${UPGRADE_UTILS_FILE}" | cut -f1 -d' ' -rm "${UPGRADE_UTILS_FILE}" +./ci/update-sha.sh fcrepo-upgrade-utils OLD_VERSION NEW_VERSION '' ``` [base image]: ../base/README.md diff --git a/images/fcrepo6/rootfs/etc/s6-overlay/scripts/fcrepo-setup.sh b/images/fcrepo6/rootfs/etc/s6-overlay/scripts/fcrepo-setup.sh index f35ebb00..abaf0528 100755 --- a/images/fcrepo6/rootfs/etc/s6-overlay/scripts/fcrepo-setup.sh +++ b/images/fcrepo6/rootfs/etc/s6-overlay/scripts/fcrepo-setup.sh @@ -2,19 +2,15 @@ # shellcheck shell=bash set -e -function mysql_create_database { - cat <<-EOF | create-database.sh --- Create fcrepo database in mariadb or mysql. -CREATE DATABASE IF NOT EXISTS ${DB_NAME} CHARACTER SET utf8 COLLATE utf8_general_ci; - --- Create fcrepo user and grant rights. -CREATE USER IF NOT EXISTS '${DB_USER}'@'%' IDENTIFIED BY '${DB_PASSWORD}'; -GRANT ALL PRIVILEGES ON ${DB_NAME}.* to '${DB_USER}'@'%'; -FLUSH PRIVILEGES; +# shellcheck disable=SC1091 +source /usr/local/share/libops/database.sh +# shellcheck disable=SC1091 +source /usr/local/share/libops/environment.sh --- Update fcrepo password if changed. -SET PASSWORD FOR ${DB_USER}@'%' = PASSWORD('${DB_PASSWORD}') -EOF +function mysql_create_database { + DB_CHARACTER_SET=utf8 \ + DB_COLLATION=utf8_general_ci \ + render-database-bootstrap-sql.sh | create-database.sh } function wait_for_broker { @@ -32,7 +28,14 @@ function wait_for_broker { } function main { - mysql_create_database + require_environment_variables DB_HOST DB_NAME DB_USER DB_PASSWORD + if [ "${DB_HOST}" = "mariadb" ]; then + database_bootstrap_if_enabled mysql_create_database + fi + LIBOPS_DATABASE_PASSWORD="${DB_PASSWORD}" wait-for-database.sh \ + --host "${DB_HOST}" \ + --port "${DB_PORT}" \ + --user "${DB_USER}" # When bind mounting we need to ensure that we # actually can write to the folder. chown tomcat:tomcat /data diff --git a/images/fcrepo6/tests/ServiceHealthcheck/docker-compose.yml b/images/fcrepo6/tests/ServiceHealthcheck/docker-compose.yml index 3f02fb21..1d4b163d 100644 --- a/images/fcrepo6/tests/ServiceHealthcheck/docker-compose.yml +++ b/images/fcrepo6/tests/ServiceHealthcheck/docker-compose.yml @@ -8,9 +8,20 @@ services: activemq: <<: *common image: ${ACTIVEMQ:-libops/activemq:local} + environment: + ACTIVEMQ_PASSWORD: test-broker-password + ACTIVEMQ_WEB_ADMIN_PASSWORD: test-web-password mariadb: image: ${MARIADB11:-libops/mariadb:local-11} + environment: + DB_ROOT_PASSWORD: root + DB_NAME: fcrepo + DB_USER: fcrepo + DB_PASSWORD: test-database-password fcrepo: + environment: + DB_PASSWORD: test-database-password + JWT_ADMIN_TOKEN: islandora volumes: - ./test.sh:/test.sh # Test to run. command: diff --git a/images/fcrepo6/tests/ServiceHealthcheck/test.sh b/images/fcrepo6/tests/ServiceHealthcheck/test.sh index dd1d6bdf..e84270db 100755 --- a/images/fcrepo6/tests/ServiceHealthcheck/test.sh +++ b/images/fcrepo6/tests/ServiceHealthcheck/test.sh @@ -2,7 +2,7 @@ # shellcheck shell=bash # Invoked indirectly by the SIGTERM trap below. -# shellcheck disable=SC2329 +# shellcheck disable=SC2317,SC2329 on_terminate() { echo "Termination signal received. Exiting..." exit 0 diff --git a/images/fcrepo6/tests/ServiceStartsWithBackendMySQL/docker-compose.yml b/images/fcrepo6/tests/ServiceStartsWithBackendMySQL/docker-compose.yml index bd543553..8fe1cb51 100644 --- a/images/fcrepo6/tests/ServiceStartsWithBackendMySQL/docker-compose.yml +++ b/images/fcrepo6/tests/ServiceStartsWithBackendMySQL/docker-compose.yml @@ -10,9 +10,20 @@ services: activemq: <<: *common image: ${ACTIVEMQ:-libops/activemq:local} + environment: + ACTIVEMQ_PASSWORD: test-broker-password + ACTIVEMQ_WEB_ADMIN_PASSWORD: test-web-password mariadb: image: ${MARIADB11:-libops/mariadb:local-11} + environment: + DB_ROOT_PASSWORD: root + DB_NAME: fcrepo + DB_USER: fcrepo + DB_PASSWORD: test-database-password fcrepo: + environment: + DB_PASSWORD: test-database-password + JWT_ADMIN_TOKEN: islandora volumes: - ./test.sh:/test.sh # Test to run. command: diff --git a/images/fcrepo6/tests/ServiceStartsWithBackendMySQL/test.sh b/images/fcrepo6/tests/ServiceStartsWithBackendMySQL/test.sh index f9ef12dd..14dd561f 100755 --- a/images/fcrepo6/tests/ServiceStartsWithBackendMySQL/test.sh +++ b/images/fcrepo6/tests/ServiceStartsWithBackendMySQL/test.sh @@ -5,7 +5,9 @@ source /usr/local/share/isle/utilities.sh function count { - cat <<-EOF | execute-sql-file.sh --database "fcrepo" - -- -N 2>/dev/null + cat <<-EOF | LIBOPS_DATABASE_PASSWORD="${DB_PASSWORD}" execute-sql-file.sh \ + --host "${DB_HOST}" --port "${DB_PORT}" --user "${DB_USER}" \ + --database "${DB_NAME}" - -- -N 2>/dev/null SELECT COUNT(*) as count FROM containment; EOF } diff --git a/images/fcrepo6/tests/SynAuthentication/docker-compose.yml b/images/fcrepo6/tests/SynAuthentication/docker-compose.yml index a43596ad..bd3f90e8 100644 --- a/images/fcrepo6/tests/SynAuthentication/docker-compose.yml +++ b/images/fcrepo6/tests/SynAuthentication/docker-compose.yml @@ -8,9 +8,20 @@ services: activemq: <<: *common image: ${ACTIVEMQ:-libops/activemq:local} + environment: + ACTIVEMQ_PASSWORD: test-broker-password + ACTIVEMQ_WEB_ADMIN_PASSWORD: test-web-password mariadb: image: ${MARIADB11:-libops/mariadb:local-11} + environment: + DB_ROOT_PASSWORD: root + DB_NAME: fcrepo + DB_USER: fcrepo + DB_PASSWORD: test-database-password fcrepo: + environment: + DB_PASSWORD: test-database-password + JWT_ADMIN_TOKEN: islandora volumes: - ./test.sh:/test.sh # Test to run. command: diff --git a/images/fcrepo7/Dockerfile b/images/fcrepo7/Dockerfile index e6cdbfee..e40e21b2 100644 --- a/images/fcrepo7/Dockerfile +++ b/images/fcrepo7/Dockerfile @@ -2,12 +2,13 @@ FROM java21 AS syn-builder ARG TARGETARCH -ARG SYN_VERSION=fix-tomcat11-release-2x -ARG SYN_REF=refs/heads/${SYN_VERSION} +# renovate: datasource=github-releases depName=islandora-syn-fcrepo7 packageName=Islandora/Syn +ARG SYN_VERSION=2.0.1 +ARG SYN_REF=refs/tags/v${SYN_VERSION} ARG SYN_REPOSITORY=https://github.com/Islandora/Syn.git ARG SYN_FILE=islandora-syn-${SYN_VERSION}-all.jar ARG SYN_RELEASE_URL=https://github.com/Islandora/Syn/releases/download/v${SYN_VERSION}/${SYN_FILE} -ARG SYN_SHA256=2ee12833f46aab8980e872a255612865323bf22165f86b8f5208018c1775afa5 +ARG SYN_SHA256=dde30978ca103a78d88f98ee0d2e95b767566377b29b3e8b680831fef8662bd8 WORKDIR /build/syn @@ -72,7 +73,7 @@ RUN --mount=type=cache,id=fcrepo7-downloads-${TARGETARCH},sharing=locked,target= ENV \ DB_NAME=fcrepo \ - DB_PASSWORD=password \ + DB_PASSWORD= \ DB_USER=fcrepo \ FCREPO_ACTIVEMQ_BROKER=tcp://activemq:61616 \ FCREPO_ACTIVEMQ_QUEUE=fedora \ @@ -87,4 +88,4 @@ COPY --link rootfs / RUN chown -R tomcat:tomcat /opt/tomcat -HEALTHCHECK CMD curl -s http://localhost:8080/fcrepo/rest/fcr:systeminfo | jq -e .version +HEALTHCHECK CMD curl -fsS http://localhost:8080/fcrepo/rest/fcr:systeminfo | jq -e .version diff --git a/images/fcrepo7/README.md b/images/fcrepo7/README.md index 5b5bc53b..7dbafb38 100644 --- a/images/fcrepo7/README.md +++ b/images/fcrepo7/README.md @@ -74,23 +74,22 @@ Change `SOFTWARE_VERSION` and then generate the `FCREPO_SHA256` with the followi commands: ```bash -SOFTWARE_VERSION=$(cat fcrepo7/Dockerfile | grep -o 'SOFTWARE_VERSION=.*' | cut -f2 -d=) -FCREPO_FILE=$(cat fcrepo7/Dockerfile | grep -o 'FCREPO_FILE=.*' | cut -f2 -d=) -FCREPO_URL=$(cat fcrepo7/Dockerfile | grep -o 'FCREPO_URL=.*' | cut -f2 -d=) -FCREPO_FILE=$(eval "echo $FCREPO_FILE") -FCREPO_URL=$(eval "echo $FCREPO_URL") -wget --quiet "${FCREPO_URL}" -shasum -a 256 "${FCREPO_FILE}" | cut -f1 -d' ' -rm "${FCREPO_FILE}" +./ci/update-sha.sh fcrepo7 OLD_VERSION NEW_VERSION '' ``` You can change the version used for [syn] by modifying the build arguments `SYN_VERSION` and `SYN_REF` in the `Dockerfile`. -By default, the image fetches `refs/heads/${SYN_VERSION}` from the Syn -repository and builds it with `./gradlew`. To build a tagged Syn release -artifact instead, set `SYN_VERSION`, `SYN_REF=refs/tags/v${SYN_VERSION}`, and -`SYN_SHA256` for that tag's release artifact. +By default, the image downloads the checksum-pinned `v${SYN_VERSION}` release +artifact. Change `SYN_VERSION` and then generate `SYN_SHA256` with: + +```bash +./ci/update-sha.sh islandora-syn-fcrepo7 OLD_VERSION NEW_VERSION '' +``` + +To test an unreleased Syn branch, set `SYN_VERSION` and +`SYN_REF=refs/heads/${SYN_VERSION}`. The image then builds Syn with `./gradlew` +instead of downloading a release artifact. You can change the version used for [fcrepo-import-export] by modifying the build argument `IMPORT_EXPORT_VERSION` and `IMPORT_EXPORT_SHA256` in the @@ -100,14 +99,7 @@ Change `IMPORT_EXPORT_VERSION` and then generate the `IMPORT_EXPORT_SHA256` with the following commands: ```bash -IMPORT_EXPORT_VERSION=$(cat fcrepo7/Dockerfile | grep -o 'IMPORT_EXPORT_VERSION=.*' | cut -f2 -d=) -IMPORT_EXPORT_FILE=$(cat fcrepo7/Dockerfile | grep -o 'IMPORT_EXPORT_FILE=.*' | cut -f2 -d=) -IMPORT_EXPORT_URL=$(cat fcrepo7/Dockerfile | grep -o 'IMPORT_EXPORT_URL=.*' | cut -f2 -d=) -IMPORT_EXPORT_FILE=$(eval "echo $IMPORT_EXPORT_FILE") -IMPORT_EXPORT_URL=$(eval "echo $IMPORT_EXPORT_URL") -wget --quiet "${IMPORT_EXPORT_URL}" -shasum -a 256 "${IMPORT_EXPORT_FILE}" | cut -f1 -d' ' -rm "${IMPORT_EXPORT_FILE}" +./ci/update-sha.sh fcrepo-import-export OLD_VERSION NEW_VERSION '' ``` You can change the version used for [fcrepo-upgrade-utils] by modifying the @@ -118,14 +110,7 @@ Change `UPGRADE_UTILS_VERSION` and then generate the `UPGRADE_UTILS_SHA256` with the following commands: ```bash -UPGRADE_UTILS_VERSION=$(cat fcrepo7/Dockerfile | grep -o 'UPGRADE_UTILS_VERSION=.*' | cut -f2 -d=) -UPGRADE_UTILS_FILE=$(cat fcrepo7/Dockerfile | grep -o 'UPGRADE_UTILS_FILE=.*' | cut -f2 -d=) -UPGRADE_UTILS_URL=$(cat fcrepo7/Dockerfile | grep -o 'UPGRADE_UTILS_URL=.*' | cut -f2 -d=) -UPGRADE_UTILS_FILE=$(eval "echo $UPGRADE_UTILS_FILE") -UPGRADE_UTILS_URL=$(eval "echo $UPGRADE_UTILS_URL") -wget --quiet "${UPGRADE_UTILS_URL}" -shasum -a 256 "${UPGRADE_UTILS_FILE}" | cut -f1 -d' ' -rm "${UPGRADE_UTILS_FILE}" +./ci/update-sha.sh fcrepo-upgrade-utils OLD_VERSION NEW_VERSION '' ``` [base image]: ../base/README.md diff --git a/images/fcrepo7/rootfs/etc/s6-overlay/scripts/fcrepo-setup.sh b/images/fcrepo7/rootfs/etc/s6-overlay/scripts/fcrepo-setup.sh index f35ebb00..abaf0528 100755 --- a/images/fcrepo7/rootfs/etc/s6-overlay/scripts/fcrepo-setup.sh +++ b/images/fcrepo7/rootfs/etc/s6-overlay/scripts/fcrepo-setup.sh @@ -2,19 +2,15 @@ # shellcheck shell=bash set -e -function mysql_create_database { - cat <<-EOF | create-database.sh --- Create fcrepo database in mariadb or mysql. -CREATE DATABASE IF NOT EXISTS ${DB_NAME} CHARACTER SET utf8 COLLATE utf8_general_ci; - --- Create fcrepo user and grant rights. -CREATE USER IF NOT EXISTS '${DB_USER}'@'%' IDENTIFIED BY '${DB_PASSWORD}'; -GRANT ALL PRIVILEGES ON ${DB_NAME}.* to '${DB_USER}'@'%'; -FLUSH PRIVILEGES; +# shellcheck disable=SC1091 +source /usr/local/share/libops/database.sh +# shellcheck disable=SC1091 +source /usr/local/share/libops/environment.sh --- Update fcrepo password if changed. -SET PASSWORD FOR ${DB_USER}@'%' = PASSWORD('${DB_PASSWORD}') -EOF +function mysql_create_database { + DB_CHARACTER_SET=utf8 \ + DB_COLLATION=utf8_general_ci \ + render-database-bootstrap-sql.sh | create-database.sh } function wait_for_broker { @@ -32,7 +28,14 @@ function wait_for_broker { } function main { - mysql_create_database + require_environment_variables DB_HOST DB_NAME DB_USER DB_PASSWORD + if [ "${DB_HOST}" = "mariadb" ]; then + database_bootstrap_if_enabled mysql_create_database + fi + LIBOPS_DATABASE_PASSWORD="${DB_PASSWORD}" wait-for-database.sh \ + --host "${DB_HOST}" \ + --port "${DB_PORT}" \ + --user "${DB_USER}" # When bind mounting we need to ensure that we # actually can write to the folder. chown tomcat:tomcat /data diff --git a/images/fcrepo7/tests/ServiceHealthcheck/docker-compose.yml b/images/fcrepo7/tests/ServiceHealthcheck/docker-compose.yml index 94254798..4413223e 100644 --- a/images/fcrepo7/tests/ServiceHealthcheck/docker-compose.yml +++ b/images/fcrepo7/tests/ServiceHealthcheck/docker-compose.yml @@ -8,9 +8,20 @@ services: activemq: <<: *common image: ${ACTIVEMQ:-libops/activemq:local} + environment: + ACTIVEMQ_PASSWORD: test-broker-password + ACTIVEMQ_WEB_ADMIN_PASSWORD: test-web-password mariadb: image: ${MARIADB11:-libops/mariadb:local-11} + environment: + DB_ROOT_PASSWORD: root + DB_NAME: fcrepo + DB_USER: fcrepo + DB_PASSWORD: test-database-password fcrepo: + environment: + DB_PASSWORD: test-database-password + JWT_ADMIN_TOKEN: islandora volumes: - ./test.sh:/test.sh # Test to run. command: diff --git a/images/fcrepo7/tests/ServiceHealthcheck/test.sh b/images/fcrepo7/tests/ServiceHealthcheck/test.sh index dd1d6bdf..e84270db 100755 --- a/images/fcrepo7/tests/ServiceHealthcheck/test.sh +++ b/images/fcrepo7/tests/ServiceHealthcheck/test.sh @@ -2,7 +2,7 @@ # shellcheck shell=bash # Invoked indirectly by the SIGTERM trap below. -# shellcheck disable=SC2329 +# shellcheck disable=SC2317,SC2329 on_terminate() { echo "Termination signal received. Exiting..." exit 0 diff --git a/images/fcrepo7/tests/ServiceStartsWithBackendMySQL/docker-compose.yml b/images/fcrepo7/tests/ServiceStartsWithBackendMySQL/docker-compose.yml index 690dfaea..c390f8cb 100644 --- a/images/fcrepo7/tests/ServiceStartsWithBackendMySQL/docker-compose.yml +++ b/images/fcrepo7/tests/ServiceStartsWithBackendMySQL/docker-compose.yml @@ -10,9 +10,20 @@ services: activemq: <<: *common image: ${ACTIVEMQ:-libops/activemq:local} + environment: + ACTIVEMQ_PASSWORD: test-broker-password + ACTIVEMQ_WEB_ADMIN_PASSWORD: test-web-password mariadb: image: ${MARIADB11:-libops/mariadb:local-11} + environment: + DB_ROOT_PASSWORD: root + DB_NAME: fcrepo + DB_USER: fcrepo + DB_PASSWORD: test-database-password fcrepo: + environment: + DB_PASSWORD: test-database-password + JWT_ADMIN_TOKEN: islandora volumes: - ./test.sh:/test.sh # Test to run. command: diff --git a/images/fcrepo7/tests/ServiceStartsWithBackendMySQL/test.sh b/images/fcrepo7/tests/ServiceStartsWithBackendMySQL/test.sh index f9ef12dd..14dd561f 100755 --- a/images/fcrepo7/tests/ServiceStartsWithBackendMySQL/test.sh +++ b/images/fcrepo7/tests/ServiceStartsWithBackendMySQL/test.sh @@ -5,7 +5,9 @@ source /usr/local/share/isle/utilities.sh function count { - cat <<-EOF | execute-sql-file.sh --database "fcrepo" - -- -N 2>/dev/null + cat <<-EOF | LIBOPS_DATABASE_PASSWORD="${DB_PASSWORD}" execute-sql-file.sh \ + --host "${DB_HOST}" --port "${DB_PORT}" --user "${DB_USER}" \ + --database "${DB_NAME}" - -- -N 2>/dev/null SELECT COUNT(*) as count FROM containment; EOF } diff --git a/images/fcrepo7/tests/SynAuthentication/docker-compose.yml b/images/fcrepo7/tests/SynAuthentication/docker-compose.yml index 2bb8e6ff..3048047b 100644 --- a/images/fcrepo7/tests/SynAuthentication/docker-compose.yml +++ b/images/fcrepo7/tests/SynAuthentication/docker-compose.yml @@ -8,9 +8,20 @@ services: activemq: <<: *common image: ${ACTIVEMQ:-libops/activemq:local} + environment: + ACTIVEMQ_PASSWORD: test-broker-password + ACTIVEMQ_WEB_ADMIN_PASSWORD: test-web-password mariadb: image: ${MARIADB11:-libops/mariadb:local-11} + environment: + DB_ROOT_PASSWORD: root + DB_NAME: fcrepo + DB_USER: fcrepo + DB_PASSWORD: test-database-password fcrepo: + environment: + DB_PASSWORD: test-database-password + JWT_ADMIN_TOKEN: islandora volumes: - ./test.sh:/test.sh # Test to run. command: diff --git a/images/islandora/Dockerfile b/images/islandora/Dockerfile index 37b50092..f3ef28c5 100644 --- a/images/islandora/Dockerfile +++ b/images/islandora/Dockerfile @@ -18,4 +18,4 @@ COPY --link rootfs / RUN mkdir -p /var/www/drupal && \ chown -R nginx:nginx /var/www/drupal -HEALTHCHECK CMD { [ ! -f /var/www/drupal/web/core/lib/Drupal.php ] || [ -f /installed ]; } && curl -s http://localhost/status | grep -q pool +HEALTHCHECK CMD { [ ! -f /var/www/drupal/web/core/lib/Drupal.php ] || [ -f /installed ]; } && curl -fsS http://localhost/status | grep -q pool diff --git a/images/islandora/README.md b/images/islandora/README.md index 7c38e2a1..1aaed527 100644 --- a/images/islandora/README.md +++ b/images/islandora/README.md @@ -53,7 +53,7 @@ derivative service and `fcrepo` based containers. | :------------------------------ | :---------------------- | :------------------------------------------------- | | DRUPAL_DEFAULT_ACCOUNT_EMAIL | webmaster@localhost.com | The email to use for the admin account | | DRUPAL_DEFAULT_ACCOUNT_NAME | admin | The Drupal administrator user | -| DRUPAL_DEFAULT_ACCOUNT_PASSWORD | password | The Drupal administrator user password | +| DRUPAL_DEFAULT_ACCOUNT_PASSWORD | | The Drupal administrator password; required when installing a site | | DRUPAL_DEFAULT_EMAIL | webmaster@localhost.com | The Drupal administrators email | | DRUPAL_DEFAULT_LOCALE | en | The Drupal sites locale | | DRUPAL_DEFAULT_NAME | default | The Drupal sites name | @@ -62,7 +62,8 @@ derivative service and `fcrepo` based containers. | DRUPAL_DEFAULT_CONFIGDIR | | Install using existing config files from directory | | DRUPAL_DEFAULT_INSTALL | true | Perform install if not already installed | -Provide your own passwords when running in production. +The image has no administrator password default and fails closed when +`DRUPAL_DEFAULT_INSTALL=true` without an explicit password. [base image]: ../base/README.md [Drupal]: https://www.drupal.org/ diff --git a/images/islandora/rootfs/etc/islandora/utilities.sh b/images/islandora/rootfs/etc/islandora/utilities.sh index 7c6eb270..fe1a9af5 100755 --- a/images/islandora/rootfs/etc/islandora/utilities.sh +++ b/images/islandora/rootfs/etc/islandora/utilities.sh @@ -100,11 +100,10 @@ function execute_sql_file { port=$(drupal_site_env "${site}" "DB_PORT") user=$(drupal_site_env "${site}" "DB_ROOT_USER") password=$(drupal_site_env "${site}" "DB_ROOT_PASSWORD") - execute-sql-file.sh \ + LIBOPS_DATABASE_PASSWORD="${password}" execute-sql-file.sh \ --host "${host}" \ --port "${port}" \ --user "${user}" \ - --password "${password}" \ "${@}" } @@ -115,16 +114,12 @@ function mysql_query { db_name=$(drupal_site_env "${site}" "DB_NAME") db_user=$(drupal_site_env "${site}" "DB_USER") db_password=$(drupal_site_env "${site}" "DB_PASSWORD") - cat <<-EOF --- Create if does not exist. -CREATE DATABASE IF NOT EXISTS ${db_name} CHARACTER SET utf8 COLLATE utf8_general_ci; -CREATE USER IF NOT EXISTS ${db_user}@'%' IDENTIFIED BY "${db_password}"; -GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, DROP, INDEX, ALTER, CREATE TEMPORARY TABLES, LOCK TABLES ON ${db_name}.* to ${db_user}@'%' IDENTIFIED BY "${db_password}"; -FLUSH PRIVILEGES; - --- Update DB_USER password if changed. -SET PASSWORD FOR ${db_user}@'%' = PASSWORD('${db_password}'); -EOF + DB_NAME="${db_name}" \ + DB_USER="${db_user}" \ + DB_PASSWORD="${db_password}" \ + DB_CHARACTER_SET=utf8 \ + DB_COLLATION=utf8_general_ci \ + render-database-bootstrap-sql.sh } function mysql_create_database { @@ -209,16 +204,16 @@ function install_site { echo "USE_EXISTIG_CONFIG: ${use_existing_config_arg}" echo "EVERYTHING ELSE: $*" + LIBOPS_DRUPAL_INSTALL_ACCOUNT_PASSWORD="${account_password}" \ + LIBOPS_DRUPAL_INSTALL_DB_PASSWORD="${password}" \ /usr/local/bin/install-drupal-site.sh \ --host "${host}" \ --port "${port}" \ --db-user "${user}" \ - --db-password "${password}" \ --db-name "${db_name}" \ "${profile}" \ --account-mail="${account_email}" \ --account-name="${account_name}" \ - --account-pass="${account_password}" \ --site-mail="${site_email}" \ --locale="${site_locale}" \ --site-name="${site_name}" \ diff --git a/images/islandora/rootfs/etc/s6-overlay/scripts/install.d/50-islandora.sh b/images/islandora/rootfs/etc/s6-overlay/scripts/install.d/50-islandora.sh index bfae00b7..0719ca00 100755 --- a/images/islandora/rootfs/etc/s6-overlay/scripts/install.d/50-islandora.sh +++ b/images/islandora/rootfs/etc/s6-overlay/scripts/install.d/50-islandora.sh @@ -2,8 +2,6 @@ # shellcheck shell=bash set -euo pipefail -readonly SITE="default" - function ingress_primary_hostname { local hostnames hostname hostnames="${INGRESS_HOSTNAMES:-localhost}" diff --git a/images/java25/README.md b/images/java25/README.md index a326586c..4d5f5275 100644 --- a/images/java25/README.md +++ b/images/java25/README.md @@ -1,8 +1,8 @@ -# Java 21 +# Java 25 -Docker image for [Java] OpenJDK version 21. +Docker image for [Java] OpenJDK version 25. -Built from [libops/isle-buildkit java21](https://github.com/libops/buildkit/tree/main/images/java21) +Built from [libops/buildkit java25](https://github.com/libops/buildkit/tree/main/images/java25) Please refer to the [Java Documentation] for more in-depth information. diff --git a/images/mariadb11/README.md b/images/mariadb11/README.md index eec5cd60..c600cfd4 100644 --- a/images/mariadb11/README.md +++ b/images/mariadb11/README.md @@ -1,17 +1,20 @@ # MariaDB 11 -Docker image for [MariaDB] version 11.4.12 +Docker image for [MariaDB] version 11.8.8. Built from [libops/isle-buildkit mariadb11](https://github.com/libops/buildkit/tree/main/images/mariadb11) Please refer to the [MariaDB Documentation] for more in-depth information. -As a quick example this will bring up an instance of MariaDB, and allow you to -log in with client as the user `root` with the password `password`. +As a quick example this will generate a root password, bring up MariaDB, and +connect as `root` using that generated credential. ```bash -docker run --rm -d -name mariadb libops/mariadb -docker exec -ti mariadb mysql -u root --password='password' +export DB_ROOT_PASSWORD="$(openssl rand -hex 32)" +docker run --rm -d --name mariadb \ + --env DB_ROOT_PASSWORD \ + libops/mariadb:11 +docker exec -ti --env MYSQL_PWD="${DB_ROOT_PASSWORD}" mariadb mariadb -u root ``` ## Dependencies @@ -41,8 +44,11 @@ default database connection configuration. | Environment Variable | Default | Description | | :------------------- | :------ | :------------------------------------------------------------------------------------ | -| MYSQL_ROOT_PASSWORD | | The database root user password. Defaults to `DB_ROOT_PASSWORD` | -| MYSQL_ROOT_USER | | The database root user (used to create the site database). Defaults to `DB_ROOT_USER` | +| DB_ROOT_PASSWORD | | The database root password; required at startup | +| DB_ROOT_USER | root | The database root user | +| DB_NAME | default | Optional scoped database to create when `DB_PASSWORD` is set | +| DB_USER | default | Optional scoped database user to create when `DB_PASSWORD` is set | +| DB_PASSWORD | | Scoped user password; leave empty when provisioning through a separate initializer | | MYSQL_MAX_ALLOWED_PACKET | 16777216 | Max packet length to send to or receive from the server, [documentation](https://mariadb.com/docs/server/ref/mdb/system-variables/max_allowed_packet/) | MYSQL_TRANSACTION_ISOLATION | READ-COMMITTED | The isolation level for transactions. diff --git a/images/mariadb11/rootfs/etc/s6-overlay/s6-rc.d/mysqld/run b/images/mariadb11/rootfs/etc/s6-overlay/s6-rc.d/mysqld/run index 8a29d178..b705d4ef 100755 --- a/images/mariadb11/rootfs/etc/s6-overlay/s6-rc.d/mysqld/run +++ b/images/mariadb11/rootfs/etc/s6-overlay/s6-rc.d/mysqld/run @@ -1,3 +1,4 @@ #!/usr/bin/env bash set -e +unset DB_ROOT_PASSWORD exec s6-setuidgid mysql /usr/bin/mariadbd diff --git a/images/mariadb11/rootfs/etc/s6-overlay/scripts/mysql-setup.sh b/images/mariadb11/rootfs/etc/s6-overlay/scripts/mysql-setup.sh index 9899706e..72bf06bb 100755 --- a/images/mariadb11/rootfs/etc/s6-overlay/scripts/mysql-setup.sh +++ b/images/mariadb11/rootfs/etc/s6-overlay/scripts/mysql-setup.sh @@ -2,6 +2,23 @@ # shellcheck shell=bash set -e +# shellcheck disable=SC1091 +source /usr/local/share/libops/database.sh +# shellcheck disable=SC1091 +source /usr/local/share/libops/environment.sh + +# Never initialize a network-accessible root account with empty credentials. +database_require_root_credentials + +# A scoped database can be provisioned by the database container for isolated +# tests or simple deployments. Production templates use a dedicated one-shot +# initializer and therefore do not pass DB_PASSWORD to this service. +bootstrap_sql= +if [ -n "${DB_PASSWORD:-}" ]; then + require_environment_variables DB_NAME DB_USER + bootstrap_sql=$(render-database-bootstrap-sql.sh) +fi + # Make run directory if it does not exist. mkdir /run/mysqld &>/dev/null || true chown mysql:mysql /run/mysqld @@ -22,12 +39,16 @@ done # Change the root users password. echo "Changing the root users (${DB_ROOT_USER}) password." +database_validate_identifier DB_ROOT_USER "${DB_ROOT_USER}" +db_root_user_sql=$(database_escape_sql_literal "${DB_ROOT_USER}") +db_root_password_sql=$(database_escape_sql_literal "${DB_ROOT_PASSWORD}") cat <<-EOF | mariadb --no-defaults --protocol=socket --user="${DB_ROOT_USER}" - CREATE USER IF NOT EXISTS '${DB_ROOT_USER}'@'%'; - GRANT ALL PRIVILEGES ON *.* TO '${DB_ROOT_USER}'@'%' WITH GRANT OPTION; - SET PASSWORD FOR '${DB_ROOT_USER}'@'localhost' = PASSWORD('${DB_ROOT_PASSWORD}'); - SET PASSWORD FOR '${DB_ROOT_USER}'@'%' = PASSWORD('${DB_ROOT_PASSWORD}'); + CREATE USER IF NOT EXISTS '${db_root_user_sql}'@'%'; + GRANT ALL PRIVILEGES ON *.* TO '${db_root_user_sql}'@'%' WITH GRANT OPTION; + SET PASSWORD FOR '${db_root_user_sql}'@'localhost' = PASSWORD('${db_root_password_sql}'); + SET PASSWORD FOR '${db_root_user_sql}'@'%' = PASSWORD('${db_root_password_sql}'); FLUSH PRIVILEGES; + ${bootstrap_sql} EOF # Stop the database. diff --git a/images/mariadb11/tests/RejectsEmptyRootPassword/docker-compose.yml b/images/mariadb11/tests/RejectsEmptyRootPassword/docker-compose.yml new file mode 100644 index 00000000..d768c432 --- /dev/null +++ b/images/mariadb11/tests/RejectsEmptyRootPassword/docker-compose.yml @@ -0,0 +1,7 @@ +--- +name: mariadb11-rejectsemptyrootpassword +services: + mariadb: + image: ${MARIADB11:-libops/mariadb:local-11} + environment: + DB_ROOT_PASSWORD: "" diff --git a/images/mariadb11/tests/ServiceHealthcheck/docker-compose.yml b/images/mariadb11/tests/ServiceHealthcheck/docker-compose.yml index 37a1e4ea..4b792668 100644 --- a/images/mariadb11/tests/ServiceHealthcheck/docker-compose.yml +++ b/images/mariadb11/tests/ServiceHealthcheck/docker-compose.yml @@ -8,6 +8,8 @@ services: mariadb: <<: *common image: ${MARIADB11:-libops/mariadb:local-11} + environment: + DB_ROOT_PASSWORD: root volumes: - ./test.sh:/test.sh # Test to run. command: diff --git a/images/mariadb11/tests/ServiceHealthcheck/test.sh b/images/mariadb11/tests/ServiceHealthcheck/test.sh index dd1d6bdf..e84270db 100755 --- a/images/mariadb11/tests/ServiceHealthcheck/test.sh +++ b/images/mariadb11/tests/ServiceHealthcheck/test.sh @@ -2,7 +2,7 @@ # shellcheck shell=bash # Invoked indirectly by the SIGTERM trap below. -# shellcheck disable=SC2329 +# shellcheck disable=SC2317,SC2329 on_terminate() { echo "Termination signal received. Exiting..." exit 0 diff --git a/images/mariadb11/tests/ServiceStartsWithDefaults/docker-compose.yml b/images/mariadb11/tests/ServiceStartsWithDefaults/docker-compose.yml index 485d12e0..cc527808 100644 --- a/images/mariadb11/tests/ServiceStartsWithDefaults/docker-compose.yml +++ b/images/mariadb11/tests/ServiceStartsWithDefaults/docker-compose.yml @@ -8,6 +8,8 @@ services: mariadb: <<: *common image: ${MARIADB11:-libops/mariadb:local-11} + environment: + DB_ROOT_PASSWORD: root volumes: - ./test.sh:/test.sh # Test to run. command: diff --git a/images/nginx/Dockerfile b/images/nginx/Dockerfile index c3522b99..1f6da414 100644 --- a/images/nginx/Dockerfile +++ b/images/nginx/Dockerfile @@ -43,4 +43,4 @@ ENV \ COPY --link rootfs / -HEALTHCHECK CMD curl -s http://localhost/status | grep -q pool +HEALTHCHECK CMD curl -fsS http://localhost/status | grep -q pool diff --git a/images/nginx/rootfs/etc/s6-overlay/s6-rc.d/nginx/run b/images/nginx/rootfs/etc/s6-overlay/s6-rc.d/nginx/run index 65719fcb..bccb8b44 100755 --- a/images/nginx/rootfs/etc/s6-overlay/s6-rc.d/nginx/run +++ b/images/nginx/rootfs/etc/s6-overlay/s6-rc.d/nginx/run @@ -1,3 +1,6 @@ #!/usr/bin/env bash set -e +unset DB_ROOT_PASSWORD +unset DRUPAL_DEFAULT_ACCOUNT_PASSWORD OJS_ADMIN_PASSWORD \ + OMEKA_CLASSIC_ADMIN_PASSWORD OMEKA_S_ADMIN_PASSWORD WORDPRESS_ADMIN_PASSWORD exec s6-notifyoncheck -t 30000 -n 1 /usr/sbin/nginx diff --git a/images/ojs/Dockerfile b/images/ojs/Dockerfile index 184c38b6..cad02818 100644 --- a/images/ojs/Dockerfile +++ b/images/ojs/Dockerfile @@ -2,6 +2,8 @@ ARG PHP_BASE=nginx-php83 FROM ${PHP_BASE} +LABEL org.opencontainers.image.licenses="GPL-3.0-only" + ARG TARGETARCH ARG PHP_FPM_SOCKET=/run/php-fpm/php-fpm.sock ARG PHP_PACKAGE=php83 @@ -14,11 +16,19 @@ ARG \ # renovate: datasource=repology depName=alpine_3_24/npm NPM_VERSION=11.12.1-r0 \ # renovate: datasource=repology depName=alpine_3_24/poppler-utils - POPPLER_VERSION=25.12.0-r1 + POPPLER_VERSION=25.12.0-r1 \ + # renovate: datasource=custom.ojs depName=ojs + SOFTWARE_VERSION=3.5.0-5 + +ARG FILE=ojs-${SOFTWARE_VERSION}.tar.gz +ARG URL=https://pkp.sfu.ca/ojs/download/${FILE} +ARG SHA256="fd59cb1add60ab4e56e40ed843ea43ff84af11f0b64c3940f2e03186ad11445e" EXPOSE 80 WORKDIR /var/www/ojs +COPY --link patches /opt/ojs-patches + RUN --mount=type=cache,id=ojs-apk-${TARGETARCH},sharing=locked,target=/var/cache/apk \ apk add \ antiword=="${ANTIWORD_VERSION}" \ @@ -29,7 +39,33 @@ RUN --mount=type=cache,id=ojs-apk-${TARGETARCH},sharing=locked,target=/var/cache "${PHP_PACKAGE}-ftp" \ "${PHP_PACKAGE}-gettext" \ && \ + cleanup.sh + +# Fresh OJS installations normally fetch mutable ROR and IP geolocation datasets +# while creating the database. Defer those network-backed tasks to OJS's scheduler +# so first boot remains bounded and can run without outbound network access. Verify +# the pinned release still contains exactly the two expected actions before changing +# its install descriptor; upgrades use a separate descriptor and are unaffected. +RUN --mount=type=cache,id=ojs-downloads-${TARGETARCH},sharing=locked,target=/opt/downloads \ + download.sh \ + --url "${URL}" \ + --sha256 "${SHA256}" \ + --strip \ + --dest /var/www/ojs \ + && \ + test "$(grep -Fc '' dbscripts/xml/install.xml)" -eq 1 && \ + test "$(grep -Fc '' dbscripts/xml/install.xml)" -eq 1 && \ + patch --forward --strip=1 --input=/opt/ojs-patches/install-cli-database-port.patch && \ + sed -i \ + -e '//d' \ + -e '//d' \ + dbscripts/xml/install.xml \ + && \ + mkdir -p /usr/share/licenses/ojs && \ + cp docs/COPYING /usr/share/licenses/ojs/COPYING && \ + rm -rf .github tests docs /opt/ojs-patches && \ mkdir -p /var/www/files /var/www/ojs/cache /var/www/ojs/public && \ + touch /var/www/ojs/opcache_stat.php && \ chown -R nginx:nginx /var/www/ojs /var/www/files && \ cleanup.sh @@ -38,13 +74,14 @@ ENV \ DB_PORT=3306 \ DB_NAME=ojs \ DB_USER=ojs \ - DB_PASSWORD=changeme \ - OJS_SALT=changeme \ - OJS_API_KEY_SECRET=changeme \ - OJS_SECRET_KEY=changeme \ + DB_PASSWORD= \ + OJS_SALT= \ + OJS_API_KEY_SECRET= \ + OJS_SECRET_KEY= \ OJS_ADMIN_USERNAME=admin \ OJS_ADMIN_EMAIL=admin@localhost \ - OJS_ADMIN_PASSWORD=changeme \ + OJS_ADMIN_PASSWORD= \ + OJS_INSTALLED=Off \ OJS_LOCALE=en \ OJS_TIMEZONE=UTC \ OJS_FILES_DIR=/var/www/files \ @@ -86,4 +123,4 @@ ENV \ COPY --link rootfs / -HEALTHCHECK CMD curl -s http://localhost/status | grep -q pool +HEALTHCHECK CMD test -f /installed && test -f /var/www/ojs/index.php && curl -fsS http://localhost/status | grep -q pool diff --git a/images/ojs/patches/install-cli-database-port.patch b/images/ojs/patches/install-cli-database-port.patch new file mode 100644 index 00000000..3e7dfe9f --- /dev/null +++ b/images/ojs/patches/install-cli-database-port.patch @@ -0,0 +1,10 @@ +--- a/lib/pkp/classes/cliTool/InstallTool.php ++++ b/lib/pkp/classes/cliTool/InstallTool.php +@@ -108,6 +108,7 @@ + $this->printTitle('installer.databaseSettings'); + $this->readParamOptions('databaseDriver', 'installer.databaseDriver', $installForm->getDatabaseDriversOptions()); + $this->readParam('databaseHost', 'installer.databaseHost', ''); ++ $this->params['databasePort'] = getenv('DB_PORT') ?: 3306; + $this->readParam('databaseUsername', 'installer.databaseUsername', ''); + $this->readParam('databasePassword', 'installer.databasePassword', ''); + $this->readParam('databaseName', 'installer.databaseName'); diff --git a/images/ojs/rootfs/etc/confd/templates/config.inc.tmpl b/images/ojs/rootfs/etc/confd/templates/config.inc.tmpl index 04a0f01f..260d822f 100644 --- a/images/ojs/rootfs/etc/confd/templates/config.inc.tmpl +++ b/images/ojs/rootfs/etc/confd/templates/config.inc.tmpl @@ -32,7 +32,7 @@ app_key = "{{ template "doubleQuoteEscape" (getenv "OJS_SECRET_KEY") }}" ; Set this to On once the system has been installed ; (This is generally done automatically by the installer) -installed = Off +installed = {{ template "booleanOnOff" (getenv "OJS_INSTALLED") }} ; The canonical URL to the OJS installation (excluding the trailing slash) {{ $base_url_ingress_hostnames := replace (getenv "INGRESS_HOSTNAMES") " " "" -1 }} @@ -279,10 +279,10 @@ session_check_ip = On encryption = sha1 ; The unique salt to use for generating password reset hashes -salt = "{{ getenv "OJS_SALT" }}" +salt = "{{ template "doubleQuoteEscape" (getenv "OJS_SALT") }}" ; The unique secret used for encoding and decoding API keys -api_key_secret = "{{ getenv "OJS_API_KEY_SECRET" }}" +api_key_secret = "{{ template "doubleQuoteEscape" (getenv "OJS_API_KEY_SECRET") }}" ; The number of seconds before a password reset hash expires (defaults to 7200 / 2 hours) reset_seconds = 7200 diff --git a/images/ojs/rootfs/etc/cont-init.d/01-ojs-permissions b/images/ojs/rootfs/etc/cont-init.d/01-ojs-permissions index b562a2ef..ce5906ae 100755 --- a/images/ojs/rootfs/etc/cont-init.d/01-ojs-permissions +++ b/images/ojs/rootfs/etc/cont-init.d/01-ojs-permissions @@ -1,4 +1,5 @@ #!/command/with-contenv bash +# shellcheck shell=bash set -euo pipefail diff --git a/images/ojs/rootfs/etc/s6-overlay/s6-rc.d/confd-oneshot/dependencies.d/ojs-install-state b/images/ojs/rootfs/etc/s6-overlay/s6-rc.d/confd-oneshot/dependencies.d/ojs-install-state new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/images/ojs/rootfs/etc/s6-overlay/s6-rc.d/confd-oneshot/dependencies.d/ojs-install-state @@ -0,0 +1 @@ + diff --git a/images/ojs/rootfs/etc/s6-overlay/s6-rc.d/confd/dependencies.d/ojs-setup b/images/ojs/rootfs/etc/s6-overlay/s6-rc.d/confd/dependencies.d/ojs-setup new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/images/ojs/rootfs/etc/s6-overlay/s6-rc.d/confd/dependencies.d/ojs-setup @@ -0,0 +1 @@ + diff --git a/images/ojs/rootfs/etc/s6-overlay/s6-rc.d/ojs-install-state/dependencies.d/container-environment b/images/ojs/rootfs/etc/s6-overlay/s6-rc.d/ojs-install-state/dependencies.d/container-environment new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/images/ojs/rootfs/etc/s6-overlay/s6-rc.d/ojs-install-state/dependencies.d/container-environment @@ -0,0 +1 @@ + diff --git a/images/ojs/rootfs/etc/s6-overlay/s6-rc.d/ojs-install-state/type b/images/ojs/rootfs/etc/s6-overlay/s6-rc.d/ojs-install-state/type new file mode 100644 index 00000000..bdd22a18 --- /dev/null +++ b/images/ojs/rootfs/etc/s6-overlay/s6-rc.d/ojs-install-state/type @@ -0,0 +1 @@ +oneshot diff --git a/images/ojs/rootfs/etc/s6-overlay/s6-rc.d/ojs-install-state/up b/images/ojs/rootfs/etc/s6-overlay/s6-rc.d/ojs-install-state/up new file mode 100644 index 00000000..b86ae318 --- /dev/null +++ b/images/ojs/rootfs/etc/s6-overlay/s6-rc.d/ojs-install-state/up @@ -0,0 +1 @@ +/etc/s6-overlay/scripts/ojs-install-state.sh diff --git a/images/ojs/rootfs/etc/s6-overlay/s6-rc.d/ojs-setup/dependencies.d/ready b/images/ojs/rootfs/etc/s6-overlay/s6-rc.d/ojs-setup/dependencies.d/ready new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/images/ojs/rootfs/etc/s6-overlay/s6-rc.d/ojs-setup/dependencies.d/ready @@ -0,0 +1 @@ + diff --git a/images/ojs/rootfs/etc/s6-overlay/scripts/ojs-install-state.sh b/images/ojs/rootfs/etc/s6-overlay/scripts/ojs-install-state.sh new file mode 100755 index 00000000..42de37a5 --- /dev/null +++ b/images/ojs/rootfs/etc/s6-overlay/scripts/ojs-install-state.sh @@ -0,0 +1,15 @@ +#!/command/with-contenv bash +# shellcheck shell=bash + +set -euo pipefail + +state=Off +if [ -f /installed ]; then + state=On +fi + +environment_dir=/var/run/s6/container_environment +umask 077 +mkdir -p "${environment_dir}" +printf '%s' "${state}" >"${environment_dir}/OJS_INSTALLED" +chmod 0600 "${environment_dir}/OJS_INSTALLED" diff --git a/images/ojs/rootfs/etc/s6-overlay/scripts/ojs-setup.sh b/images/ojs/rootfs/etc/s6-overlay/scripts/ojs-setup.sh index ebc62727..a1cc54f5 100755 --- a/images/ojs/rootfs/etc/s6-overlay/scripts/ojs-setup.sh +++ b/images/ojs/rootfs/etc/s6-overlay/scripts/ojs-setup.sh @@ -3,22 +3,22 @@ set -eou pipefail -function mysql_create_database { - cat <<-EOF | create-database.sh -CREATE DATABASE IF NOT EXISTS ${DB_NAME} CHARACTER SET utf8 COLLATE utf8_general_ci; - -CREATE USER IF NOT EXISTS '${DB_USER}'@'%' IDENTIFIED BY '${DB_PASSWORD}'; -GRANT ALL PRIVILEGES ON ${DB_NAME}.* to '${DB_USER}'@'%'; -FLUSH PRIVILEGES; +# shellcheck source=images/base/rootfs/usr/local/share/libops/database.sh +source "${LIBOPS_DATABASE_LIBRARY:-/usr/local/share/libops/database.sh}" +# shellcheck source=images/base/rootfs/usr/local/share/libops/environment.sh +source "${LIBOPS_ENVIRONMENT_LIBRARY:-/usr/local/share/libops/environment.sh}" -SET PASSWORD FOR ${DB_USER}@'%' = PASSWORD('${DB_PASSWORD}'); -EOF +function mysql_create_database { + DB_CHARACTER_SET=utf8 \ + DB_COLLATION=utf8_general_ci \ + render-database-bootstrap-sql.sh | create-database.sh } function set_ojs_installed { - sed -i 's/^installed = .*/installed = On/' /var/www/ojs/config.inc.php - chmod 440 /var/www/ojs/config.inc.php touch /installed + /etc/s6-overlay/scripts/ojs-install-state.sh + export OJS_INSTALLED=On + render_ojs_config } function fix_ojs_writable_permissions { @@ -35,16 +35,62 @@ function render_ojs_config { /etc/s6-overlay/scripts/confd-oneshot.sh } +function validate_ojs_app_key { + local encoded_key key_length + + if [[ "${OJS_SECRET_KEY}" == base64:* ]]; then + encoded_key=${OJS_SECRET_KEY#base64:} + if [[ ! "${encoded_key}" =~ ^[A-Za-z0-9+/]{43}=$ ]] || + ! key_length=$(printf '%s' "${encoded_key}" | openssl base64 -d -A 2>/dev/null | wc -c); then + echo "OJS_SECRET_KEY is not valid base64" >&2 + return 1 + fi + else + key_length=${#OJS_SECRET_KEY} + fi + + if [ "${key_length}" -ne 32 ]; then + echo "OJS_SECRET_KEY must contain exactly 32 bytes for the default aes-256-cbc cipher" >&2 + return 1 + fi +} + +function wait_for_database { + local attempts=0 + until database_mariadb_with_password "${DB_PASSWORD}" \ + --host="${DB_HOST}" \ + --port="${DB_PORT}" \ + --user="${DB_USER}" \ + --database="${DB_NAME}" \ + --execute='SELECT 1' \ + >/dev/null 2>&1; do + attempts=$((attempts + 1)) + if [ "${attempts}" -ge 60 ]; then + echo "Database was not ready in time: ${DB_HOST}:${DB_PORT}" >&2 + return 1 + fi + sleep 2 + done +} + function check_ojs_installed { - mysql -h"${DB_HOST}" -u"${DB_USER}" -p"${DB_PASSWORD}" "${DB_NAME}" \ - -e "SELECT 1 FROM versions WHERE current = 1 AND product_type = 'core' AND product = 'ojs2' LIMIT 1" &>/dev/null + database_mariadb_with_password "${DB_PASSWORD}" \ + --host="${DB_HOST}" \ + --port="${DB_PORT}" \ + --user="${DB_USER}" \ + --database="${DB_NAME}" \ + --execute="SELECT 1 FROM versions WHERE current = 1 AND product_type = 'core' AND product = 'ojs2' LIMIT 1" \ + &>/dev/null return $? } function install_ojs { echo "OJS not installed. Running installation..." - local enable_beacon=n + local enable_beacon=n install_log install_success= + umask 077 + install_log=$(mktemp -t ojs-install.XXXXXXXXXX) + trap 'rm -f -- "${install_log}"' EXIT case "${OJS_ENABLE_BEACON}" in 1|[Oo][Nn]|[Tt][Rr][Uu][Ee]|[Yy]*) enable_beacon=y @@ -68,49 +114,60 @@ function install_ojs { printf '%s\n' "${OJS_OAI_REPOSITORY_ID}" printf '%s\n' "${enable_beacon}" printf 'y\n' - } | php /var/www/ojs/tools/install.php > /tmp/ojs-install.log 2>&1 && install_success=true || install_success= + } | php /var/www/ojs/tools/install.php >"${install_log}" 2>&1 && install_success=true || install_success= - if [ -n "${install_success}" ] && grep -q "Successfully installed version" /tmp/ojs-install.log && check_ojs_installed; then + if [ -n "${install_success}" ] && grep -q "Successfully installed version" "${install_log}" && check_ojs_installed; then echo "==========================================" echo "OJS Installation Complete!" echo "==========================================" - rm /tmp/ojs-install.log - render_ojs_config + rm -f -- "${install_log}" + trap - EXIT set_ojs_installed fix_ojs_writable_permissions else echo "==========================================" echo "OJS Installation Failed!" echo "==========================================" - cat /tmp/ojs-install.log + cat "${install_log}" echo "==========================================" exit 1 fi } +function setup_ojs_database { + if [ "${DB_HOST}" = "mariadb" ]; then + database_bootstrap_if_enabled mysql_create_database + fi + wait_for_database + if check_ojs_installed; then + echo "OJS already installed. Skipping installation." + set_ojs_installed + fix_ojs_writable_permissions + return 0 + fi + require_environment_variables OJS_ADMIN_PASSWORD + install_ojs +} + function main { if [ ! -f /var/www/ojs/index.php ]; then echo "OJS application files are not present. Skipping OJS setup." return 0 fi + require_environment_variables \ + DB_HOST DB_PORT DB_NAME DB_USER DB_PASSWORD \ + OJS_SALT OJS_API_KEY_SECRET OJS_SECRET_KEY + validate_ojs_app_key + # wait for nginx if ! timeout 300 wait-for-open-port.sh localhost 80; then echo "Could not connect to nginx at localhost:80" exit 1 fi - if [ "${DB_HOST}" = "mariadb" ]; then - mysql_create_database - if check_ojs_installed; then - echo "OJS already installed. Skipping installation." - set_ojs_installed - fix_ojs_writable_permissions - exit 0 - fi - install_ojs - fi - - set_ojs_installed - fix_ojs_writable_permissions + setup_ojs_database } -main + +if [ "${OJS_SETUP_LIBRARY_ONLY:-false}" != "true" ]; then + main +fi diff --git a/images/ojs/tests/ContinuousConfdPreservesInstalledState/docker-compose.yml b/images/ojs/tests/ContinuousConfdPreservesInstalledState/docker-compose.yml new file mode 100644 index 00000000..707a4485 --- /dev/null +++ b/images/ojs/tests/ContinuousConfdPreservesInstalledState/docker-compose.yml @@ -0,0 +1,50 @@ +--- +name: ojs-continuousconfdpreservesinstalledstate +services: + ojs: + image: ${OJS:-libops/ojs:local-php83} + environment: + CONFD_ENABLE_SERVICE: "true" + CONFD_POLLING_INTERVAL: 1 + DB_HOST: database + DB_PASSWORD: 'p"a\ss''word&+=' + OJS_SALT: test-salt + OJS_API_KEY_SECRET: test-api-key-secret + OJS_SECRET_KEY: base64:MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTIzNDU2Nzg5MDE= + OJS_ADMIN_PASSWORD: 'Adm&in+=p@ss' + command: + - /bin/bash + - -c + - | + set -euo pipefail + for attempt in $$(seq 1 300); do + if [ -f /installed ] && grep -qx 'installed = On' /var/www/ojs/config.inc.php; then + break + fi + sleep 1 + done + test -f /installed + grep -qx 'installed = On' /var/www/ojs/config.inc.php + test "$$(cat /var/run/s6/container_environment/OJS_INSTALLED)" = On + test "$$(stat -c '%a' /var/run/s6/container_environment/OJS_INSTALLED)" = 600 + test "$$(stat -c '%a' /var/www/ojs/config.inc.php)" = 640 + curl -fsS http://localhost/status | grep -q pool + curl -fsSI http://localhost/ >/dev/null + for attempt in $$(seq 1 10); do + sleep 1 + grep -qx 'installed = On' /var/www/ojs/config.inc.php + done + depends_on: + mariadb: + condition: service_healthy + mariadb: + image: ${MARIADB11:-libops/mariadb:local-11} + environment: + DB_ROOT_PASSWORD: root + DB_NAME: ojs + DB_USER: ojs + DB_PASSWORD: 'p"a\ss''word&+=' + networks: + default: + aliases: + - database diff --git a/images/ojs/tests/ServiceHealthcheck/docker-compose.yml b/images/ojs/tests/ServiceHealthcheck/docker-compose.yml index 0a1031c5..6888ea03 100644 --- a/images/ojs/tests/ServiceHealthcheck/docker-compose.yml +++ b/images/ojs/tests/ServiceHealthcheck/docker-compose.yml @@ -3,3 +3,24 @@ name: ojs-servicehealthcheck services: ojs: image: ${OJS:-libops/ojs:local-php83} + environment: + DB_HOST: database + DB_PASSWORD: 'p"a\ss''word&+=' + OJS_SALT: test-salt + OJS_API_KEY_SECRET: test-api-key-secret + OJS_SECRET_KEY: base64:MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTIzNDU2Nzg5MDE= + OJS_ADMIN_PASSWORD: 'Adm&in+=p@ss' + depends_on: + mariadb: + condition: service_healthy + mariadb: + image: ${MARIADB11:-libops/mariadb:local-11} + environment: + DB_ROOT_PASSWORD: root + DB_NAME: ojs + DB_USER: ojs + DB_PASSWORD: 'p"a\ss''word&+=' + networks: + default: + aliases: + - database diff --git a/images/omeka-classic/Dockerfile b/images/omeka-classic/Dockerfile index c97b5f25..d47c73a1 100644 --- a/images/omeka-classic/Dockerfile +++ b/images/omeka-classic/Dockerfile @@ -2,6 +2,8 @@ ARG PHP_BASE=nginx-php83 FROM ${PHP_BASE} +LABEL org.opencontainers.image.licenses="GPL-3.0-or-later" + ARG TARGETARCH ARG PHP_FPM_SOCKET=/run/php-fpm/php-fpm.sock ARG PHP_PACKAGE=php83 @@ -10,7 +12,15 @@ ARG \ # renovate: datasource=repology depName=alpine_3_24/imagemagick IMAGEMAGICK_VERSION=7.1.2.24-r0 \ # renovate: datasource=repology depName=alpine_3_24/msmtp - MSMTP_VERSION=1.8.32-r1 + MSMTP_VERSION=1.8.32-r1 \ + # renovate: datasource=repology depName=alpine_3_24/unzip + UNZIP_VERSION=6.0-r16 \ + # renovate: datasource=github-releases depName=omeka-classic packageName=omeka/Omeka + SOFTWARE_VERSION=3.2.1 + +ARG FILE=omeka-${SOFTWARE_VERSION}.zip +ARG URL=https://github.com/omeka/Omeka/releases/download/v${SOFTWARE_VERSION}/${FILE} +ARG SHA256="2cb4d65511321cc5c009cb61516d9ed97378a800fc4a26eb46450c3c4ca230c2" EXPOSE 80 WORKDIR /var/www/omeka-classic @@ -19,9 +29,20 @@ RUN --mount=type=cache,id=omeka-classic-apk-${TARGETARCH},sharing=locked,target= apk add \ imagemagick=="${IMAGEMAGICK_VERSION}" \ msmtp=="${MSMTP_VERSION}" \ + unzip=="${UNZIP_VERSION}" \ "${PHP_PACKAGE}-exif" \ && \ - mkdir -p /var/www/omeka-classic/files && \ + cleanup.sh + +RUN --mount=type=cache,id=omeka-classic-downloads-${TARGETARCH},sharing=locked,target=/opt/downloads \ + download.sh \ + --url "${URL}" \ + --sha256 "${SHA256}" \ + --strip \ + --dest /var/www/omeka-classic \ + && \ + mkdir -p /usr/share/licenses/omeka-classic && \ + cp /var/www/omeka-classic/license.txt /usr/share/licenses/omeka-classic/license.txt && \ chown -R nginx:nginx /var/www/omeka-classic && \ cleanup.sh @@ -30,10 +51,10 @@ ENV \ DB_PORT=3306 \ DB_NAME=omeka_classic \ DB_USER=omeka_classic \ - DB_PASSWORD=changeme \ + DB_PASSWORD= \ OMEKA_CLASSIC_ADMIN_USERNAME=admin \ OMEKA_CLASSIC_ADMIN_EMAIL=admin@example.com \ - OMEKA_CLASSIC_ADMIN_PASSWORD=changeme \ + OMEKA_CLASSIC_ADMIN_PASSWORD= \ OMEKA_CLASSIC_SITE_TITLE="Omeka Classic" \ OMEKA_CLASSIC_TABLE_PREFIX=omeka_ \ INGRESS_HOSTNAMES=localhost \ @@ -65,8 +86,9 @@ ENV \ PHP_MAX_INPUT_TIME=300 \ PHP_DEFAULT_SOCKET_TIMEOUT=300 \ PHP_REQUEST_TERMINATE_TIMEOUT=300 \ - PHP_MEMORY_LIMIT=256M + PHP_MEMORY_LIMIT=256M \ + S6_CMD_WAIT_FOR_SERVICES_MAXTIME=600000 COPY --link rootfs / -HEALTHCHECK CMD curl -s http://localhost/status | grep -q pool +HEALTHCHECK CMD test -f /installed && test -f /var/www/omeka-classic/index.php && curl -fsS http://localhost/status | grep -q pool diff --git a/images/omeka-classic/rootfs/etc/confd/templates/db.ini.tmpl b/images/omeka-classic/rootfs/etc/confd/templates/db.ini.tmpl index 6a02cd0f..0107a254 100644 --- a/images/omeka-classic/rootfs/etc/confd/templates/db.ini.tmpl +++ b/images/omeka-classic/rootfs/etc/confd/templates/db.ini.tmpl @@ -1,9 +1,9 @@ +{{- define "doubleQuoteEscape" -}}{{- $escaped := replace . "\\" "\\\\" -1 -}}{{- replace $escaped "\"" "\\\"" -1 -}}{{- end -}} [database] -host = "{{ getenv "DB_HOST" }}" -username = "{{ getenv "DB_USER" }}" -password = "{{ getenv "DB_PASSWORD" }}" -dbname = "{{ getenv "DB_NAME" }}" -prefix = "{{ getenv "OMEKA_CLASSIC_TABLE_PREFIX" }}" +host = "{{ template "doubleQuoteEscape" (getenv "DB_HOST") }}" +username = "{{ template "doubleQuoteEscape" (getenv "DB_USER") }}" +password = "{{ template "doubleQuoteEscape" (getenv "DB_PASSWORD") }}" +dbname = "{{ template "doubleQuoteEscape" (getenv "DB_NAME") }}" +prefix = "{{ template "doubleQuoteEscape" (getenv "OMEKA_CLASSIC_TABLE_PREFIX") }}" charset = "utf8mb4" -port = "{{ getenv "DB_PORT" }}" - +port = "{{ template "doubleQuoteEscape" (getenv "DB_PORT") }}" diff --git a/images/omeka-classic/rootfs/etc/s6-overlay/s6-rc.d/omeka-classic-setup/dependencies.d/ready b/images/omeka-classic/rootfs/etc/s6-overlay/s6-rc.d/omeka-classic-setup/dependencies.d/ready new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/images/omeka-classic/rootfs/etc/s6-overlay/s6-rc.d/omeka-classic-setup/dependencies.d/ready @@ -0,0 +1 @@ + diff --git a/images/omeka-classic/rootfs/etc/s6-overlay/scripts/omeka-classic-setup.sh b/images/omeka-classic/rootfs/etc/s6-overlay/scripts/omeka-classic-setup.sh index 8a1aa800..5029645f 100755 --- a/images/omeka-classic/rootfs/etc/s6-overlay/scripts/omeka-classic-setup.sh +++ b/images/omeka-classic/rootfs/etc/s6-overlay/scripts/omeka-classic-setup.sh @@ -3,19 +3,26 @@ set -eou pipefail +# shellcheck disable=SC1091 +source /usr/local/share/libops/database.sh +# shellcheck disable=SC1091 +source /usr/local/share/libops/environment.sh + function mysql_create_database { - cat <<-SQL | create-database.sh -CREATE DATABASE IF NOT EXISTS ${DB_NAME} CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; -CREATE USER IF NOT EXISTS '${DB_USER}'@'%' IDENTIFIED BY '${DB_PASSWORD}'; -GRANT ALL PRIVILEGES ON ${DB_NAME}.* to '${DB_USER}'@'%'; -FLUSH PRIVILEGES; -SET PASSWORD FOR ${DB_USER}@'%' = PASSWORD('${DB_PASSWORD}'); -SQL + DB_CHARACTER_SET=utf8mb4 \ + DB_COLLATION=utf8mb4_unicode_ci \ + render-database-bootstrap-sql.sh | create-database.sh } function wait_for_database { local attempts=0 - until mysql -h"${DB_HOST}" -u"${DB_USER}" -p"${DB_PASSWORD}" "${DB_NAME}" -e 'SELECT 1' >/dev/null 2>&1; do + until database_mariadb_with_password "${DB_PASSWORD}" \ + --host="${DB_HOST}" \ + --port="${DB_PORT}" \ + --user="${DB_USER}" \ + --database="${DB_NAME}" \ + --execute='SELECT 1' \ + >/dev/null 2>&1; do attempts=$((attempts + 1)) if [ "$attempts" -ge 60 ]; then echo "Database was not ready in time" @@ -26,8 +33,14 @@ function wait_for_database { } function check_installed { - mysql -h"${DB_HOST}" -u"${DB_USER}" -p"${DB_PASSWORD}" "${DB_NAME}" \ - -e "SELECT 1 FROM ${OMEKA_CLASSIC_TABLE_PREFIX}options LIMIT 1" >/dev/null 2>&1 + database_validate_identifier OMEKA_CLASSIC_TABLE_PREFIX "${OMEKA_CLASSIC_TABLE_PREFIX}" + database_mariadb_with_password "${DB_PASSWORD}" \ + --host="${DB_HOST}" \ + --port="${DB_PORT}" \ + --user="${DB_USER}" \ + --database="${DB_NAME}" \ + --execute="SELECT 1 FROM ${OMEKA_CLASSIC_TABLE_PREFIX}options LIMIT 1" \ + >/dev/null 2>&1 } function install_omeka { @@ -36,31 +49,59 @@ function install_omeka { return 0 fi + require_environment_variables OMEKA_CLASSIC_ADMIN_PASSWORD + timeout 300 wait-for-open-port.sh localhost 80 + + local form_dir install_log curl_status=0 + umask 077 + form_dir=$(mktemp -d) + install_log=$(mktemp -t omeka-classic-install.XXXXXXXXXX) + trap 'rm -rf -- "${form_dir}" "${install_log}"' EXIT + printf '%s' "${OMEKA_CLASSIC_ADMIN_USERNAME}" >"${form_dir}/admin-username" + printf '%s' "${OMEKA_CLASSIC_ADMIN_PASSWORD}" >"${form_dir}/admin-password" + printf '%s' "${OMEKA_CLASSIC_ADMIN_EMAIL}" >"${form_dir}/admin-email" + printf '%s' "${OMEKA_CLASSIC_SITE_TITLE}" >"${form_dir}/site-title" + printf '%s' , >"${form_dir}/tag-delimiter" + printf '%s' 800 >"${form_dir}/fullsize-constraint" + printf '%s' 200 >"${form_dir}/thumbnail-constraint" + printf '%s' 200 >"${form_dir}/square-thumbnail-constraint" + printf '%s' 10 >"${form_dir}/per-page-admin" + printf '%s' 10 >"${form_dir}/per-page-public" + printf '%s' /usr/bin >"${form_dir}/path-to-convert" + printf '%s' Install >"${form_dir}/install-submit" + curl -fsS \ - --data-urlencode "username=${OMEKA_CLASSIC_ADMIN_USERNAME}" \ - --data-urlencode "password=${OMEKA_CLASSIC_ADMIN_PASSWORD}" \ - --data-urlencode "password_confirm=${OMEKA_CLASSIC_ADMIN_PASSWORD}" \ - --data-urlencode "super_email=${OMEKA_CLASSIC_ADMIN_EMAIL}" \ - --data-urlencode "administrator_email=${OMEKA_CLASSIC_ADMIN_EMAIL}" \ - --data-urlencode "site_title=${OMEKA_CLASSIC_SITE_TITLE}" \ - --data-urlencode "tag_delimiter=," \ - --data-urlencode "fullsize_constraint=800" \ - --data-urlencode "thumbnail_constraint=200" \ - --data-urlencode "square_thumbnail_constraint=200" \ - --data-urlencode "per_page_admin=10" \ - --data-urlencode "per_page_public=10" \ - --data-urlencode "path_to_convert=/usr/bin" \ - --data-urlencode "install_submit=Install" \ - http://localhost/install/install.php >/tmp/omeka-classic-install.log 2>&1 || { - cat /tmp/omeka-classic-install.log - exit 1 - } + --data-urlencode "username@${form_dir}/admin-username" \ + --data-urlencode "password@${form_dir}/admin-password" \ + --data-urlencode "password_confirm@${form_dir}/admin-password" \ + --data-urlencode "super_email@${form_dir}/admin-email" \ + --data-urlencode "administrator_email@${form_dir}/admin-email" \ + --data-urlencode "site_title@${form_dir}/site-title" \ + --data-urlencode "tag_delimiter@${form_dir}/tag-delimiter" \ + --data-urlencode "fullsize_constraint@${form_dir}/fullsize-constraint" \ + --data-urlencode "thumbnail_constraint@${form_dir}/thumbnail-constraint" \ + --data-urlencode "square_thumbnail_constraint@${form_dir}/square-thumbnail-constraint" \ + --data-urlencode "per_page_admin@${form_dir}/per-page-admin" \ + --data-urlencode "per_page_public@${form_dir}/per-page-public" \ + --data-urlencode "path_to_convert@${form_dir}/path-to-convert" \ + --data-urlencode "install_submit@${form_dir}/install-submit" \ + http://localhost/install/install.php >"${install_log}" 2>&1 || curl_status=$? + rm -rf -- "${form_dir}" + + if [ "${curl_status}" -ne 0 ]; then + cat "${install_log}" + rm -f -- "${install_log}" + trap - EXIT + return "${curl_status}" + fi if ! check_installed; then echo "Omeka Classic installer response did not create the expected database tables." - cat /tmp/omeka-classic-install.log + cat "${install_log}" exit 1 fi + rm -f -- "${install_log}" + trap - EXIT } function main { @@ -69,8 +110,10 @@ function main { return 0 fi + require_environment_variables DB_HOST DB_PORT DB_NAME DB_USER DB_PASSWORD + if [ "${DB_HOST}" = "mariadb" ]; then - mysql_create_database + database_bootstrap_if_enabled mysql_create_database fi wait_for_database install_omeka diff --git a/images/omeka-classic/tests/ServiceHealthcheck/docker-compose.yml b/images/omeka-classic/tests/ServiceHealthcheck/docker-compose.yml index 7f9b70ed..46b9a42d 100644 --- a/images/omeka-classic/tests/ServiceHealthcheck/docker-compose.yml +++ b/images/omeka-classic/tests/ServiceHealthcheck/docker-compose.yml @@ -3,3 +3,18 @@ name: omeka-classic-servicehealthcheck services: omeka-classic: image: ${OMEKA_CLASSIC:-libops/omeka-classic:local-php83} + environment: + DB_PASSWORD: 'p"a\ss''word&+=' + OMEKA_CLASSIC_ADMIN_EMAIL: admin+test@example.com + OMEKA_CLASSIC_ADMIN_PASSWORD: 'Adm&in+=p@ss' + OMEKA_CLASSIC_SITE_TITLE: "Omeka Classic & Test = +" + depends_on: + mariadb: + condition: service_healthy + mariadb: + image: ${MARIADB11:-libops/mariadb:local-11} + environment: + DB_ROOT_PASSWORD: root + DB_NAME: omeka_classic + DB_USER: omeka_classic + DB_PASSWORD: 'p"a\ss''word&+=' diff --git a/images/omeka-s/Dockerfile b/images/omeka-s/Dockerfile index 1b2d1bc7..5c390dc9 100644 --- a/images/omeka-s/Dockerfile +++ b/images/omeka-s/Dockerfile @@ -2,6 +2,8 @@ ARG PHP_BASE=nginx-php83 FROM ${PHP_BASE} +LABEL org.opencontainers.image.licenses="GPL-3.0-only" + ARG TARGETARCH ARG PHP_FPM_SOCKET=/run/php-fpm/php-fpm.sock @@ -9,7 +11,15 @@ ARG \ # renovate: datasource=repology depName=alpine_3_24/imagemagick IMAGEMAGICK_VERSION=7.1.2.24-r0 \ # renovate: datasource=repology depName=alpine_3_24/msmtp - MSMTP_VERSION=1.8.32-r1 + MSMTP_VERSION=1.8.32-r1 \ + # renovate: datasource=repology depName=alpine_3_24/unzip + UNZIP_VERSION=6.0-r16 \ + # renovate: datasource=github-releases depName=omeka-s packageName=omeka/omeka-s + SOFTWARE_VERSION=4.2.1 + +ARG FILE=omeka-s-${SOFTWARE_VERSION}.zip +ARG URL=https://github.com/omeka/omeka-s/releases/download/v${SOFTWARE_VERSION}/${FILE} +ARG SHA256="cc27d1c7aca0209523d19aa285f4a08e29e34950dcc446951a7c1311de348e82" EXPOSE 80 WORKDIR /var/www/omeka-s @@ -18,8 +28,19 @@ RUN --mount=type=cache,id=omeka-s-apk-${TARGETARCH},sharing=locked,target=/var/c apk add \ imagemagick=="${IMAGEMAGICK_VERSION}" \ msmtp=="${MSMTP_VERSION}" \ + unzip=="${UNZIP_VERSION}" \ + && \ + cleanup.sh + +RUN --mount=type=cache,id=omeka-s-downloads-${TARGETARCH},sharing=locked,target=/opt/downloads \ + download.sh \ + --url "${URL}" \ + --sha256 "${SHA256}" \ + --strip \ + --dest /var/www/omeka-s \ && \ - mkdir -p /var/www/omeka-s/config /var/www/omeka-s/files && \ + mkdir -p /usr/share/licenses/omeka-s && \ + cp /var/www/omeka-s/LICENSE /usr/share/licenses/omeka-s/LICENSE && \ chown -R nginx:nginx /var/www/omeka-s && \ cleanup.sh @@ -28,10 +49,10 @@ ENV \ DB_PORT=3306 \ DB_NAME=omeka_s \ DB_USER=omeka_s \ - DB_PASSWORD=changeme \ + DB_PASSWORD= \ OMEKA_S_ADMIN_EMAIL=admin@example.com \ OMEKA_S_ADMIN_NAME=Administrator \ - OMEKA_S_ADMIN_PASSWORD=changeme \ + OMEKA_S_ADMIN_PASSWORD= \ OMEKA_S_SITE_TITLE="Omeka S" \ OMEKA_S_TIME_ZONE=UTC \ OMEKA_S_LOCALE=en_US \ @@ -64,8 +85,9 @@ ENV \ PHP_MAX_INPUT_TIME=300 \ PHP_DEFAULT_SOCKET_TIMEOUT=300 \ PHP_REQUEST_TERMINATE_TIMEOUT=300 \ - PHP_MEMORY_LIMIT=256M + PHP_MEMORY_LIMIT=256M \ + S6_CMD_WAIT_FOR_SERVICES_MAXTIME=600000 COPY --link rootfs / -HEALTHCHECK CMD curl -s http://localhost/status | grep -q pool +HEALTHCHECK CMD test -f /installed && test -f /var/www/omeka-s/index.php && curl -fsS http://localhost/status | grep -q pool diff --git a/images/omeka-s/rootfs/etc/confd/templates/database.ini.tmpl b/images/omeka-s/rootfs/etc/confd/templates/database.ini.tmpl index ec6661db..0ffffecd 100644 --- a/images/omeka-s/rootfs/etc/confd/templates/database.ini.tmpl +++ b/images/omeka-s/rootfs/etc/confd/templates/database.ini.tmpl @@ -1,6 +1,6 @@ -user = "{{ getenv "DB_USER" }}" -password = "{{ getenv "DB_PASSWORD" }}" -dbname = "{{ getenv "DB_NAME" }}" -host = "{{ getenv "DB_HOST" }}" -port = "{{ getenv "DB_PORT" }}" - +{{- define "doubleQuoteEscape" -}}{{- $escaped := replace . "\\" "\\\\" -1 -}}{{- replace $escaped "\"" "\\\"" -1 -}}{{- end -}} +user = "{{ template "doubleQuoteEscape" (getenv "DB_USER") }}" +password = "{{ template "doubleQuoteEscape" (getenv "DB_PASSWORD") }}" +dbname = "{{ template "doubleQuoteEscape" (getenv "DB_NAME") }}" +host = "{{ template "doubleQuoteEscape" (getenv "DB_HOST") }}" +port = "{{ template "doubleQuoteEscape" (getenv "DB_PORT") }}" diff --git a/images/omeka-s/rootfs/etc/s6-overlay/s6-rc.d/omeka-s-setup/dependencies.d/ready b/images/omeka-s/rootfs/etc/s6-overlay/s6-rc.d/omeka-s-setup/dependencies.d/ready new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/images/omeka-s/rootfs/etc/s6-overlay/s6-rc.d/omeka-s-setup/dependencies.d/ready @@ -0,0 +1 @@ + diff --git a/images/omeka-s/rootfs/etc/s6-overlay/scripts/omeka-s-setup.sh b/images/omeka-s/rootfs/etc/s6-overlay/scripts/omeka-s-setup.sh index 23793a9e..7806d212 100755 --- a/images/omeka-s/rootfs/etc/s6-overlay/scripts/omeka-s-setup.sh +++ b/images/omeka-s/rootfs/etc/s6-overlay/scripts/omeka-s-setup.sh @@ -3,19 +3,26 @@ set -eou pipefail +# shellcheck disable=SC1091 +source /usr/local/share/libops/database.sh +# shellcheck disable=SC1091 +source /usr/local/share/libops/environment.sh + function mysql_create_database { - cat <<-SQL | create-database.sh -CREATE DATABASE IF NOT EXISTS ${DB_NAME} CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; -CREATE USER IF NOT EXISTS '${DB_USER}'@'%' IDENTIFIED BY '${DB_PASSWORD}'; -GRANT ALL PRIVILEGES ON ${DB_NAME}.* to '${DB_USER}'@'%'; -FLUSH PRIVILEGES; -SET PASSWORD FOR ${DB_USER}@'%' = PASSWORD('${DB_PASSWORD}'); -SQL + DB_CHARACTER_SET=utf8mb4 \ + DB_COLLATION=utf8mb4_unicode_ci \ + render-database-bootstrap-sql.sh | create-database.sh } function wait_for_database { local attempts=0 - until mysql -h"${DB_HOST}" -u"${DB_USER}" -p"${DB_PASSWORD}" "${DB_NAME}" -e 'SELECT 1' >/dev/null 2>&1; do + until database_mariadb_with_password "${DB_PASSWORD}" \ + --host="${DB_HOST}" \ + --port="${DB_PORT}" \ + --user="${DB_USER}" \ + --database="${DB_NAME}" \ + --execute='SELECT 1' \ + >/dev/null 2>&1; do attempts=$((attempts + 1)) if [ "$attempts" -ge 60 ]; then echo "Database was not ready in time" @@ -26,8 +33,13 @@ function wait_for_database { } function check_installed { - mysql -h"${DB_HOST}" -u"${DB_USER}" -p"${DB_PASSWORD}" "${DB_NAME}" \ - -e "SELECT 1 FROM \`user\` LIMIT 1" >/dev/null 2>&1 + database_mariadb_with_password "${DB_PASSWORD}" \ + --host="${DB_HOST}" \ + --port="${DB_PORT}" \ + --user="${DB_USER}" \ + --database="${DB_NAME}" \ + --execute="SELECT 1 FROM \`user\` LIMIT 1" \ + >/dev/null 2>&1 } function install_omeka { @@ -36,20 +48,49 @@ function install_omeka { return 0 fi + require_environment_variables OMEKA_S_ADMIN_PASSWORD + timeout 300 wait-for-open-port.sh localhost 80 - curl -fsS -d "user[email]=${OMEKA_S_ADMIN_EMAIL}" \ - -d "user[email-confirm]=${OMEKA_S_ADMIN_EMAIL}" \ - -d "user[name]=${OMEKA_S_ADMIN_NAME}" \ - -d "user[password-confirm][password]=${OMEKA_S_ADMIN_PASSWORD}" \ - -d "user[password-confirm][password-confirm]=${OMEKA_S_ADMIN_PASSWORD}" \ - -d "settings[installation_title]=${OMEKA_S_SITE_TITLE}" \ - -d "settings[time_zone]=${OMEKA_S_TIME_ZONE}" \ - -d "settings[locale]=${OMEKA_S_LOCALE}" \ - -d "submit=Submit" \ - http://localhost/install >/tmp/omeka-s-install.log 2>&1 || { - cat /tmp/omeka-s-install.log - exit 1 - } + + local form_dir install_log curl_status=0 + umask 077 + form_dir=$(mktemp -d) + install_log=$(mktemp -t omeka-s-install.XXXXXXXXXX) + trap 'rm -rf -- "${form_dir}" "${install_log}"' EXIT + printf '%s' "${OMEKA_S_ADMIN_EMAIL}" >"${form_dir}/admin-email" + printf '%s' "${OMEKA_S_ADMIN_NAME}" >"${form_dir}/admin-name" + printf '%s' "${OMEKA_S_ADMIN_PASSWORD}" >"${form_dir}/admin-password" + printf '%s' "${OMEKA_S_SITE_TITLE}" >"${form_dir}/site-title" + printf '%s' "${OMEKA_S_TIME_ZONE}" >"${form_dir}/time-zone" + printf '%s' "${OMEKA_S_LOCALE}" >"${form_dir}/locale" + printf '%s' Submit >"${form_dir}/submit" + + curl -fsS \ + --data-urlencode "user[email]@${form_dir}/admin-email" \ + --data-urlencode "user[email-confirm]@${form_dir}/admin-email" \ + --data-urlencode "user[name]@${form_dir}/admin-name" \ + --data-urlencode "user[password-confirm][password]@${form_dir}/admin-password" \ + --data-urlencode "user[password-confirm][password-confirm]@${form_dir}/admin-password" \ + --data-urlencode "settings[installation_title]@${form_dir}/site-title" \ + --data-urlencode "settings[time_zone]@${form_dir}/time-zone" \ + --data-urlencode "settings[locale]@${form_dir}/locale" \ + --data-urlencode "submit@${form_dir}/submit" \ + http://localhost/install >"${install_log}" 2>&1 || curl_status=$? + rm -rf -- "${form_dir}" + + if [ "${curl_status}" -ne 0 ]; then + cat "${install_log}" + rm -f -- "${install_log}" + trap - EXIT + return "${curl_status}" + fi + if ! check_installed; then + echo "Omeka S installer response did not create the expected database tables." + cat "${install_log}" + exit 1 + fi + rm -f -- "${install_log}" + trap - EXIT } function main { @@ -58,8 +99,10 @@ function main { return 0 fi + require_environment_variables DB_HOST DB_PORT DB_NAME DB_USER DB_PASSWORD + if [ "${DB_HOST}" = "mariadb" ]; then - mysql_create_database + database_bootstrap_if_enabled mysql_create_database fi wait_for_database install_omeka diff --git a/images/omeka-s/tests/ServiceHealthcheck/docker-compose.yml b/images/omeka-s/tests/ServiceHealthcheck/docker-compose.yml index 44871445..6bd1ac7b 100644 --- a/images/omeka-s/tests/ServiceHealthcheck/docker-compose.yml +++ b/images/omeka-s/tests/ServiceHealthcheck/docker-compose.yml @@ -3,3 +3,19 @@ name: omeka-s-servicehealthcheck services: omeka-s: image: ${OMEKA_S:-libops/omeka-s:local-php83} + environment: + DB_PASSWORD: 'p"a\ss''word&+=' + OMEKA_S_ADMIN_EMAIL: admin+test@example.com + OMEKA_S_ADMIN_NAME: "Administrator & Curator" + OMEKA_S_ADMIN_PASSWORD: 'Adm&in+=p@ss' + OMEKA_S_SITE_TITLE: "Omeka S & Test = +" + depends_on: + mariadb: + condition: service_healthy + mariadb: + image: ${MARIADB11:-libops/mariadb:local-11} + environment: + DB_ROOT_PASSWORD: root + DB_NAME: omeka_s + DB_USER: omeka_s + DB_PASSWORD: 'p"a\ss''word&+=' diff --git a/images/php83/Dockerfile b/images/php83/Dockerfile index de2d88ec..e68acc96 100644 --- a/images/php83/Dockerfile +++ b/images/php83/Dockerfile @@ -24,7 +24,7 @@ ARG \ # renovate: datasource=repology depName=alpine_3_24/icu-data-full ICU_VERSION=78.1-r0 \ # renovate: datasource=repology depName=alpine_3_24/php83 - SOFTWARE_VERSION=8.3.31-r1 + SOFTWARE_VERSION=8.3.32-r0 # Platform specific does require arch specific identifier. RUN --mount=type=cache,id=php83-apk-${TARGETARCH},sharing=locked,target=/var/cache/apk \ diff --git a/images/php83/rootfs/etc/s6-overlay/s6-rc.d/fpm/run b/images/php83/rootfs/etc/s6-overlay/s6-rc.d/fpm/run index 01e7ef9c..83060448 100755 --- a/images/php83/rootfs/etc/s6-overlay/s6-rc.d/fpm/run +++ b/images/php83/rootfs/etc/s6-overlay/s6-rc.d/fpm/run @@ -1,5 +1,8 @@ #!/usr/bin/env bash set -e +unset DB_ROOT_PASSWORD +unset DRUPAL_DEFAULT_ACCOUNT_PASSWORD OJS_ADMIN_PASSWORD \ + OMEKA_CLASSIC_ADMIN_PASSWORD OMEKA_S_ADMIN_PASSWORD WORDPRESS_ADMIN_PASSWORD exec s6-notifyoncheck -d -t 30000 -n 1 /usr/sbin/php-fpm83 \ --pid /run/php-fpm/php-fpm.pid \ --prefix /run/php-fpm \ diff --git a/images/php84/Dockerfile b/images/php84/Dockerfile index 97e62805..bffc0804 100644 --- a/images/php84/Dockerfile +++ b/images/php84/Dockerfile @@ -24,7 +24,7 @@ ARG \ # renovate: datasource=repology depName=alpine_3_24/icu-data-full ICU_VERSION=78.1-r0 \ # renovate: datasource=repology depName=alpine_3_24/php84 - SOFTWARE_VERSION=8.4.22-r0 + SOFTWARE_VERSION=8.4.23-r0 # Platform specific does require arch specific identifier. RUN --mount=type=cache,id=php84-apk-${TARGETARCH},sharing=locked,target=/var/cache/apk \ diff --git a/images/php84/rootfs/etc/s6-overlay/s6-rc.d/fpm/run b/images/php84/rootfs/etc/s6-overlay/s6-rc.d/fpm/run index 9ef7b851..082a8835 100755 --- a/images/php84/rootfs/etc/s6-overlay/s6-rc.d/fpm/run +++ b/images/php84/rootfs/etc/s6-overlay/s6-rc.d/fpm/run @@ -1,5 +1,8 @@ #!/usr/bin/env bash set -e +unset DB_ROOT_PASSWORD +unset DRUPAL_DEFAULT_ACCOUNT_PASSWORD OJS_ADMIN_PASSWORD \ + OMEKA_CLASSIC_ADMIN_PASSWORD OMEKA_S_ADMIN_PASSWORD WORDPRESS_ADMIN_PASSWORD exec s6-notifyoncheck -d -t 30000 -n 1 /usr/sbin/php-fpm84 \ --pid /run/php-fpm/php-fpm.pid \ --prefix /run/php-fpm \ diff --git a/images/scyllaridae/rootfs/etc/s6-overlay/s6-rc.d/scyllaridae/run b/images/scyllaridae/rootfs/etc/s6-overlay/s6-rc.d/scyllaridae/run index ad4f6548..6bbb0a4e 100755 --- a/images/scyllaridae/rootfs/etc/s6-overlay/s6-rc.d/scyllaridae/run +++ b/images/scyllaridae/rootfs/etc/s6-overlay/s6-rc.d/scyllaridae/run @@ -1,4 +1,5 @@ #!/command/execlineb -P with-contenv +unexport DB_ROOT_PASSWORD exec s6-setuidgid scyllaridae /app/scyllaridae diff --git a/images/solr10/Dockerfile b/images/solr10/Dockerfile index c72a136f..f0ba2960 100644 --- a/images/solr10/Dockerfile +++ b/images/solr10/Dockerfile @@ -54,4 +54,4 @@ ENV \ COPY --link rootfs / -HEALTHCHECK CMD curl -s http://localhost:8983/solr/admin/info/system?wt=json | jq -r .responseHeader.status | grep -q 0 +HEALTHCHECK CMD curl -fsS http://localhost:8983/solr/admin/info/system?wt=json | jq -r .responseHeader.status | grep -q 0 diff --git a/images/solr10/README.md b/images/solr10/README.md index 0d373ac1..6a320c45 100644 --- a/images/solr10/README.md +++ b/images/solr10/README.md @@ -64,14 +64,7 @@ Change `SOFTWARE_VERSION` and then generate the `SOLR_FILE_SHA256` with the foll commands: ```bash -SOFTWARE_VERSION=$(cat solr10/Dockerfile | grep -o 'SOFTWARE_VERSION=.*' | cut -f2 -d=) -SOLR_FILE=$(cat solr10/Dockerfile | grep -o 'SOLR_FILE=.*' | cut -f2 -d=) -SOLR_URL=$(cat solr10/Dockerfile | grep -o 'SOLR_URL=.*' | cut -f2 -d=) -SOLR_FILE=$(eval "echo $SOLR_FILE") -SOLR_URL=$(eval "echo $SOLR_URL") -wget --quiet "${SOLR_URL}" -shasum -a 256 "${SOLR_FILE}" | cut -f1 -d' ' -rm "${SOLR_FILE}" +./ci/update-sha.sh apache-solr10 OLD_VERSION NEW_VERSION '' ``` [Solr Documentation]: https://lucene.apache.org/solr/guide/7_1/ diff --git a/images/solr10/rootfs/etc/s6-overlay/s6-rc.d/solr/run b/images/solr10/rootfs/etc/s6-overlay/s6-rc.d/solr/run index e7f87058..617a2d95 100755 --- a/images/solr10/rootfs/etc/s6-overlay/s6-rc.d/solr/run +++ b/images/solr10/rootfs/etc/s6-overlay/s6-rc.d/solr/run @@ -15,4 +15,5 @@ fi # actually can write to the folder. chown -R solr:solr /opt/solr/server/solr +unset DB_ROOT_PASSWORD exec s6-setuidgid solr /opt/solr/bin/solr start -f "${ARGS[@]}" diff --git a/images/solr10/tests/ServiceHealthcheck/test.sh b/images/solr10/tests/ServiceHealthcheck/test.sh index dd1d6bdf..e84270db 100755 --- a/images/solr10/tests/ServiceHealthcheck/test.sh +++ b/images/solr10/tests/ServiceHealthcheck/test.sh @@ -2,7 +2,7 @@ # shellcheck shell=bash # Invoked indirectly by the SIGTERM trap below. -# shellcheck disable=SC2329 +# shellcheck disable=SC2317,SC2329 on_terminate() { echo "Termination signal received. Exiting..." exit 0 diff --git a/images/solr9/Dockerfile b/images/solr9/Dockerfile index c7cd3082..51f8cba6 100644 --- a/images/solr9/Dockerfile +++ b/images/solr9/Dockerfile @@ -54,4 +54,4 @@ ENV \ COPY --link rootfs / -HEALTHCHECK CMD curl -s http://localhost:8983/solr/admin/info/system?wt=json | jq -r .responseHeader.status | grep -q 0 +HEALTHCHECK CMD curl -fsS http://localhost:8983/solr/admin/info/system?wt=json | jq -r .responseHeader.status | grep -q 0 diff --git a/images/solr9/README.md b/images/solr9/README.md index 5e986cec..a2f52824 100644 --- a/images/solr9/README.md +++ b/images/solr9/README.md @@ -64,14 +64,7 @@ Change `SOFTWARE_VERSION` and then generate the `SOLR_FILE_SHA256` with the foll commands: ```bash -SOFTWARE_VERSION=$(cat solr9/Dockerfile | grep -o 'SOFTWARE_VERSION=.*' | cut -f2 -d=) -SOLR_FILE=$(cat solr9/Dockerfile | grep -o 'SOLR_FILE=.*' | cut -f2 -d=) -SOLR_URL=$(cat solr9/Dockerfile | grep -o 'SOLR_URL=.*' | cut -f2 -d=) -SOLR_FILE=$(eval "echo $SOLR_FILE") -SOLR_URL=$(eval "echo $SOLR_URL") -wget --quiet "${SOLR_URL}" -shasum -a 256 "${SOLR_FILE}" | cut -f1 -d' ' -rm "${SOLR_FILE}" +./ci/update-sha.sh apache-solr9 OLD_VERSION NEW_VERSION '' ``` [Solr Documentation]: https://lucene.apache.org/solr/guide/7_1/ diff --git a/images/solr9/rootfs/etc/s6-overlay/s6-rc.d/solr/run b/images/solr9/rootfs/etc/s6-overlay/s6-rc.d/solr/run index e7f87058..617a2d95 100755 --- a/images/solr9/rootfs/etc/s6-overlay/s6-rc.d/solr/run +++ b/images/solr9/rootfs/etc/s6-overlay/s6-rc.d/solr/run @@ -15,4 +15,5 @@ fi # actually can write to the folder. chown -R solr:solr /opt/solr/server/solr +unset DB_ROOT_PASSWORD exec s6-setuidgid solr /opt/solr/bin/solr start -f "${ARGS[@]}" diff --git a/images/solr9/tests/ServiceHealthcheck/test.sh b/images/solr9/tests/ServiceHealthcheck/test.sh index dd1d6bdf..e84270db 100755 --- a/images/solr9/tests/ServiceHealthcheck/test.sh +++ b/images/solr9/tests/ServiceHealthcheck/test.sh @@ -2,7 +2,7 @@ # shellcheck shell=bash # Invoked indirectly by the SIGTERM trap below. -# shellcheck disable=SC2329 +# shellcheck disable=SC2317,SC2329 on_terminate() { echo "Termination signal received. Exiting..." exit 0 diff --git a/images/tomcat11/Dockerfile b/images/tomcat11/Dockerfile index 9d93893f..4b1dac4e 100644 --- a/images/tomcat11/Dockerfile +++ b/images/tomcat11/Dockerfile @@ -31,12 +31,12 @@ RUN create-service-user.sh \ ENV \ TOMCAT_ADMIN_NAME=admin \ - TOMCAT_ADMIN_PASSWORD=password \ + TOMCAT_ADMIN_PASSWORD= \ TOMCAT_ADMIN_ROLES=manager-gui \ TOMCAT_CONNECTION_TIMEOUT=20000 \ TOMCAT_CATALINA_OPTS= \ TOMCAT_JAVA_OPTS= \ TOMCAT_LOG_LEVEL=INFO \ - TOMCAT_MANAGER_REMOTE_ADDRESS_VALVE=^.*$ + TOMCAT_MANAGER_REMOTE_ADDRESS_VALVE=^(127[.].*|::1)$ COPY --link rootfs / diff --git a/images/tomcat11/README.md b/images/tomcat11/README.md index b7d33af0..cb3d439c 100644 --- a/images/tomcat11/README.md +++ b/images/tomcat11/README.md @@ -1,24 +1,30 @@ -# Tomcat +# Tomcat 11 -Docker image for [Tomcat] version 9.0.118. +Docker image for [Tomcat] version 11.0.22. -Built from [libops/isle-buildkit tomcat](https://github.com/libops/buildkit/tree/main/images/tomcat) +Built from [libops/buildkit tomcat11](https://github.com/libops/buildkit/tree/main/images/tomcat11) Please refer to the [Tomcat Documentation] for more in-depth information. -As a quick example this will bring up an instance of [Tomcat], and allow you -to view the manager webapp on . +As a quick example this will bring up an instance of [Tomcat] and allow you to +view the manager webapp on as `admin` +using a generated password. The port is bound to the host loopback interface; +the manager valve is relaxed only for this local example because Docker bridge +traffic does not arrive from the container's loopback address. ```bash +export TOMCAT_ADMIN_PASSWORD="$(openssl rand -hex 32)" docker run --rm -ti \ - -p 8080:8080 \ - libops/tomcat + --env TOMCAT_ADMIN_PASSWORD \ + --env TOMCAT_MANAGER_REMOTE_ADDRESS_VALVE='.*' \ + -p 127.0.0.1:8080:8080 \ + libops/tomcat:11 ``` ## Dependencies -Requires `libops/java:17` Docker image to build. Please refer to the -[Java 17 Image README](../java17/README.md) for additional information including +Requires `libops/java:25` Docker image to build. Please refer to the +[Java 25 Image README](../java25/README.md) for additional information including additional settings, volumes, ports, etc. ## Ports @@ -35,12 +41,12 @@ additional settings, volumes, ports, etc. | Environment Variable | Default | Description | | :---------------------------------- | :---------- | :------------------------------------------------------------------------------------ | | TOMCAT_ADMIN_NAME | admin | The user name of the manager webapp admin user | -| TOMCAT_ADMIN_PASSWORD | password | The password for the manager webapp admin user | +| TOMCAT_ADMIN_PASSWORD | | The password for the manager webapp admin user; an empty value disables the admin user| | TOMCAT_ADMIN_ROLES | manager-gui | Comma separated list of roles the user has | | TOMCAT_CATALINA_OPTS | | | | TOMCAT_JAVA_OPTS | | | | TOMCAT_LOG_LEVEL | INFO | Log level. Possible Values: SEVERE, WARNING, INFO, CONFIG, FINE, FINER, FINEST or ALL | -| TOMCAT_MANAGER_REMOTE_ADDRESS_VALVE | ^.*$ | Allows / blocks access to manager app to addresses which match this regex | +| TOMCAT_MANAGER_REMOTE_ADDRESS_VALVE | `^(127[.].*\|::1)$` | Allows / blocks access to manager app to addresses which match this regex | Additional users/groups/etc can be defined by adding more environment variables, following the above conventions: @@ -66,7 +72,7 @@ For example to add a new user `someone` you would need to define the following: > become `FCREPO_TOMCAT_CATALINA_OPTS`. This is to allow for different settings > on a per-service basis when sharing the same confd backend. -[AJP]: https://tomcat.apache.org/tomcat-9.0-doc/config/ajp.html -[Tomcat Documentation]: https://tomcat.apache.org/tomcat-9.0-doc/ -[Tomcat Logging]: https://tomcat.apache.org/tomcat-9.0-doc/logging.html +[AJP]: https://tomcat.apache.org/tomcat-11.0-doc/config/ajp.html +[Tomcat Documentation]: https://tomcat.apache.org/tomcat-11.0-doc/ +[Tomcat Logging]: https://tomcat.apache.org/tomcat-11.0-doc/logging.html [Tomcat]: https://tomcat.apache.org/ diff --git a/images/tomcat11/rootfs/etc/confd/conf.d/tomcat-users.toml b/images/tomcat11/rootfs/etc/confd/conf.d/tomcat-users.toml index f5ebde25..fe990515 100644 --- a/images/tomcat11/rootfs/etc/confd/conf.d/tomcat-users.toml +++ b/images/tomcat11/rootfs/etc/confd/conf.d/tomcat-users.toml @@ -3,4 +3,5 @@ src = "tomcat-users.xml.tmpl" dest = "/opt/tomcat/conf/tomcat-users.xml" uid = 100 gid = 1000 +mode = "0640" keys = [ "/" ] diff --git a/images/tomcat11/rootfs/etc/confd/templates/tomcat-users.xml.tmpl b/images/tomcat11/rootfs/etc/confd/templates/tomcat-users.xml.tmpl index b137d2cf..4297e20c 100644 --- a/images/tomcat11/rootfs/etc/confd/templates/tomcat-users.xml.tmpl +++ b/images/tomcat11/rootfs/etc/confd/templates/tomcat-users.xml.tmpl @@ -1,9 +1,10 @@ +{{- define "xmlAttributeEscape" -}}{{- $ampersands := replace . "&" "&" -1 -}}{{- $quotes := replace $ampersands "\"" """ -1 -}}{{- $lessThan := replace $quotes "<" "<" -1 -}}{{- replace $lessThan ">" ">" -1 -}}{{- end -}} - - {{ range $dir := lsdir "/tomcat/user" }} + {{ if getenv "TOMCAT_ADMIN_PASSWORD" }}{{ end }} + {{ range $dir := lsdir "/tomcat/user" }} {{ end }} diff --git a/images/tomcat11/rootfs/etc/s6-overlay/s6-rc.d/tomcat/run b/images/tomcat11/rootfs/etc/s6-overlay/s6-rc.d/tomcat/run index 1ef17ba7..cabfc60b 100755 --- a/images/tomcat11/rootfs/etc/s6-overlay/s6-rc.d/tomcat/run +++ b/images/tomcat11/rootfs/etc/s6-overlay/s6-rc.d/tomcat/run @@ -1,4 +1,5 @@ -#!/usr/bin/env bash +#!/command/with-contenv bash +# shellcheck shell=bash set -e # Many services deployed into tomcat need somewhere to write data, by convention @@ -8,4 +9,5 @@ set -e # actually can write to the folder. chown tomcat:tomcat /data -exec with-contenv s6-setuidgid tomcat /opt/tomcat/bin/catalina.sh run +unset DB_ROOT_PASSWORD +exec s6-setuidgid tomcat /opt/tomcat/bin/catalina.sh run diff --git a/images/tomcat9/Dockerfile b/images/tomcat9/Dockerfile index 893a6b35..b5251adb 100644 --- a/images/tomcat9/Dockerfile +++ b/images/tomcat9/Dockerfile @@ -31,12 +31,12 @@ RUN create-service-user.sh \ ENV \ TOMCAT_ADMIN_NAME=admin \ - TOMCAT_ADMIN_PASSWORD=password \ + TOMCAT_ADMIN_PASSWORD= \ TOMCAT_ADMIN_ROLES=manager-gui \ TOMCAT_CONNECTION_TIMEOUT=20000 \ TOMCAT_CATALINA_OPTS= \ TOMCAT_JAVA_OPTS= \ TOMCAT_LOG_LEVEL=INFO \ - TOMCAT_MANAGER_REMOTE_ADDRESS_VALVE=^.*$ + TOMCAT_MANAGER_REMOTE_ADDRESS_VALVE=^(127[.].*|::1)$ COPY --link rootfs / diff --git a/images/tomcat9/README.md b/images/tomcat9/README.md index b7d33af0..e0d54ca0 100644 --- a/images/tomcat9/README.md +++ b/images/tomcat9/README.md @@ -6,13 +6,19 @@ Built from [libops/isle-buildkit tomcat](https://github.com/libops/buildkit/tree Please refer to the [Tomcat Documentation] for more in-depth information. -As a quick example this will bring up an instance of [Tomcat], and allow you -to view the manager webapp on . +As a quick example this will bring up an instance of [Tomcat] and allow you to +view the manager webapp on as `admin` +using a generated password. The port is bound to the host loopback interface; +the manager valve is relaxed only for this local example because Docker bridge +traffic does not arrive from the container's loopback address. ```bash +export TOMCAT_ADMIN_PASSWORD="$(openssl rand -hex 32)" docker run --rm -ti \ - -p 8080:8080 \ - libops/tomcat + --env TOMCAT_ADMIN_PASSWORD \ + --env TOMCAT_MANAGER_REMOTE_ADDRESS_VALVE='.*' \ + -p 127.0.0.1:8080:8080 \ + libops/tomcat:9 ``` ## Dependencies @@ -35,12 +41,12 @@ additional settings, volumes, ports, etc. | Environment Variable | Default | Description | | :---------------------------------- | :---------- | :------------------------------------------------------------------------------------ | | TOMCAT_ADMIN_NAME | admin | The user name of the manager webapp admin user | -| TOMCAT_ADMIN_PASSWORD | password | The password for the manager webapp admin user | +| TOMCAT_ADMIN_PASSWORD | | The password for the manager webapp admin user; an empty value disables the admin user| | TOMCAT_ADMIN_ROLES | manager-gui | Comma separated list of roles the user has | | TOMCAT_CATALINA_OPTS | | | | TOMCAT_JAVA_OPTS | | | | TOMCAT_LOG_LEVEL | INFO | Log level. Possible Values: SEVERE, WARNING, INFO, CONFIG, FINE, FINER, FINEST or ALL | -| TOMCAT_MANAGER_REMOTE_ADDRESS_VALVE | ^.*$ | Allows / blocks access to manager app to addresses which match this regex | +| TOMCAT_MANAGER_REMOTE_ADDRESS_VALVE | `^(127[.].*\|::1)$` | Allows / blocks access to manager app to addresses which match this regex | Additional users/groups/etc can be defined by adding more environment variables, following the above conventions: diff --git a/images/tomcat9/rootfs/etc/confd/conf.d/tomcat-users.toml b/images/tomcat9/rootfs/etc/confd/conf.d/tomcat-users.toml index f5ebde25..fe990515 100644 --- a/images/tomcat9/rootfs/etc/confd/conf.d/tomcat-users.toml +++ b/images/tomcat9/rootfs/etc/confd/conf.d/tomcat-users.toml @@ -3,4 +3,5 @@ src = "tomcat-users.xml.tmpl" dest = "/opt/tomcat/conf/tomcat-users.xml" uid = 100 gid = 1000 +mode = "0640" keys = [ "/" ] diff --git a/images/tomcat9/rootfs/etc/confd/templates/tomcat-users.xml.tmpl b/images/tomcat9/rootfs/etc/confd/templates/tomcat-users.xml.tmpl index b137d2cf..4297e20c 100644 --- a/images/tomcat9/rootfs/etc/confd/templates/tomcat-users.xml.tmpl +++ b/images/tomcat9/rootfs/etc/confd/templates/tomcat-users.xml.tmpl @@ -1,9 +1,10 @@ +{{- define "xmlAttributeEscape" -}}{{- $ampersands := replace . "&" "&" -1 -}}{{- $quotes := replace $ampersands "\"" """ -1 -}}{{- $lessThan := replace $quotes "<" "<" -1 -}}{{- replace $lessThan ">" ">" -1 -}}{{- end -}} - - {{ range $dir := lsdir "/tomcat/user" }} + {{ if getenv "TOMCAT_ADMIN_PASSWORD" }}{{ end }} + {{ range $dir := lsdir "/tomcat/user" }} {{ end }} diff --git a/images/tomcat9/rootfs/etc/s6-overlay/s6-rc.d/tomcat/run b/images/tomcat9/rootfs/etc/s6-overlay/s6-rc.d/tomcat/run index 1ef17ba7..cabfc60b 100755 --- a/images/tomcat9/rootfs/etc/s6-overlay/s6-rc.d/tomcat/run +++ b/images/tomcat9/rootfs/etc/s6-overlay/s6-rc.d/tomcat/run @@ -1,4 +1,5 @@ -#!/usr/bin/env bash +#!/command/with-contenv bash +# shellcheck shell=bash set -e # Many services deployed into tomcat need somewhere to write data, by convention @@ -8,4 +9,5 @@ set -e # actually can write to the folder. chown tomcat:tomcat /data -exec with-contenv s6-setuidgid tomcat /opt/tomcat/bin/catalina.sh run +unset DB_ROOT_PASSWORD +exec s6-setuidgid tomcat /opt/tomcat/bin/catalina.sh run diff --git a/images/wp/Dockerfile b/images/wp/Dockerfile index 797d2775..b719e3a3 100644 --- a/images/wp/Dockerfile +++ b/images/wp/Dockerfile @@ -2,6 +2,8 @@ ARG PHP_BASE=nginx-php83 FROM ${PHP_BASE} +LABEL org.opencontainers.image.licenses="GPL-2.0-or-later" + ARG TARGETARCH ARG PHP_FPM_SOCKET=/run/php-fpm/php-fpm.sock @@ -28,24 +30,24 @@ ENV \ DB_PORT=3306 \ DB_NAME=wordpress \ DB_USER=wordpress \ - DB_PASSWORD=changeme \ + DB_PASSWORD= \ INGRESS_HOSTNAMES=localhost \ INGRESS_SCHEME=http \ WORDPRESS_SITE_TITLE=WordPress \ WORDPRESS_ADMIN_USERNAME=admin \ WORDPRESS_ADMIN_EMAIL=admin@example.com \ - WORDPRESS_ADMIN_PASSWORD=changeme \ + WORDPRESS_ADMIN_PASSWORD= \ WORDPRESS_LOCALE=en_US \ WORDPRESS_TABLE_PREFIX=wp_ \ WORDPRESS_BLOG_PUBLIC=0 \ - WORDPRESS_AUTH_KEY=changeme \ - WORDPRESS_SECURE_AUTH_KEY=changeme \ - WORDPRESS_LOGGED_IN_KEY=changeme \ - WORDPRESS_NONCE_KEY=changeme \ - WORDPRESS_AUTH_SALT=changeme \ - WORDPRESS_SECURE_AUTH_SALT=changeme \ - WORDPRESS_LOGGED_IN_SALT=changeme \ - WORDPRESS_NONCE_SALT=changeme \ + WORDPRESS_AUTH_KEY= \ + WORDPRESS_SECURE_AUTH_KEY= \ + WORDPRESS_LOGGED_IN_KEY= \ + WORDPRESS_NONCE_KEY= \ + WORDPRESS_AUTH_SALT= \ + WORDPRESS_SECURE_AUTH_SALT= \ + WORDPRESS_LOGGED_IN_SALT= \ + WORDPRESS_NONCE_SALT= \ LIBOPS_SMTP_HOST=host.docker.internal \ LIBOPS_SMTP_PORT=25 \ SMTP_FROM= \ @@ -78,4 +80,4 @@ ENV \ COPY --link rootfs / -HEALTHCHECK CMD curl -s http://localhost/status | grep -q pool +HEALTHCHECK CMD curl -fsS http://localhost/status | grep -q pool diff --git a/images/wp/rootfs/etc/confd/conf.d/wordpress.application.toml b/images/wp/rootfs/etc/confd/conf.d/wordpress.application.toml index 127529bf..a5f69f3c 100644 --- a/images/wp/rootfs/etc/confd/conf.d/wordpress.application.toml +++ b/images/wp/rootfs/etc/confd/conf.d/wordpress.application.toml @@ -2,6 +2,6 @@ src = "wordpress.application.php.tmpl" dest = "/var/www/bedrock/config/libops-runtime.php" uid = 0 -gid = 0 -mode = "0644" +gid = 101 +mode = "0640" keys = [ "/" ] diff --git a/images/wp/rootfs/etc/cont-init.d/01-wordpress-permissions b/images/wp/rootfs/etc/cont-init.d/01-wordpress-permissions index f4b29821..0dd17226 100755 --- a/images/wp/rootfs/etc/cont-init.d/01-wordpress-permissions +++ b/images/wp/rootfs/etc/cont-init.d/01-wordpress-permissions @@ -1,4 +1,5 @@ #!/command/with-contenv bash +# shellcheck shell=bash DIRS=( /var/www/bedrock/web/app/uploads diff --git a/images/wp/rootfs/etc/s6-overlay/scripts/wordpress-setup.sh b/images/wp/rootfs/etc/s6-overlay/scripts/wordpress-setup.sh index c798fa3e..61d054a4 100755 --- a/images/wp/rootfs/etc/s6-overlay/scripts/wordpress-setup.sh +++ b/images/wp/rootfs/etc/s6-overlay/scripts/wordpress-setup.sh @@ -3,16 +3,15 @@ set -eou pipefail -function mysql_create_database { - cat <<-SQL | create-database.sh -CREATE DATABASE IF NOT EXISTS ${DB_NAME} CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; - -CREATE USER IF NOT EXISTS '${DB_USER}'@'%' IDENTIFIED BY '${DB_PASSWORD}'; -GRANT ALL PRIVILEGES ON ${DB_NAME}.* to '${DB_USER}'@'%'; -FLUSH PRIVILEGES; +# shellcheck disable=SC1091 +source /usr/local/share/libops/database.sh +# shellcheck disable=SC1091 +source /usr/local/share/libops/environment.sh -SET PASSWORD FOR ${DB_USER}@'%' = PASSWORD('${DB_PASSWORD}'); -SQL +function mysql_create_database { + DB_CHARACTER_SET=utf8mb4 \ + DB_COLLATION=utf8mb4_unicode_ci \ + render-database-bootstrap-sql.sh | create-database.sh } function wp_cli { @@ -37,7 +36,13 @@ function should_skip_wordpress_setup { function wait_for_database { local attempts=0 - until mysql -h"${DB_HOST}" -u"${DB_USER}" -p"${DB_PASSWORD}" "${DB_NAME}" -e 'SELECT 1' >/dev/null 2>&1; do + until database_mariadb_with_password "${DB_PASSWORD}" \ + --host="${DB_HOST}" \ + --port="${DB_PORT}" \ + --user="${DB_USER}" \ + --database="${DB_NAME}" \ + --execute='SELECT 1' \ + >/dev/null 2>&1; do attempts=$((attempts + 1)) if [ "$attempts" -ge 60 ]; then echo "Database was not ready in time" @@ -61,13 +66,15 @@ function install_wordpress { return 0 fi + require_environment_variables WORDPRESS_ADMIN_PASSWORD + echo "WordPress not installed. Running wp-cli installation..." - wp_cli core install \ + printf '%s\n' "${WORDPRESS_ADMIN_PASSWORD}" | wp_cli core install \ --url="$(wordpress_home)" \ --title="${WORDPRESS_SITE_TITLE}" \ --admin_user="${WORDPRESS_ADMIN_USERNAME}" \ - --admin_password="${WORDPRESS_ADMIN_PASSWORD}" \ --admin_email="${WORDPRESS_ADMIN_EMAIL}" \ + --prompt=admin_password \ --skip-email wp_cli option update permalink_structure '/%postname%/' @@ -96,9 +103,16 @@ function main { return 0 fi + require_environment_variables \ + DB_HOST DB_PORT DB_NAME DB_USER DB_PASSWORD \ + WORDPRESS_AUTH_KEY WORDPRESS_SECURE_AUTH_KEY \ + WORDPRESS_LOGGED_IN_KEY WORDPRESS_NONCE_KEY \ + WORDPRESS_AUTH_SALT WORDPRESS_SECURE_AUTH_SALT \ + WORDPRESS_LOGGED_IN_SALT WORDPRESS_NONCE_SALT + wait_for_wordpress_files if [ "${DB_HOST}" = "mariadb" ]; then - mysql_create_database + database_bootstrap_if_enabled mysql_create_database fi wait_for_database install_wordpress diff --git a/internal/buildkit/metadata.go b/internal/buildkit/metadata.go index bf076cee..862153d4 100644 --- a/internal/buildkit/metadata.go +++ b/internal/buildkit/metadata.go @@ -232,7 +232,7 @@ func (m *Metadata) FirstTag(image, mode, fallback string) (string, error) { } func (m *Metadata) versionTags(image, fallback string) ([]string, error) { - if tag, ok, err := m.lampAppVersionTag(image); ok || err != nil { + if tag, ok, err := m.composerScaffoldVersionTag(image); ok || err != nil { if err != nil { return nil, err } @@ -287,9 +287,9 @@ func (m *Metadata) versionTags(image, fallback string) ([]string, error) { return uniqueStrings(result), nil } -func (m *Metadata) lampAppVersionTag(image string) (string, bool, error) { +func (m *Metadata) composerScaffoldVersionTag(image string) (string, bool, error) { suffix := phpFlavorSuffix(image) - if suffix == "" || !isLAMPAppImage(image) { + if suffix == "" || !isComposerScaffoldImage(image) { return "", false, nil } value, err := dockerfileArgDefault(filepath.Join(m.Root, "images", "nginx", "Dockerfile"), "NGINX_VERSION") @@ -306,9 +306,9 @@ func (m *Metadata) lampAppVersionTag(image string) (string, bool, error) { return "nginx-" + version + "-" + suffix, true, nil } -func isLAMPAppImage(image string) bool { +func isComposerScaffoldImage(image string) bool { switch imageContext(image) { - case "drupal", "islandora", "ojs", "omeka-classic", "omeka-s", "wp": + case "drupal", "islandora", "wp": return true default: return false @@ -600,10 +600,14 @@ func splitLines(value string) []string { func isGlobalPath(path string) bool { return path == "docker-bake.hcl" || + path == "go.mod" || + path == "go.sum" || path == "Makefile" || strings.HasPrefix(path, "buildSrc/") || + strings.HasPrefix(path, "cmd/") || strings.HasPrefix(path, "ci/") || - strings.HasPrefix(path, ".github/workflows/") + strings.HasPrefix(path, ".github/workflows/") || + strings.HasPrefix(path, "internal/") } func (m *Metadata) expandDependents(affected []string) []string { diff --git a/internal/buildkit/metadata_test.go b/internal/buildkit/metadata_test.go index caada06a..e9b2980d 100644 --- a/internal/buildkit/metadata_test.go +++ b/internal/buildkit/metadata_test.go @@ -1,8 +1,11 @@ package buildkit import ( + "archive/zip" "bytes" + "io" "os" + "os/exec" "path/filepath" "strings" "testing" @@ -27,11 +30,18 @@ func TestMetadataTagsMatchForkedVersioning(t *testing.T) { want []string }{ {image: "activemq5", mode: "fallback", fallback: "local", want: []string{"local-5"}}, + {image: "archivesspace", mode: "fallback", fallback: "local", want: []string{"local"}}, {image: "drupal-php83", mode: "fallback", fallback: "branch/name", want: []string{"branch-name-php83"}}, {image: "drupal-php83", mode: "version", fallback: "branch/name", want: []string{nginxTag + "-php83"}}, {image: "drupal-php84", mode: "version", fallback: "branch/name", want: []string{nginxTag + "-php84"}}, {image: "islandora-php84", mode: "version", fallback: "branch/name", want: []string{nginxTag + "-php84"}}, {image: "wp-php84", mode: "version", fallback: "branch/name", want: []string{nginxTag + "-php84"}}, + {image: "ojs-php83", mode: "version", fallback: "branch/name", want: []string{"3.5.0-5-php83", "3.5-php83", "3-php83", "php83"}}, + {image: "ojs-php84", mode: "version", fallback: "branch/name", want: []string{"3.5.0-5-php84", "3.5-php84", "3-php84", "php84", "latest-php84", "latest"}}, + {image: "omeka-s-php83", mode: "version", fallback: "branch/name", want: []string{"4.2.1-php83", "4.2-php83", "4-php83", "php83"}}, + {image: "omeka-s-php84", mode: "version", fallback: "branch/name", want: []string{"4.2.1-php84", "4.2-php84", "4-php84", "php84", "latest-php84", "latest"}}, + {image: "omeka-classic-php83", mode: "version", fallback: "branch/name", want: []string{"3.2.1-php83", "3.2-php83", "3-php83", "php83"}}, + {image: "omeka-classic-php84", mode: "version", fallback: "branch/name", want: []string{"3.2.1-php84", "3.2-php84", "3-php84", "php84", "latest-php84", "latest"}}, } for _, tt := range cases { @@ -50,6 +60,172 @@ func TestMetadataTagsMatchForkedVersioning(t *testing.T) { } } +func TestBundledPHPAppsDownloadVerifiedReleases(t *testing.T) { + root := repoRoot(t) + cases := []struct { + image string + version string + checksum string + dest string + }{ + {image: "ojs", version: "3.5.0-5", checksum: "fd59cb1add60ab4e56e40ed843ea43ff84af11f0b64c3940f2e03186ad11445e", dest: "/var/www/ojs"}, + {image: "omeka-s", version: "4.2.1", checksum: "cc27d1c7aca0209523d19aa285f4a08e29e34950dcc446951a7c1311de348e82", dest: "/var/www/omeka-s"}, + {image: "omeka-classic", version: "3.2.1", checksum: "2cb4d65511321cc5c009cb61516d9ed97378a800fc4a26eb46450c3c4ca230c2", dest: "/var/www/omeka-classic"}, + } + + for _, tt := range cases { + dockerfile := filepath.Join(root, "images", tt.image, "Dockerfile") + content, err := os.ReadFile(dockerfile) + if err != nil { + t.Fatal(err) + } + got := string(content) + for _, want := range []string{ + "SOFTWARE_VERSION=" + tt.version, + "SHA256=\"" + tt.checksum + "\"", + "download.sh", + "--sha256 \"${SHA256}\"", + "--strip", + "--dest " + tt.dest, + } { + if !strings.Contains(got, want) { + t.Errorf("%s missing %q", dockerfile, want) + } + } + } +} + +func TestMarketedApplicationImagesOverrideInheritedLicense(t *testing.T) { + root := repoRoot(t) + cases := map[string]string{ + "archivesspace": "ECL-2.0", + "archivesspace-solr": "Apache-2.0 AND ECL-2.0", + "drupal": "GPL-2.0-or-later", + "ojs": "GPL-3.0-only", + "omeka-classic": "GPL-3.0-or-later", + "omeka-s": "GPL-3.0-only", + "wp": "GPL-2.0-or-later", + } + + for image, license := range cases { + dockerfile := filepath.Join(root, "images", image, "Dockerfile") + content, err := os.ReadFile(dockerfile) + if err != nil { + t.Fatal(err) + } + want := `LABEL org.opencontainers.image.licenses="` + license + `"` + if !strings.Contains(string(content), want) { + t.Errorf("%s must override the inherited license with %q", dockerfile, license) + } + } +} + +func TestBundledApplicationImagesPreserveLicenseTexts(t *testing.T) { + root := repoRoot(t) + cases := map[string]string{ + "archivesspace": "cp /archivesspace/COPYING /usr/share/licenses/archivesspace/COPYING", + "archivesspace-solr": "cp /tmp/archivesspace-release/archivesspace/COPYING /usr/share/licenses/archivesspace-solr/COPYING", + "ojs": "cp docs/COPYING /usr/share/licenses/ojs/COPYING", + "omeka-classic": "cp /var/www/omeka-classic/license.txt /usr/share/licenses/omeka-classic/license.txt", + "omeka-s": "cp /var/www/omeka-s/LICENSE /usr/share/licenses/omeka-s/LICENSE", + } + + for image, want := range cases { + dockerfile := filepath.Join(root, "images", image, "Dockerfile") + content, err := os.ReadFile(dockerfile) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(content), want) { + t.Errorf("%s must preserve the bundled application's license text at a standard path", dockerfile) + } + } +} + +func TestCurlHealthchecksFailOnHTTPError(t *testing.T) { + root := repoRoot(t) + entries, err := os.ReadDir(filepath.Join(root, "images")) + if err != nil { + t.Fatal(err) + } + + for _, entry := range entries { + dockerfile := filepath.Join(root, "images", entry.Name(), "Dockerfile") + content, err := os.ReadFile(dockerfile) + if os.IsNotExist(err) { + continue + } + if err != nil { + t.Fatal(err) + } + for _, line := range strings.Split(string(content), "\n") { + if strings.Contains(line, "HEALTHCHECK") && strings.Contains(line, "curl ") && !strings.Contains(line, "curl -f") { + t.Errorf("%s healthcheck must make curl fail on HTTP errors: %s", dockerfile, line) + } + } + } +} + +func TestActiveMQHealthchecksKeepCredentialsOutOfArguments(t *testing.T) { + root := repoRoot(t) + for _, image := range []string{"activemq5", "activemq6"} { + dockerfile := filepath.Join(root, "images", image, "Dockerfile") + content, err := os.ReadFile(dockerfile) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(content), "HEALTHCHECK CMD activemq-healthcheck.sh") { + t.Errorf("%s must delegate to the credential-safe healthcheck helper", dockerfile) + } + if strings.Contains(string(content), "HEALTHCHECK CMD curl") || strings.Contains(string(content), "cat /var/run/s6/container_environment/ACTIVEMQ_WEB_ADMIN_PASSWORD") { + t.Errorf("%s must not expand web credentials in the healthcheck command", dockerfile) + } + + helper := filepath.Join(root, "images", image, "rootfs", "usr", "local", "bin", "activemq-healthcheck.sh") + helperContent, err := os.ReadFile(helper) + if err != nil { + t.Fatal(err) + } + got := string(helperContent) + for _, want := range []string{ + "ACTIVEMQ_WEB_ADMIN_NAME ACTIVEMQ_WEB_ADMIN_PASSWORD", + "umask 077", + "chmod 0600", + "trap 'rm -f", + `curl --config "${curl_config}"`, + `'fail'`, + } { + if !strings.Contains(got, want) { + t.Errorf("%s missing %q", helper, want) + } + } + if strings.Contains(got, `curl -u`) || strings.Contains(got, `curl --user`) { + t.Errorf("%s passes credentials through curl arguments", helper) + } + + for _, template := range []string{"credentials.properties.tmpl", "groups.properties.tmpl", "jetty-realm.properties.tmpl", "users.properties.tmpl"} { + templateFile := filepath.Join(root, "images", image, "rootfs", "etc", "confd", "templates", template) + templateContent, err := os.ReadFile(templateFile) + if err != nil { + t.Fatal(err) + } + rendered := string(templateContent) + for _, want := range []string{`define "propertiesValueEscape"`, `replace $backslashes "\r" "\\r"`, `replace $carriageReturns "\n" "\\n"`, `replace $newlines "\t" "\\t"`, `replace $tabs " " "\\ "`} { + if !strings.Contains(rendered, want) { + t.Errorf("%s missing Java properties escaping contract %q", templateFile, want) + } + } + if template != "credentials.properties.tmpl" { + for _, want := range []string{`define "propertiesKeyEscape"`, `replace $spaces ":" "\\:"`, `replace $colons "=" "\\="`, `template "propertiesKeyEscape"`} { + if !strings.Contains(rendered, want) { + t.Errorf("%s missing Java properties key escaping contract %q", templateFile, want) + } + } + } + } + } +} + func TestComposeEnvUsesCurrentVersionedImageForGenericAlias(t *testing.T) { root := repoRoot(t) metadata, err := LoadMetadata(root) @@ -105,6 +281,74 @@ func TestPlanSupportsLevelFourImages(t *testing.T) { } } +func TestPlannerImplementationPathsAreGlobal(t *testing.T) { + global := []string{ + "cmd/buildkit/main.go", + "go.mod", + "go.sum", + "internal/buildkit/metadata.go", + } + for _, path := range global { + if !isGlobalPath(path) { + t.Errorf("isGlobalPath(%q) = false, want true", path) + } + } + + if isGlobalPath("marketing/copy.md") { + t.Error("unrelated documentation must not trigger a global image build") + } +} + +func TestMakefileNormalizesBranchWithoutShellReinterpolation(t *testing.T) { + root := repoRoot(t) + makefile := filepath.Join(root, "Makefile") + content, err := os.ReadFile(makefile) + if err != nil { + t.Fatal(err) + } + got := string(content) + if !strings.Contains(got, "rev-parse --abbrev-ref HEAD | sed -E") { + t.Fatalf("%s must pipe the raw Git ref directly into normalization", makefile) + } + if strings.Contains(got, "BRANCH_RAW") { + t.Fatalf("%s must not interpolate an untrusted raw branch into another shell command", makefile) + } + if !strings.Contains(got, `BRANCH="$(BRANCH)"`) { + t.Fatalf("%s must quote the normalized branch passed to buildx bake", makefile) + } +} + +func TestBakeCacheRefsNormalizeSlashBranch(t *testing.T) { + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("docker CLI is not available") + } + root := repoRoot(t) + command := exec.Command("docker", "buildx", "bake", "--print", "activemq5-amd64") + command.Dir = root + command.Env = append(os.Environ(), "BRANCH=feature/foo") + output, err := command.CombinedOutput() + if err != nil { + t.Skipf("docker buildx bake is not available: %v\n%s", err, output) + } + got := string(output) + if !strings.Contains(got, "activemq5-feature-foo-amd64") { + t.Fatalf("slash branch was not normalized in cache refs:\n%s", got) + } + if strings.Contains(got, "feature/foo") { + t.Fatalf("slash branch leaked into a cache reference:\n%s", got) + } +} + +func TestUpdateSHAReadmeIsFailClosed(t *testing.T) { + root := repoRoot(t) + testScript := filepath.Join(root, "ci", "tests", "update-sha-readme.sh") + command := exec.Command("bash", testScript) + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("%s failed: %v\n%s", testScript, err, output) + } +} + func TestPlanOutputUsesEmptyArraysForEmptyLevels(t *testing.T) { root := repoRoot(t) metadata, err := LoadMetadata(root) @@ -167,6 +411,166 @@ func TestOJSEnableBeaconTemplateUsesTruthyHelper(t *testing.T) { } } +func TestOJSConfigEscapesDoubleQuotedSecrets(t *testing.T) { + root := repoRoot(t) + templateFile := filepath.Join(root, "images", "ojs", "rootfs", "etc", "confd", "templates", "config.inc.tmpl") + content, err := os.ReadFile(templateFile) + if err != nil { + t.Fatal(err) + } + got := string(content) + for _, variable := range []string{"OJS_SECRET_KEY", "OJS_SALT", "OJS_API_KEY_SECRET"} { + want := `template "doubleQuoteEscape" (getenv "` + variable + `")` + if !strings.Contains(got, want) { + t.Errorf("%s does not escape %s before rendering it in double quotes", templateFile, variable) + } + } +} + +func TestOJSInstalledStateIsDurableAcrossConfdRendering(t *testing.T) { + root := repoRoot(t) + templateFile := filepath.Join(root, "images", "ojs", "rootfs", "etc", "confd", "templates", "config.inc.tmpl") + content, err := os.ReadFile(templateFile) + if err != nil { + t.Fatal(err) + } + got := string(content) + if strings.Contains(got, "installed = Off") { + t.Fatalf("%s hardcodes the pre-install lifecycle state", templateFile) + } + if !strings.Contains(got, `installed = {{ template "booleanOnOff" (getenv "OJS_INSTALLED") }}`) { + t.Fatalf("%s must render the reconciled OJS lifecycle state", templateFile) + } + + for _, relative := range []string{ + "images/ojs/rootfs/etc/s6-overlay/s6-rc.d/confd-oneshot/dependencies.d/ojs-install-state", + "images/ojs/rootfs/etc/s6-overlay/s6-rc.d/confd/dependencies.d/ojs-setup", + "images/ojs/tests/ContinuousConfdPreservesInstalledState/docker-compose.yml", + } { + if _, err := os.Stat(filepath.Join(root, relative)); err != nil { + t.Errorf("missing OJS lifecycle regression asset %s: %v", relative, err) + } + } + + setupFile := filepath.Join(root, "images", "ojs", "rootfs", "etc", "s6-overlay", "scripts", "ojs-setup.sh") + setup, err := os.ReadFile(setupFile) + if err != nil { + t.Fatal(err) + } + stateIndex := strings.Index(string(setup), "/etc/s6-overlay/scripts/ojs-install-state.sh") + renderIndex := strings.LastIndex(string(setup), "render_ojs_config") + if stateIndex < 0 || renderIndex < 0 || stateIndex > renderIndex { + t.Fatalf("%s must persist installed state before rerendering config", setupFile) + } +} + +func TestCredentialBearingConfdTemplatesAreNotWorldReadable(t *testing.T) { + root := repoRoot(t) + files := []string{ + "images/alpaca/rootfs/etc/confd/conf.d/alpaca.properties.toml", + "images/ojs/rootfs/etc/confd/conf.d/config.inc.toml", + "images/tomcat9/rootfs/etc/confd/conf.d/tomcat-users.toml", + "images/tomcat11/rootfs/etc/confd/conf.d/tomcat-users.toml", + } + + for _, relative := range files { + content, err := os.ReadFile(filepath.Join(root, relative)) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(content), `mode = "0640"`) { + t.Errorf("%s must render service-readable credentials without world access", relative) + } + } +} + +func TestOmekaDatabaseTemplatesEscapeDoubleQuotedValues(t *testing.T) { + root := repoRoot(t) + cases := []struct { + file string + fields []string + }{ + { + file: filepath.Join(root, "images", "omeka-s", "rootfs", "etc", "confd", "templates", "database.ini.tmpl"), + fields: []string{"DB_HOST", "DB_PORT", "DB_NAME", "DB_USER", "DB_PASSWORD"}, + }, + { + file: filepath.Join(root, "images", "omeka-classic", "rootfs", "etc", "confd", "templates", "db.ini.tmpl"), + fields: []string{"DB_HOST", "DB_PORT", "DB_NAME", "DB_USER", "DB_PASSWORD", "OMEKA_CLASSIC_TABLE_PREFIX"}, + }, + } + + for _, tt := range cases { + content, err := os.ReadFile(tt.file) + if err != nil { + t.Fatal(err) + } + got := string(content) + if !strings.Contains(got, `define "doubleQuoteEscape"`) { + t.Errorf("%s does not define doubleQuoteEscape", tt.file) + } + for _, field := range tt.fields { + want := `template "doubleQuoteEscape" (getenv "` + field + `")` + if !strings.Contains(got, want) { + t.Errorf("%s does not escape %s", tt.file, field) + } + } + } +} + +func TestArchivesSpaceJDBCCredentialsAreURLAndRubyEscaped(t *testing.T) { + root := repoRoot(t) + templateFile := filepath.Join(root, "images", "archivesspace", "rootfs", "etc", "confd", "templates", "archivesspace.config.rb.tmpl") + content, err := os.ReadFile(templateFile) + if err != nil { + t.Fatal(err) + } + got := string(content) + for _, field := range []string{"$dbUser", "$dbPassword"} { + want := `URI.encode_www_form_component('{{ template "sqlEscape" ` + field + ` }}')` + if !strings.Contains(got, want) { + t.Errorf("%s must Ruby-escape and URL-encode %s", templateFile, field) + } + } + for _, unsafe := range []string{ + `&user={{ template "sqlEscape" $dbUser }}`, + `&password={{ template "sqlEscape" $dbPassword }}`, + } { + if strings.Contains(got, unsafe) { + t.Errorf("%s inserts a raw JDBC credential: %s", templateFile, unsafe) + } + } + + composeFile := filepath.Join(root, "images", "archivesspace", "tests", "ServiceHealthcheck", "docker-compose.yml") + compose, err := os.ReadFile(composeFile) + if err != nil { + t.Fatal(err) + } + if strings.Count(string(compose), `DB_PASSWORD: "archive&=space%#password"`) != 2 { + t.Errorf("%s must exercise URL-sensitive database credentials for both services", composeFile) + } +} + +func TestOmekaInstallersKeepAdminPasswordsOutOfCurlArguments(t *testing.T) { + root := repoRoot(t) + for _, image := range []string{"omeka-s", "omeka-classic"} { + file := filepath.Join(root, "images", image, "rootfs", "etc", "s6-overlay", "scripts", image+"-setup.sh") + content, err := os.ReadFile(file) + if err != nil { + t.Fatal(err) + } + got := string(content) + if strings.Contains(got, `--data-urlencode "password=${`) || strings.Contains(got, `-d "user[password`) { + t.Errorf("%s passes an admin password value in curl argv", file) + } + for _, want := range []string{"umask 077", `>"${form_dir}/admin-password"`, `@${form_dir}/admin-password`} { + if !strings.Contains(got, want) { + t.Errorf("%s missing secure form handling %q", file, want) + } + } + } +} + func TestBaseVaultSecretsBootstrap(t *testing.T) { root := repoRoot(t) @@ -200,6 +604,11 @@ func TestBaseVaultSecretsBootstrap(t *testing.T) { if !strings.Contains(string(containerEnvironmentContent), "GOOGLE_APPLICATION_CREDENTIALS") { t.Fatalf("%s must preserve GOOGLE_APPLICATION_CREDENTIALS as a file path", containerEnvironment) } + for _, want := range []string{"umask 077", "chmod 0700 /var/run/s6/container_environment", "chmod 0600"} { + if !strings.Contains(string(containerEnvironmentContent), want) { + t.Fatalf("%s must protect imported secret values with %q", containerEnvironment, want) + } + } script := filepath.Join(root, "images", "base", "rootfs", "etc", "s6-overlay", "scripts", "vault-secrets.sh") scriptContent, err := os.ReadFile(script) @@ -215,6 +624,8 @@ func TestBaseVaultSecretsBootstrap(t *testing.T) { "secret-site", "chmod 0400", "chown \"${app_uid}:0\"", + `if [[ "${name}" = "DB_ROOT_PASSWORD" ]]`, + "chown 0:0", } { if !strings.Contains(string(scriptContent), want) { t.Fatalf("%s missing %q", script, want) @@ -222,6 +633,553 @@ func TestBaseVaultSecretsBootstrap(t *testing.T) { } } +func TestContainerEnvironmentEscapesExeclineValues(t *testing.T) { + root := repoRoot(t) + file := filepath.Join(root, "images", "base", "rootfs", "etc", "s6-overlay", "scripts", "container-environment.sh") + content, err := os.ReadFile(file) + if err != nil { + t.Fatal(err) + } + got := string(content) + if !strings.Contains(got, `define "execlineEscape"`) { + t.Fatalf("%s must define an execline value escape helper", file) + } + if !strings.Contains(got, `template \"execlineEscape\"`) { + t.Fatalf("%s must escape values before generating the execline import script", file) + } +} + +func TestWithContEnvApplicationLongrunsDropDatabaseRootPassword(t *testing.T) { + root := repoRoot(t) + files := []string{ + filepath.Join(root, "images", "activemq5", "rootfs", "etc", "s6-overlay", "s6-rc.d", "activemq", "run"), + filepath.Join(root, "images", "activemq6", "rootfs", "etc", "s6-overlay", "s6-rc.d", "activemq", "run"), + filepath.Join(root, "images", "alpaca", "rootfs", "etc", "s6-overlay", "s6-rc.d", "alpaca", "run"), + filepath.Join(root, "images", "archivesspace", "rootfs", "usr", "local", "bin", "archivesspace-startup.sh"), + filepath.Join(root, "images", "solr9", "rootfs", "etc", "s6-overlay", "s6-rc.d", "solr", "run"), + filepath.Join(root, "images", "solr10", "rootfs", "etc", "s6-overlay", "s6-rc.d", "solr", "run"), + filepath.Join(root, "images", "tomcat9", "rootfs", "etc", "s6-overlay", "s6-rc.d", "tomcat", "run"), + filepath.Join(root, "images", "tomcat11", "rootfs", "etc", "s6-overlay", "s6-rc.d", "tomcat", "run"), + } + + for _, file := range files { + content, err := os.ReadFile(file) + if err != nil { + t.Fatal(err) + } + got := string(content) + unsetIndex := strings.Index(got, "unset DB_ROOT_PASSWORD") + execIndex := strings.LastIndex(got, "exec ") + if unsetIndex < 0 || execIndex < 0 || unsetIndex > execIndex { + t.Errorf("%s must unset DB_ROOT_PASSWORD before its final exec", file) + } + } +} + +func TestApplicationDatabaseBootstrapIsExplicit(t *testing.T) { + root := repoRoot(t) + baseDockerfile := filepath.Join(root, "images", "base", "Dockerfile") + content, err := os.ReadFile(baseDockerfile) + if err != nil { + t.Fatal(err) + } + base := string(content) + if !strings.Contains(base, "DB_BOOTSTRAP_ENABLED=false") { + t.Fatalf("%s must disable application-side database bootstrap by default", baseDockerfile) + } + if strings.Contains(base, "DB_ROOT_PASSWORD=password") { + t.Fatalf("%s must not provide a placeholder database root password", baseDockerfile) + } + + files := []string{ + filepath.Join(root, "images", "drupal", "rootfs", "etc", "s6-overlay", "scripts", "install.sh"), + filepath.Join(root, "images", "ojs", "rootfs", "etc", "s6-overlay", "scripts", "ojs-setup.sh"), + filepath.Join(root, "images", "omeka-s", "rootfs", "etc", "s6-overlay", "scripts", "omeka-s-setup.sh"), + filepath.Join(root, "images", "omeka-classic", "rootfs", "etc", "s6-overlay", "scripts", "omeka-classic-setup.sh"), + filepath.Join(root, "images", "wp", "rootfs", "etc", "s6-overlay", "scripts", "wordpress-setup.sh"), + filepath.Join(root, "images", "archivesspace", "rootfs", "usr", "local", "bin", "archivesspace-startup.sh"), + filepath.Join(root, "images", "fcrepo6", "rootfs", "etc", "s6-overlay", "scripts", "fcrepo-setup.sh"), + filepath.Join(root, "images", "fcrepo7", "rootfs", "etc", "s6-overlay", "scripts", "fcrepo-setup.sh"), + } + for _, file := range files { + content, err := os.ReadFile(file) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(content), "database_bootstrap_if_enabled") { + t.Errorf("%s must gate database creation behind the explicit bootstrap helper", file) + } + } +} + +func TestMarketedApplicationImagesDoNotShipKnownCredentials(t *testing.T) { + root := repoRoot(t) + images := []string{"archivesspace", "drupal", "ojs", "omeka-s", "omeka-classic", "wp"} + banned := []string{ + "DB_PASSWORD=changeme", + "DRUPAL_DEFAULT_ACCOUNT_PASSWORD=password", + "DRUPAL_DEFAULT_SALT=9PPaL0", + "OJS_SALT=changeme", + "OJS_API_KEY_SECRET=changeme", + "OJS_SECRET_KEY=changeme", + "OJS_ADMIN_PASSWORD=changeme", + "OMEKA_S_ADMIN_PASSWORD=changeme", + "OMEKA_CLASSIC_ADMIN_PASSWORD=changeme", + "WORDPRESS_ADMIN_PASSWORD=changeme", + "WORDPRESS_AUTH_KEY=changeme", + "WORDPRESS_NONCE_SALT=changeme", + } + for _, image := range images { + file := filepath.Join(root, "images", image, "Dockerfile") + content, err := os.ReadFile(file) + if err != nil { + t.Fatal(err) + } + got := string(content) + for _, value := range banned { + if strings.Contains(got, value) { + t.Errorf("%s ships known credential %q", file, value) + } + } + } +} + +func TestNetworkServicesDoNotShipKnownCredentials(t *testing.T) { + root := repoRoot(t) + cases := []struct { + file string + banned []string + }{ + {file: filepath.Join(root, "images", "activemq5", "Dockerfile"), banned: []string{"ACTIVEMQ_PASSWORD=password", "ACTIVEMQ_WEB_ADMIN_PASSWORD=password"}}, + {file: filepath.Join(root, "images", "activemq6", "Dockerfile"), banned: []string{"ACTIVEMQ_PASSWORD=password", "ACTIVEMQ_WEB_ADMIN_PASSWORD=password"}}, + {file: filepath.Join(root, "images", "alpaca", "Dockerfile"), banned: []string{"ALPACA_JMS_PASSWORD=password"}}, + {file: filepath.Join(root, "images", "base", "Dockerfile"), banned: []string{"DB_PASSWORD=password", "JWT_ADMIN_TOKEN=islandora"}}, + {file: filepath.Join(root, "images", "fcrepo6", "Dockerfile"), banned: []string{"DB_PASSWORD=password"}}, + {file: filepath.Join(root, "images", "fcrepo7", "Dockerfile"), banned: []string{"DB_PASSWORD=password"}}, + {file: filepath.Join(root, "images", "tomcat9", "Dockerfile"), banned: []string{"TOMCAT_ADMIN_PASSWORD=password", "TOMCAT_MANAGER_REMOTE_ADDRESS_VALVE=^.*$", "TOMCAT_MANAGER_REMOTE_ADDRESS_VALVE=^(127[.]|::1$)"}}, + {file: filepath.Join(root, "images", "tomcat11", "Dockerfile"), banned: []string{"TOMCAT_ADMIN_PASSWORD=password", "TOMCAT_MANAGER_REMOTE_ADDRESS_VALVE=^.*$", "TOMCAT_MANAGER_REMOTE_ADDRESS_VALVE=^(127[.]|::1$)"}}, + } + for _, tt := range cases { + content, err := os.ReadFile(tt.file) + if err != nil { + t.Fatal(err) + } + for _, value := range tt.banned { + if strings.Contains(string(content), value) { + t.Errorf("%s ships known credential %q", tt.file, value) + } + } + } +} + +func TestOJSSetupInstallsAgainstExternalDatabaseHost(t *testing.T) { + root := repoRoot(t) + library := filepath.Join(root, "images", "base", "rootfs", "usr", "local", "share", "libops", "database.sh") + environmentLibrary := filepath.Join(root, "images", "base", "rootfs", "usr", "local", "share", "libops", "environment.sh") + setup := filepath.Join(root, "images", "ojs", "rootfs", "etc", "s6-overlay", "scripts", "ojs-setup.sh") + harness := ` +set -euo pipefail +export OJS_SETUP_LIBRARY_ONLY=true +export LIBOPS_DATABASE_LIBRARY="$1" +export LIBOPS_ENVIRONMENT_LIBRARY="$2" +source "$3" +calls=$(mktemp) +mysql_create_database() { printf 'bootstrap\n' >>"${calls}"; } +wait_for_database() { printf 'wait\n' >>"${calls}"; } +check_ojs_installed() { printf 'check\n' >>"${calls}"; return 1; } +install_ojs() { printf 'install\n' >>"${calls}"; } +export DB_HOST=external-database.example +export DB_BOOTSTRAP_ENABLED=false +export OJS_ADMIN_PASSWORD=test-admin-password +setup_ojs_database +test "$(cat "${calls}")" = $'wait\ncheck\ninstall' +` + command := exec.Command("bash", "-c", harness, "bash", library, environmentLibrary, setup) + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("external database setup stub failed: %v\n%s", err, output) + } +} + +func TestNonDatabaseLongRunsDropDatabaseRootPassword(t *testing.T) { + root := repoRoot(t) + files := []string{ + filepath.Join(root, "images", "base", "rootfs", "etc", "s6-overlay", "s6-rc.d", "confd", "run"), + filepath.Join(root, "images", "nginx", "rootfs", "etc", "s6-overlay", "s6-rc.d", "nginx", "run"), + filepath.Join(root, "images", "php83", "rootfs", "etc", "s6-overlay", "s6-rc.d", "fpm", "run"), + filepath.Join(root, "images", "php84", "rootfs", "etc", "s6-overlay", "s6-rc.d", "fpm", "run"), + filepath.Join(root, "images", "scyllaridae", "rootfs", "etc", "s6-overlay", "s6-rc.d", "scyllaridae", "run"), + } + for _, file := range files { + content, err := os.ReadFile(file) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(content), "unset DB_ROOT_PASSWORD") && !strings.Contains(string(content), "unexport DB_ROOT_PASSWORD") { + t.Errorf("%s retains DB_ROOT_PASSWORD in a non-database longrun", file) + } + } +} + +func TestPHPLongRunsDropSetupOnlyAdminPasswords(t *testing.T) { + root := repoRoot(t) + files := []string{ + filepath.Join(root, "images", "nginx", "rootfs", "etc", "s6-overlay", "s6-rc.d", "nginx", "run"), + filepath.Join(root, "images", "php83", "rootfs", "etc", "s6-overlay", "s6-rc.d", "fpm", "run"), + filepath.Join(root, "images", "php84", "rootfs", "etc", "s6-overlay", "s6-rc.d", "fpm", "run"), + } + variables := []string{ + "DRUPAL_DEFAULT_ACCOUNT_PASSWORD", + "OJS_ADMIN_PASSWORD", + "OMEKA_CLASSIC_ADMIN_PASSWORD", + "OMEKA_S_ADMIN_PASSWORD", + "WORDPRESS_ADMIN_PASSWORD", + } + for _, file := range files { + content, err := os.ReadFile(file) + if err != nil { + t.Fatal(err) + } + got := string(content) + for _, variable := range variables { + if !strings.Contains(got, variable) { + t.Errorf("%s retains setup-only %s", file, variable) + } + } + } +} + +func TestWordPressAdminPasswordUsesPromptInput(t *testing.T) { + root := repoRoot(t) + file := filepath.Join(root, "images", "wp", "rootfs", "etc", "s6-overlay", "scripts", "wordpress-setup.sh") + content, err := os.ReadFile(file) + if err != nil { + t.Fatal(err) + } + got := string(content) + if strings.Contains(got, `--admin_password="${WORDPRESS_ADMIN_PASSWORD}"`) { + t.Fatalf("%s exposes the administrator password in wp-cli argv", file) + } + if !strings.Contains(got, `--prompt=admin_password`) || !strings.Contains(got, `printf '%s\n' "${WORDPRESS_ADMIN_PASSWORD}"`) { + t.Fatalf("%s must provide the administrator password through wp-cli prompt input", file) + } +} + +func TestTomcatManagerDefaultAllowsOnlyLoopback(t *testing.T) { + root := repoRoot(t) + for _, version := range []string{"tomcat9", "tomcat11"} { + file := filepath.Join(root, "images", version, "Dockerfile") + content, err := os.ReadFile(file) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(content), "TOMCAT_MANAGER_REMOTE_ADDRESS_VALVE=^(127[.].*|::1)$") { + t.Errorf("%s does not restrict the manager valve to complete loopback addresses", file) + } + + templateFile := filepath.Join(root, "images", version, "rootfs", "etc", "confd", "templates", "tomcat-users.xml.tmpl") + templateContent, err := os.ReadFile(templateFile) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(templateContent), `template "xmlAttributeEscape" (getenv "TOMCAT_ADMIN_PASSWORD")`) { + t.Errorf("%s does not XML-escape the administrator password", templateFile) + } + if strings.Contains(string(templateContent), `$escaped = replace`) { + t.Errorf("%s uses template reassignment unsupported by the bundled confd", templateFile) + } + + readmeFile := filepath.Join(root, "images", version, "README.md") + readmeContent, err := os.ReadFile(readmeFile) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{"-p 127.0.0.1:8080:8080", `--env TOMCAT_MANAGER_REMOTE_ADDRESS_VALVE='.*'`} { + if !strings.Contains(string(readmeContent), want) { + t.Errorf("%s local manager example missing %q", readmeFile, want) + } + } + } +} + +func TestDrupalInstalledQueryDoesNotInterpolateDatabaseName(t *testing.T) { + root := repoRoot(t) + library := filepath.Join(root, "images", "base", "rootfs", "usr", "local", "share", "libops", "database.sh") + environmentLibrary := filepath.Join(root, "images", "base", "rootfs", "usr", "local", "share", "libops", "environment.sh") + setup := filepath.Join(root, "images", "drupal", "rootfs", "etc", "s6-overlay", "scripts", "install.sh") + harness := ` +set -euo pipefail +export DRUPAL_SETUP_LIBRARY_ONLY=true +export LIBOPS_DATABASE_LIBRARY="$1" +export LIBOPS_ENVIRONMENT_LIBRARY="$2" +export DB_NAME="external-db-'quoted" +source "$3" +query=$(mysql_count_query) +grep -Fq 'WHERE table_schema = DATABASE();' <<<"${query}" +if grep -Fq "${DB_NAME}" <<<"${query}"; then exit 1; fi +` + command := exec.Command("bash", "-c", harness, "bash", library, environmentLibrary, setup) + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("Drupal installed query interpolation test failed: %v\n%s", err, output) + } +} + +func TestCommandHelpersDoNotEvalArguments(t *testing.T) { + root := repoRoot(t) + files := []string{ + filepath.Join(root, "images", "base", "rootfs", "usr", "local", "bin", "download.sh"), + filepath.Join(root, "images", "base", "rootfs", "usr", "local", "bin", "create-service-user.sh"), + filepath.Join(root, "images", "base", "rootfs", "usr", "local", "bin", "cleanup.sh"), + filepath.Join(root, "images", "base", "rootfs", "usr", "local", "bin", "confd-import-environment.sh"), + filepath.Join(root, "images", "base", "rootfs", "usr", "local", "bin", "wait-for-open-port.sh"), + filepath.Join(root, "images", "base", "rootfs", "usr", "local", "bin", "confd-render-templates.sh"), + filepath.Join(root, "images", "drupal", "rootfs", "usr", "local", "bin", "install-drupal-site.sh"), + } + for _, file := range files { + content, err := os.ReadFile(file) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(content), "eval "+"set --") { + t.Errorf("%s evaluates reconstructed command-line arguments", file) + } + + marker := filepath.Join(t.TempDir(), "argument-was-executed") + malicious := `space "$(touch ` + marker + `)" 'quoted'` + command := exec.Command("bash", file, "--help", malicious) + command.Env = append(os.Environ(), "DOWNLOAD_CACHE_DIRECTORY=/tmp") + _ = command.Run() + if _, err := os.Stat(marker); !os.IsNotExist(err) { + t.Errorf("%s executed a command embedded in an argument", file) + } + } +} + +func TestDatabaseHelpersDoNotForwardPasswordsInArguments(t *testing.T) { + root := repoRoot(t) + cases := []struct { + file string + banned []string + }{ + { + file: filepath.Join(root, "images", "base", "rootfs", "usr", "local", "bin", "create-database.sh"), + banned: []string{ + `--password "${PASSWORD}"`, + }, + }, + { + file: filepath.Join(root, "images", "base", "rootfs", "usr", "local", "bin", "execute-sql-file.sh"), + banned: []string{ + `--password "${PASSWORD}"`, + `--password="${PASSWORD}"`, + }, + }, + { + file: filepath.Join(root, "images", "base", "rootfs", "usr", "local", "bin", "wait-for-database.sh"), + banned: []string{ + `--password="${PASSWORD}"`, + }, + }, + } + for _, tt := range cases { + content, err := os.ReadFile(tt.file) + if err != nil { + t.Fatal(err) + } + for _, banned := range tt.banned { + if strings.Contains(string(content), banned) { + t.Errorf("%s exposes a password through child argv: %s", tt.file, banned) + } + } + } +} + +func TestDrupalInstallersKeepDatabasePasswordsOutOfArguments(t *testing.T) { + root := repoRoot(t) + primary := filepath.Join(root, "images", "drupal", "rootfs", "etc", "s6-overlay", "scripts", "install.sh") + helper := filepath.Join(root, "images", "drupal", "rootfs", "usr", "local", "bin", "install-drupal-site.sh") + islandora := filepath.Join(root, "images", "islandora", "rootfs", "etc", "islandora", "utilities.sh") + + for _, file := range []string{primary, helper, islandora} { + content, err := os.ReadFile(file) + if err != nil { + t.Fatal(err) + } + got := string(content) + for _, banned := range []string{"--db-url=", "--db-password", "--account-pass="} { + if strings.Contains(got, banned) { + t.Errorf("%s still forwards a credential using %q", file, banned) + } + } + } + + helperContent, err := os.ReadFile(helper) + if err != nil { + t.Fatal(err) + } + got := string(helperContent) + for _, want := range []string{ + `rawurlencode((string) getenv("LIBOPS_DRUPAL_URI_COMPONENT"))`, + `DRUSH_COMMAND_SITE_INSTALL_OPTIONS_DB_URL="${drush_database_url}"`, + `DB_PASSWORD=${LIBOPS_DRUPAL_INSTALL_DB_PASSWORD:-}`, + } { + if !strings.Contains(got, want) { + t.Errorf("%s missing %q", helper, want) + } + } +} + +func TestGenericDrupalInstallerEncodesDatabaseURLWithoutArgumentExposure(t *testing.T) { + root := repoRoot(t) + helper := filepath.Join(root, "images", "drupal", "rootfs", "usr", "local", "bin", "install-drupal-site.sh") + binDir := t.TempDir() + outputFile := filepath.Join(t.TempDir(), "drush-db-url") + rawPassword := `p"a\ss'word&+=:/?#%` + + executeSQL := filepath.Join(binDir, "execute-sql-file.sh") + if err := os.WriteFile(executeSQL, []byte("#!/bin/sh\nprintf '0\\n'\n"), 0o755); err != nil { + t.Fatal(err) + } + drush := filepath.Join(binDir, "drush") + drushScript := `#!/bin/sh +for argument do + case "${argument}" in + *"${EXPECTED_RAW_PASSWORD}"*) exit 42 ;; + esac +done +printf '%s\n' "${DRUSH_COMMAND_SITE_INSTALL_OPTIONS_DB_URL}" >"${DRUSH_OUTPUT}" +` + if err := os.WriteFile(drush, []byte(drushScript), 0o755); err != nil { + t.Fatal(err) + } + + command := exec.Command("bash", helper, + "--host", "database", + "--port", "3306", + "--db-user", "user@name", + "--db-name", "drupal/name", + "standard", "--sites-subdir=default", + ) + command.Env = append(os.Environ(), + "PATH="+binDir+":"+os.Getenv("PATH"), + "LIBOPS_DRUPAL_INSTALL_DB_PASSWORD="+rawPassword, + "EXPECTED_RAW_PASSWORD="+rawPassword, + "DRUSH_OUTPUT="+outputFile, + ) + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("generic Drupal installer failed: %v\n%s", err, output) + } + url, err := os.ReadFile(outputFile) + if err != nil { + t.Fatal(err) + } + want := "mysql://user%40name:p%22a%5Css%27word%26%2B%3D%3A%2F%3F%23%25@database:3306/drupal%2Fname\n" + if string(url) != want { + t.Fatalf("encoded Drush database URL = %q, want %q", url, want) + } +} + +func TestPHPRuntimeSecretConfigsAreGroupReadableOnly(t *testing.T) { + root := repoRoot(t) + files := []string{ + "images/drupal/rootfs/etc/confd/conf.d/drupal.libops.settings.toml", + "images/wp/rootfs/etc/confd/conf.d/wordpress.application.toml", + } + for _, relative := range files { + content, err := os.ReadFile(filepath.Join(root, relative)) + if err != nil { + t.Fatal(err) + } + got := string(content) + if !strings.Contains(got, "uid = 0\ngid = 101\nmode = \"0640\"") { + t.Errorf("%s must render root-owned, nginx-group-readable runtime secrets", relative) + } + } + + developmentScript, err := os.ReadFile(filepath.Join(root, "images", "islandora", "rootfs", "etc", "s6-overlay", "scripts", "development-environment.sh")) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(developmentScript), "groupmod") { + t.Error("Islandora development UID remapping must preserve the stable nginx GID used by runtime secret configs") + } +} + +func TestVersionedImageReadmesMatchDockerfiles(t *testing.T) { + root := repoRoot(t) + images := []string{"alpaca", "mariadb11", "tomcat11"} + for _, image := range images { + version, err := dockerfileArgDefault(filepath.Join(root, "images", image, "Dockerfile"), "SOFTWARE_VERSION") + if err != nil { + t.Fatal(err) + } + version = normalizeVersion(image, version) + readme := filepath.Join(root, "images", image, "README.md") + content, err := os.ReadFile(readme) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(content), "version "+version+".") { + t.Errorf("%s does not document Dockerfile SOFTWARE_VERSION %s", readme, version) + } + } +} + +func TestDownloadZipStripRequiresOneTopLevelDirectory(t *testing.T) { + root := repoRoot(t) + script := filepath.Join(root, "images", "base", "rootfs", "usr", "local", "bin", "download.sh") + + invalidZip := filepath.Join(t.TempDir(), "invalid.zip") + writeTestZip(t, invalidZip, map[string]string{ + "first/file.txt": "first", + "second/file.txt": "second", + }) + invalidDest := t.TempDir() + command := exec.Command("bash", "-c", `set -euo pipefail; export DOWNLOAD_LIBRARY_ONLY=true; source "$1"; STRIP=true; REMOVE=(); unpack "$2" "$3"`, "bash", script, invalidZip, invalidDest) + output, err := command.CombinedOutput() + if err == nil || !strings.Contains(string(output), "exactly one top-level directory") { + t.Fatalf("multi-root ZIP was not rejected safely: err=%v output=%s", err, output) + } + + validZip := filepath.Join(t.TempDir(), "valid.zip") + writeTestZip(t, validZip, map[string]string{"release/file.txt": "payload"}) + validDest := t.TempDir() + command = exec.Command("bash", "-c", `set -euo pipefail; export DOWNLOAD_LIBRARY_ONLY=true; source "$1"; STRIP=true; REMOVE=(); unpack "$2" "$3"`, "bash", script, validZip, validDest) + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("single-root ZIP strip failed: %v\n%s", err, output) + } + content, err := os.ReadFile(filepath.Join(validDest, "file.txt")) + if err != nil { + t.Fatal(err) + } + if string(content) != "payload" { + t.Fatalf("stripped ZIP payload = %q", content) + } +} + +func writeTestZip(t *testing.T, path string, entries map[string]string) { + t.Helper() + file, err := os.Create(path) + if err != nil { + t.Fatal(err) + } + archive := zip.NewWriter(file) + for name, content := range entries { + entry, err := archive.Create(name) + if err != nil { + t.Fatal(err) + } + if _, err := io.WriteString(entry, content); err != nil { + t.Fatal(err) + } + } + if err := archive.Close(); err != nil { + t.Fatal(err) + } + if err := file.Close(); err != nil { + t.Fatal(err) + } +} + func repoRoot(t *testing.T) string { t.Helper() dir, err := os.Getwd() diff --git a/internal/buildkit/test_runner.go b/internal/buildkit/test_runner.go index de454460..5fcce67a 100644 --- a/internal/buildkit/test_runner.go +++ b/internal/buildkit/test_runner.go @@ -454,7 +454,16 @@ func (r *Runner) specFor(test TestCase) testSpec { } case "drupal": spec.Timeout = 10 * time.Minute - case "ojs-php83", "ojs-php84", "omeka-s-php83", "omeka-s-php84", "omeka-classic-php83", "omeka-classic-php84", "wp-php83", "wp-php84", "drupal-php83", "drupal-php84", "islandora-php83", "islandora-php84": + case "mariadb11": + if test.Name == "RejectsEmptyRootPassword" { + spec.ExpectedExitCode["mariadb"] = []int{1} + } + case "ojs-php83", "ojs-php84", "omeka-s-php83", "omeka-s-php84", "omeka-classic-php83", "omeka-classic-php84": + spec.Timeout = 10 * time.Minute + if test.Name == "ServiceLogsClientIp" { + spec.CheckClientIPLog = true + } + case "wp-php83", "wp-php84", "drupal-php83", "drupal-php84", "islandora-php83", "islandora-php84": if test.Name == "ServiceLogsClientIp" { spec.CheckClientIPLog = true } @@ -559,8 +568,7 @@ func (r *Runner) waitForHealthy(ctx context.Context, test TestCase, env []string ticker := time.NewTicker(5 * time.Second) defer ticker.Stop() targetServices := r.healthcheckServices(test, services) - attempts := 20 - for attempt := 1; attempt <= attempts; attempt++ { + for { healthy, err := r.anyHealthyContainer(ctx, test, env, targetServices) if err == nil && healthy { fmt.Fprintf(r.stdout, "healthy service found for %s/%s: %s\n", test.Image, test.Name, strings.Join(targetServices, ", ")) @@ -574,7 +582,6 @@ func (r *Runner) waitForHealthy(ctx context.Context, test TestCase, env []string case <-ticker.C: } } - return fmt.Errorf("no target service reported healthy after %d attempts", attempts) } func (r *Runner) healthcheckServices(test TestCase, services []string) []string { diff --git a/renovate.json5 b/renovate.json5 index cd37c96c..f769aa28 100644 --- a/renovate.json5 +++ b/renovate.json5 @@ -63,6 +63,18 @@ ], allowedVersions: "/^7\\./", }, + { + matchDepNames: [ + "islandora-syn-fcrepo6", + ], + allowedVersions: "/^1\\./", + }, + { + matchDepNames: [ + "islandora-syn-fcrepo7", + ], + allowedVersions: "/^2\\./", + }, { matchDepNames: [ "apache-tomcat9", @@ -85,7 +97,7 @@ matchDepNames: [ "omeka-classic", ], - allowedVersions: "/^\\d+\\.\\d+$/", + allowedVersions: "/^\\d+\\.\\d+(?:\\.\\d+)?$/", }, { matchDatasources: [ @@ -174,6 +186,8 @@ "fits-servlet", "golang", "islandora-starter-site", + "islandora-syn-fcrepo6", + "islandora-syn-fcrepo7", "mysql-connector-j", "ojs", "omeka-classic", @@ -199,6 +213,17 @@ }, ], customManagers: [ + { + customType: "regex", + description: "Update the digest-pinned BuildKit daemon image", + managerFilePatterns: [ + "/^\\.github/workflows/build\\.yml$/", + ], + matchStrings: [ + "# renovate: datasource=(?docker) depName=(?moby/buildkit)\\s+BUILDKIT_IMAGE: moby/buildkit:(?[^@\\s]+)@(?sha256:[a-f0-9]{64})", + ], + versioningTemplate: "docker", + }, { customType: "regex", description: "Update (COMMIT|_VERSION) variables in Dockerfiles",