diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7224cce..31fa45d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,7 +1,14 @@ name: Build and push image # Builds insectai/minio for linux/amd64 and linux/arm64 from the sources pinned in versions.env. +# Every run first builds linux/amd64 into the runner's Docker daemon and runs test/smoke.sh against it. # Pull requests only build (no push). Pushes to main and manual runs build and push. +# +# Each published build gets three tags: +# -r immutable; never overwritten (a run whose revision is already published +# pushes nothing; raise IMAGE_REVISION in versions.env to publish a rebuild) +# moves to the newest build of that release +# latest moves to the newest build (skipped when tag_latest is false) on: push: @@ -9,11 +16,17 @@ on: paths: - Dockerfile - versions.env + - docker-entrypoint.sh + - minio-healthcheck + - test/** - .github/workflows/build.yml pull_request: paths: - Dockerfile - versions.env + - docker-entrypoint.sh + - minio-healthcheck + - test/** - .github/workflows/build.yml workflow_dispatch: inputs: @@ -49,13 +62,50 @@ jobs: if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then push="${{ inputs.push }}"; latest="${{ inputs.tag_latest }}" fi + revision_tag="$IMAGE:$MINIO_TAG-r$IMAGE_REVISION" + # The -rN tag is immutable: if it already exists, do not publish again. + if [ "$push" = "true" ] && docker buildx imagetools inspect "$revision_tag" >/dev/null 2>&1; then + push=false + { + echo "## Not published" + echo + echo "\`$revision_tag\` already exists and is never overwritten." + echo "To publish a rebuild, raise \`IMAGE_REVISION\` in versions.env (or run the update workflow with force_rebuild)." + } >> "$GITHUB_STEP_SUMMARY" + fi echo "push=$push" >> "$GITHUB_OUTPUT" - tags="$IMAGE:$MINIO_TAG" + echo "revision_tag=$revision_tag" >> "$GITHUB_OUTPUT" + tags="$revision_tag,$IMAGE:$MINIO_TAG" if [ "$latest" = "true" ]; then tags="$tags,$IMAGE:latest"; fi echo "tags=$tags" >> "$GITHUB_OUTPUT" - uses: docker/setup-buildx-action@v3 + - name: Build for the smoke test (linux/amd64, loaded locally) + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64 + load: true + push: false + tags: insectai/minio:smoke + build-args: | + GO_IMAGE=${{ env.GO_IMAGE }} + RUNTIME_IMAGE=${{ env.RUNTIME_IMAGE }} + MINIO_REPO=${{ env.MINIO_REPO }} + MINIO_TAG=${{ env.MINIO_TAG }} + MINIO_COMMIT=${{ env.MINIO_COMMIT }} + MC_REPO=${{ env.MC_REPO }} + MC_TAG=${{ env.MC_TAG }} + MC_COMMIT=${{ env.MC_COMMIT }} + SOURCE_REVISION=${{ github.sha }} + provenance: false + sbom: false + cache-from: type=gha + + - name: Smoke test + run: test/smoke.sh insectai/minio:smoke + - name: Log in to Docker Hub if: steps.mode.outputs.push == 'true' uses: docker/login-action@v3 @@ -96,9 +146,11 @@ jobs: echo "Copy this into docker-compose files to pin the image:" echo echo '```' - echo "image: $IMAGE:$MINIO_TAG@${{ steps.build.outputs.digest }}" + echo "image: ${{ steps.mode.outputs.revision_tag }}@${{ steps.build.outputs.digest }}" echo '```' echo + echo "Tags pushed: \`${{ steps.mode.outputs.tags }}\`" + echo echo "Server: \`$MINIO_REPO\` @ \`$MINIO_TAG\` (\`$MINIO_COMMIT\`)" echo echo "Client: \`$MC_REPO\` @ \`$MC_TAG\` (\`$MC_COMMIT\`)" diff --git a/.github/workflows/scan.yml b/.github/workflows/scan.yml new file mode 100644 index 0000000..ab8d1a8 --- /dev/null +++ b/.github/workflows/scan.yml @@ -0,0 +1,81 @@ +name: Scan published image + +# Scans the published insectai/minio:latest for known vulnerabilities with Trivy: weekly, on demand, and after +# every successful publish from main. Findings rated CRITICAL or HIGH that have a fix available fail the run +# and appear under the repository's Security tab (code scanning). +# +# Trivy reads the Go module list embedded in the compiled minio and mc binaries, so it reports vulnerable Go +# dependencies and Go standard library versions, not only Alpine packages. For this image that is the main +# signal: most fixes arrive as a new upstream release or a newer GO_IMAGE, which the "Check for updates" +# workflow proposes. + +on: + schedule: + - cron: "0 6 * * 2" # Tuesdays 06:00 UTC + workflow_dispatch: + workflow_run: + workflows: ["Build and push image"] + types: [completed] + branches: [main] + +permissions: + contents: read + security-events: write + +env: + IMAGE_REF: insectai/minio:latest + +jobs: + scan: + # After a publish, only scan when the build succeeded. + if: github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' + runs-on: ubuntu-latest + steps: + # Pinned by commit: v0.36.0. + - name: Scan for the Security tab (SARIF) + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 + with: + scan-type: image + image-ref: ${{ env.IMAGE_REF }} + format: sarif + output: trivy-results.sarif + severity: CRITICAL,HIGH + ignore-unfixed: true + exit-code: "0" + + - name: Upload to code scanning + uses: github/codeql-action/upload-sarif@v4 + with: + sarif_file: trivy-results.sarif + category: trivy-published-image + + # Every input is repeated here because trivy-action carries some settings over between calls in one job. + - name: Scan and fail on CRITICAL or HIGH findings + id: table + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 + with: + scan-type: image + image-ref: ${{ env.IMAGE_REF }} + format: table + output: trivy-results.txt + severity: CRITICAL,HIGH + ignore-unfixed: true + exit-code: "1" + + - name: Write the findings to the run summary + if: always() + run: | + { + echo "## Trivy: \`$IMAGE_REF\` (CRITICAL and HIGH, fixable only)" + echo + if [ "${{ steps.table.outcome }}" = "success" ]; then + echo "No CRITICAL or HIGH findings with a fix available." + else + echo "Findings below. Fixes usually arrive as an upstream release or a newer GO_IMAGE/RUNTIME_IMAGE," + echo "which the \"Check for updates\" workflow proposes; it can also be run by hand." + fi + echo + echo '```' + cat trivy-results.txt 2>/dev/null || echo "(no table output; see the step log)" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/update.yml b/.github/workflows/update.yml new file mode 100644 index 0000000..6dec276 --- /dev/null +++ b/.github/workflows/update.yml @@ -0,0 +1,133 @@ +name: Check for updates + +# Weekly: asks scripts/check_upstream.py whether versions.env needs a change. A change is either a new +# upstream release (new pins, IMAGE_REVISION back to 1) or a rebuild of the current release (IMAGE_REVISION + 1) +# because a base image has a newer patch release (or, if rebuild_if_older_than_days is set, because the published +# image is older than that). The age rule is off by default: the runtime stage only copies files onto the pinned +# Alpine image, so a rebuild with unchanged pins produces the same image; newer Go or Alpine patch tags are what +# matter, and the script detects those on its own. +# +# When something changed, the new pins are built for linux/amd64 and smoke-tested here, and only then is a +# pull request opened on the update/versions branch. Merging that pull request publishes the image through +# the "Build and push image" workflow. Nothing is published from this workflow. +# +# Pull requests opened with the default GITHUB_TOKEN do not trigger other workflows, which is why the build +# and smoke test run inline. If the UPDATE_PR_TOKEN secret (a fine-grained token with contents and pull +# requests write access to this repository) is set, it is used instead and the normal PR checks run too. + +on: + schedule: + - cron: "0 6 * * 1" # Mondays 06:00 UTC + workflow_dispatch: + inputs: + force_rebuild: + description: Rebuild the current release with the next IMAGE_REVISION even if nothing changed + type: boolean + default: false + rebuild_if_older_than_days: + description: Also rebuild when the published image is older than this many days (0 = off) + type: number + default: 0 + +permissions: + contents: write + pull-requests: write + +concurrency: + group: update-versions + cancel-in-progress: false + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Check upstream + id: check + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Scheduled runs have no inputs; fall back to the defaults above. + OLDER_THAN: ${{ inputs.rebuild_if_older_than_days || 0 }} + FORCE: ${{ inputs.force_rebuild && 'true' || 'false' }} + run: | + args=(--write --github-output "$GITHUB_OUTPUT") + if [ "${OLDER_THAN:-0}" -gt 0 ]; then args+=(--rebuild-if-older-than "$OLDER_THAN"); fi + if [ "$FORCE" = "true" ]; then args+=(--force-rebuild); fi + python3 scripts/check_upstream.py "${args[@]}" + + - name: Summary (no change) + if: steps.check.outputs.changed != 'true' + run: | + echo "No update needed: pins are current and the published image is recent." >> "$GITHUB_STEP_SUMMARY" + + - name: Load the new pins + if: steps.check.outputs.changed == 'true' + run: grep -v '^#' versions.env | grep . >> "$GITHUB_ENV" + + - uses: docker/setup-buildx-action@v3 + if: steps.check.outputs.changed == 'true' + + - name: Build the new pins (linux/amd64, loaded locally) + if: steps.check.outputs.changed == 'true' + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64 + load: true + push: false + tags: insectai/minio:update-check + build-args: | + GO_IMAGE=${{ env.GO_IMAGE }} + RUNTIME_IMAGE=${{ env.RUNTIME_IMAGE }} + MINIO_REPO=${{ env.MINIO_REPO }} + MINIO_TAG=${{ env.MINIO_TAG }} + MINIO_COMMIT=${{ env.MINIO_COMMIT }} + MC_REPO=${{ env.MC_REPO }} + MC_TAG=${{ env.MC_TAG }} + MC_COMMIT=${{ env.MC_COMMIT }} + SOURCE_REVISION=${{ github.sha }} + provenance: false + sbom: false + cache-from: type=gha + + - name: Smoke test + if: steps.check.outputs.changed == 'true' + run: test/smoke.sh insectai/minio:update-check + + - name: Open or update the pull request + if: steps.check.outputs.changed == 'true' + id: pr + uses: peter-evans/create-pull-request@v7 + with: + token: ${{ secrets.UPDATE_PR_TOKEN || secrets.GITHUB_TOKEN }} + branch: update/versions + delete-branch: true + add-paths: versions.env + commit-message: | + chore: update pinned versions + + ${{ steps.check.outputs.title }} + title: ${{ steps.check.outputs.title }} + body: | + ${{ steps.check.outputs.body }} + + Built for linux/amd64 and smoke-tested in run ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} before this PR was opened. + + Merging this pull request publishes the image to Docker Hub (the "Build and push image" workflow pushes the revision tag, the release tag and `latest`). After that, update the digest pins in the repositories that use the image. + + - name: Summary (change) + if: steps.check.outputs.changed == 'true' + env: + TITLE: ${{ steps.check.outputs.title }} + PR_URL: ${{ steps.pr.outputs.pull-request-url }} + PR_OP: ${{ steps.pr.outputs.pull-request-operation }} + BODY: ${{ steps.check.outputs.body }} + run: | + { + echo "## $TITLE" + echo + echo "Built and smoke-tested. Pull request ($PR_OP): $PR_URL" + echo + echo "$BODY" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/Dockerfile b/Dockerfile index 688097f..edd0da3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -75,7 +75,12 @@ LABEL org.opencontainers.image.title="MinIO server and mc client (community buil org.insectai.mc.commit="${MC_COMMIT}" COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt COPY --from=build /out/ / +# The entrypoint creates MINIO_DEFAULT_BUCKETS at startup; the health check reports healthy only once the +# server is ready and those buckets exist. See README "Creating buckets at startup". +COPY --chmod=0755 docker-entrypoint.sh /usr/bin/docker-entrypoint.sh +COPY --chmod=0755 minio-healthcheck /usr/bin/minio-healthcheck EXPOSE 9000 9001 VOLUME ["/data"] -ENTRYPOINT ["/usr/bin/minio"] +HEALTHCHECK --interval=5s --timeout=5s --start-period=10s --retries=12 CMD ["/usr/bin/minio-healthcheck"] +ENTRYPOINT ["/usr/bin/docker-entrypoint.sh"] CMD ["server", "/data", "--console-address", ":9001"] diff --git a/README.md b/README.md index 7291c7d..b90f1e0 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,9 @@ The `insectai` organisation is a Docker-Sponsored Open Source namespace, so anon |---|---|---| | `/usr/bin/minio` | [pgsty/silo](https://github.com/pgsty/silo), the community-maintained fork of `minio/minio` | Built with the fork's own `gen-ldflags.go`, so `minio --version` reports the pinned release. | | `/usr/bin/mc` | [pgsty/mc](https://github.com/pgsty/mc), the matching fork of `minio/mc` | Same command set as upstream `mc` (`alias set`, `mb`, `anonymous set`, `ready`). | -| `/bin/sh` and BusyBox | Alpine base | Lets an init container run a shell script with `mc`. | +| `/usr/bin/docker-entrypoint.sh` | This repository | Starts the server and creates the buckets listed in `MINIO_DEFAULT_BUCKETS`. | +| `/usr/bin/minio-healthcheck` | This repository | The image's health check: healthy once the server is ready and those buckets exist. | +| `/bin/sh` and BusyBox | Alpine base | Runs the two scripts above, and lets you run your own shell scripts with `mc`. | | `/licenses/` | Copied from the source checkouts | AGPL-3.0 licence, NOTICE and CREDITS files. | The binaries keep their original names (`minio`, `mc`) so existing compose files and scripts work unchanged. The image runs as root by default, like the historical official image, and `/data` is world-writable so a non-root `user:` also works on a fresh volume. @@ -25,28 +27,103 @@ Exact source revisions are recorded in `versions.env` and in the image labels (` ## Using the image -Pin by tag and digest in compose files. The digest for each published tag is printed in the workflow run's summary. To look it up later, read the first `Digest:` line (the manifest list, which covers both platforms) from: +Every published build gets three tags: + +| Tag | Example | Moves? | +|---|---|---| +| `-r` | `insectai/minio:RELEASE.2026-09-16T00-00-00Z-r1` | Never. Each build of a release gets the next revision number (`IMAGE_REVISION` in `versions.env`), and a revision tag is never overwritten. | +| `` | `insectai/minio:RELEASE.2026-09-16T00-00-00Z` | Yes, to the newest build of that release (for example a rebuild with newer base images). | +| `latest` | `insectai/minio:latest` | Yes, to the newest build of the newest release. | + +The release is the server release tag. A new revision of the same release contains the same `minio` and `mc` source code, rebuilt with newer Go toolchain or Alpine base images; the revision restarts at 1 when the release changes. + +In compose files, pin by the revision tag and its digest: `insectai/minio:-r@sha256:`. The tag tells a reader what they are running, and the digest guarantees the bytes. The pin line for each build is printed in the workflow run's summary. To look up the digest later, read the first `Digest:` line (the manifest list, which covers both platforms) from: ```sh -docker buildx imagetools inspect insectai/minio:RELEASE.2026-09-16T00-00-00Z +docker buildx imagetools inspect insectai/minio:RELEASE.2026-09-16T00-00-00Z-r1 ``` ```yaml services: minio: - image: insectai/minio:RELEASE.2026-09-16T00-00-00Z@sha256: - command: server /data --console-address ":9001" + image: insectai/minio:RELEASE.2026-09-16T00-00-00Z-r1@sha256: + environment: + MINIO_ROOT_USER: minioadmin + MINIO_ROOT_PASSWORD: change-me-please + MINIO_DEFAULT_BUCKETS: media:public,backups healthcheck: - test: ["CMD", "mc", "ready", "local"] - minio-init: - image: insectai/minio:RELEASE.2026-09-16T00-00-00Z@sha256: - entrypoint: ["/bin/sh", "/etc/minio/init.sh"] + test: ["CMD", "minio-healthcheck"] + interval: 5s + retries: 12 + + app: + depends_on: + minio: + condition: service_healthy +``` + +The default command is `server /data --console-address ":9001"`, so the `command:` line can be left out. The `healthcheck:` block above only repeats the image's built-in health check with a shorter interval; it can also be left out. + +The client version is recorded in the `org.insectai.mc.tag` label. + +## Creating buckets at startup + +Set `MINIO_DEFAULT_BUCKETS` to a comma-separated list of buckets, each optionally followed by a colon and an anonymous-access policy: + +```sh +MINIO_DEFAULT_BUCKETS=media:public,uploads:upload,backups ``` -The image tag is the server release tag. The client version is recorded in the `org.insectai.mc.tag` label. +The policy is one of the values `mc anonymous set` accepts: `none`, `download` (anonymous read), `upload` (anonymous write) or `public` (anonymous read and write). A bucket without a policy is created private. The variable name and format are the same as in the Bitnami MinIO image, so this setting carries over from compose files written for that image. Bitnami's other variables (such as its port settings) are not supported. + +When the variable is set, the entrypoint starts the server, waits until it is ready, creates each missing bucket, applies its policy, and logs one line per bucket. Existing buckets and their contents are left alone, and the policy is applied again on every start. If any step fails (an invalid bucket name, an unknown policy, wrong credentials), the entrypoint stops the server and the container exits with a non-zero status, so a misconfiguration is visible immediately instead of surfacing later as missing buckets. The server stays the container's main process: `docker stop` is forwarded to it and the container's exit code is the server's. + +When the variable is not set, the entrypoint hands straight over to the server, so the image behaves exactly as it did before this feature existed. Any command other than `server ...` (for example `--version`) is passed to the `minio` binary unchanged, and `--entrypoint mc` still runs the client. + +### Health check + +The image declares a Docker `HEALTHCHECK` that runs `/usr/bin/minio-healthcheck`. It reports healthy only when the server answers its readiness probe and, if `MINIO_DEFAULT_BUCKETS` is set, the entrypoint has finished setting up the buckets (it writes a marker file, `/tmp/minio-default-buckets.ready`, as its last step). In compose, `depends_on: {minio: {condition: service_healthy}}` therefore means "the server is up and the buckets are in place", which replaces a separate init container. + +### Limits + +- The scripts talk to the server over plain HTTP on `127.0.0.1`. The API port is taken from the server's `--address` argument (for example `--address :9100`) and defaults to 9000. TLS on the API port is not supported by the bucket setup. +- The root credentials come from `MINIO_ROOT_USER` and `MINIO_ROOT_PASSWORD`. Credentials supplied only through files or other mechanisms are not read. +- The entrypoint waits up to 120 seconds for the server to become ready before giving up. Set `MINIO_DEFAULT_BUCKETS_TIMEOUT` (in seconds) to change this. + +## Keeping the image up to date + +Three scheduled workflows keep the image current. A person only reviews and merges pull requests; nothing is published without a merge. + +| When | Workflow | What it does | +|---|---|---| +| Mondays 06:00 UTC | Check for updates (`update.yml`) | Runs `scripts/check_upstream.py`. If pgsty/silo or pgsty/mc published a new release, it proposes the new pins with `IMAGE_REVISION=1`. Otherwise, if the Go or Alpine base image has a newer patch release, it proposes a rebuild of the same release with the next `IMAGE_REVISION`. (An age-based rebuild can be switched on with the `rebuild_if_older_than_days` input; it is off by default because the runtime stage only copies files onto the pinned Alpine image, so rebuilding unchanged pins produces the same image.) The proposed change is built for linux/amd64 and smoke-tested before the pull request is opened on the `update/versions` branch. | +| Tuesdays 06:00 UTC, and after every publish | Scan published image (`scan.yml`) | Scans `insectai/minio:latest` with Trivy. Findings rated CRITICAL or HIGH that have a fix available fail the run and are listed under the repository's Security tab. Trivy also reads the Go modules compiled into `minio` and `mc`, which is where most findings for this image come from. | +| Every merge to `main` | Build and push image (`build.yml`) | Publishes the three tags described in "Using the image". | + +What a person does: + +- **Update pull request:** read the old/new table and the linked upstream release notes, check that the build and smoke test passed (the run is linked in the pull request), and merge. Merging publishes the image. Then update the digest pins in the repositories that use it. +- **Failed scan:** open the run summary or the Security tab to see the affected package and the fixed version. A fix usually needs a new upstream release or a newer base image, which the update workflow proposes when one exists. If upstream has not fixed it yet, the finding stays open until it does. + +To force a rebuild of the current release now (for example after an Alpine security announcement), run: + +```sh +gh workflow run update.yml -f force_rebuild=true +``` + +Pull requests opened by a workflow with the default `GITHUB_TOKEN` do not trigger other workflows, so the update pull request does not get the normal pull-request build. That is why the update workflow builds and smoke-tests before opening it. To get the normal checks as well, add a repository secret `UPDATE_PR_TOKEN` holding a fine-grained personal access token with read and write access to contents and pull requests on this repository; the update workflow uses it when it is set. ## Bumping versions +The update workflow does steps 1 to 3 every week. The same script can be run locally to see what it would change (set `GITHUB_TOKEN` to avoid GitHub's anonymous rate limit; `--write` applies the change to `versions.env`): + +```sh +python3 scripts/check_upstream.py --dry-run +python3 scripts/check_upstream.py --force-rebuild --dry-run # what a rebuild would look like +``` + +The manual steps, for reference or when the script cannot decide (for example when a new Go minor needs a different Alpine minor): + 1. Find the new release tags on [pgsty/silo/releases](https://github.com/pgsty/silo/releases) and [pgsty/mc/releases](https://github.com/pgsty/mc/releases), and read their release notes for behaviour changes. 2. Resolve each tag to its commit (annotated tags need the second command): @@ -55,28 +132,31 @@ The image tag is the server release tag. The client version is recorded in the ` gh api repos/pgsty/silo/git/tags/ -q .object.sha ``` -3. Edit `versions.env`: `MINIO_TAG`, `MINIO_COMMIT`, `MC_TAG`, `MC_COMMIT`. Check the `go` directive in both `go.mod` files and raise `GO_IMAGE` if needed. Bump `RUNTIME_IMAGE` to the current Alpine patch release. +3. Edit `versions.env`: `MINIO_TAG`, `MINIO_COMMIT`, `MC_TAG`, `MC_COMMIT`, and set `IMAGE_REVISION=1`. Check the `go` directive in both `go.mod` files and raise `GO_IMAGE` if needed. Bump `RUNTIME_IMAGE` to the current Alpine patch release. To rebuild the same release instead, leave the tags alone and raise `IMAGE_REVISION` by one. 4. Build locally and smoke-test: ```sh ./build.sh - docker run --rm insectai/minio:dev --version - docker run --rm --entrypoint mc insectai/minio:dev --version + test/smoke.sh insectai/minio:dev ``` + The smoke test starts the image with and without `MINIO_DEFAULT_BUCKETS`, checks bucket creation, anonymous access, the health check, `--version` output and clean shutdown, and removes everything it created. The workflow runs the same script on every pull request. + 5. Open a pull request. The workflow builds both platforms without pushing. -6. Merge to `main`. The workflow pushes `insectai/minio:` and `insectai/minio:latest`, and prints the digest pin line in the run summary. +6. Merge to `main`. The workflow pushes `insectai/minio:-r`, `insectai/minio:` and `insectai/minio:latest`, and prints the digest pin line in the run summary. 7. Update the digest pins in the consuming repositories (for Antenna: `docker-compose.yml` and `docker-compose.ci.yml`). -To republish an existing version without moving `latest` (for example after a base-image security update), run the workflow manually with "Also move the latest tag" unchecked. +A revision tag is never overwritten: if `-r` already exists on Docker Hub, the workflow builds but pushes nothing. To publish a build without moving `latest`, raise `IMAGE_REVISION` and run the workflow manually with "Also move the latest tag" unchecked. ## Publishing setup -The workflow needs two repository secrets: +The build workflow needs two repository secrets: - `DOCKERHUB_USERNAME`: the Docker Hub account or organisation the token belongs to. - `DOCKERHUB_TOKEN`: an access token with read and write access to `insectai/minio`. Create it under the Docker Hub organisation settings (organisation access token) or as a personal access token of an organisation member. +The update workflow can optionally use `UPDATE_PR_TOKEN` (see "Keeping the image up to date"). The scan workflow needs no secrets. + ## Licence The build files in this repository are licensed under the GNU Affero General Public License v3.0 or later, the same licence as the software they package. The published image contains unmodified builds of AGPL-3.0 software from the repositories named in `versions.env`; the corresponding source for any published image is the tagged commit recorded in that file and in the image labels. MinIO is a trademark of MinIO, Inc.; the name is used here only to identify the upstream project and compatibility lineage. diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100755 index 0000000..2e6bbac --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,117 @@ +#!/bin/sh +# Container entrypoint: runs the MinIO server and, when MINIO_DEFAULT_BUCKETS is set, creates those +# buckets (and their anonymous-access policies) once the server is ready. This removes the need for a +# separate init container in compose stacks. +# +# MINIO_DEFAULT_BUCKETS="media:public,backups" # name[:policy], comma-separated +# +# The policy is one of mc's anonymous-access policies: none, download, upload, public. A bucket without a +# policy is created private. The variable name and format match the old Bitnami MinIO image. +# +# Anything other than `server ...` (for example `--version`) is passed straight to the minio binary. +# Without MINIO_DEFAULT_BUCKETS the server is exec'd directly, exactly as before this script existed. +# The API port is read from `--address` (default 9000) and shared with minio-healthcheck via a file. +set -eu + +MARKER=/tmp/minio-default-buckets.ready +PORT_FILE=/tmp/minio-api-port +READY_TIMEOUT="${MINIO_DEFAULT_BUCKETS_TIMEOUT:-120}" + +log() { echo "minio-default-buckets: $*"; } + +if [ "${1:-}" != "server" ]; then + exec /usr/bin/minio "$@" +fi + +# Find the API port from `--address HOST:PORT` or `--address=HOST:PORT`; the last occurrence wins. +address="" +prev="" +for arg in "$@"; do + case "$prev" in --address) address="$arg" ;; esac + case "$arg" in --address=*) address="${arg#--address=}" ;; esac + prev="$arg" +done +port=9000 +if [ -n "$address" ]; then + port="${address##*:}" +fi +case "$port" in + '' | *[!0-9]*) echo "docker-entrypoint.sh: cannot read a port from --address '$address'" >&2; exit 64 ;; +esac + +# A restarted container keeps /tmp, so clear the marker before the buckets are (re)checked. +rm -f "$MARKER" +echo "$port" > "$PORT_FILE" + +if [ -z "${MINIO_DEFAULT_BUCKETS:-}" ]; then + exec /usr/bin/minio "$@" +fi + +/usr/bin/minio "$@" & +server_pid=$! +trap 'kill -TERM "$server_pid" 2>/dev/null || true' TERM INT + +# Returns the server's exit status once it has exited, surviving waits interrupted by a trapped signal. +wait_for_server() { + status=0 + while :; do + wait "$server_pid" && status=0 || status=$? + kill -0 "$server_pid" 2>/dev/null || break + done + return "$status" +} + +fail() { + echo "minio-default-buckets: $*; stopping the server" >&2 + kill -TERM "$server_pid" 2>/dev/null || true + wait_for_server || true + exit 1 +} + +# A private mc config directory keeps credentials out of any ~/.mc the user may have mounted. +MC_CONFIG_DIR="$(mktemp -d /tmp/minio-entrypoint-mc.XXXXXX)" +export MC_CONFIG_DIR +endpoint="http://127.0.0.1:${port}" + +# `mc ready` retries forever, so bound each attempt and check the server is still running between them. +elapsed=0 +until MC_HOST_local="$endpoint" timeout 2 mc ready local >/dev/null 2>&1; do + if ! kill -0 "$server_pid" 2>/dev/null; then + wait_for_server && exit 0 || exit $? + fi + elapsed=$((elapsed + 3)) + [ "$elapsed" -lt "$READY_TIMEOUT" ] || fail "server not ready after ${READY_TIMEOUT}s" + sleep 1 +done + +mc alias set local "$endpoint" "${MINIO_ROOT_USER:-minioadmin}" "${MINIO_ROOT_PASSWORD:-minioadmin}" >/dev/null \ + || fail "could not authenticate to the server" + +old_ifs="$IFS" +IFS=, +for entry in $MINIO_DEFAULT_BUCKETS; do + IFS="$old_ifs" + entry="$(echo "$entry" | tr -d '[:space:]')" + [ -n "$entry" ] || continue + name="${entry%%:*}" + policy="" + case "$entry" in *:*) policy="${entry#*:}" ;; esac + case "$policy" in + '' | none | download | upload | public) ;; + *) fail "bucket '$name' has unknown policy '$policy' (use none, download, upload or public)" ;; + esac + mc mb --ignore-existing "local/$name" >/dev/null || fail "could not create bucket '$name'" + if [ -n "$policy" ]; then + mc anonymous set "$policy" "local/$name" >/dev/null || fail "could not set policy '$policy' on '$name'" + log "bucket '$name' ready (anonymous access: $policy)" + else + log "bucket '$name' ready" + fi +done +IFS="$old_ifs" + +rm -rf "$MC_CONFIG_DIR" +touch "$MARKER" +log "all buckets ready" + +wait_for_server && exit 0 || exit $? diff --git a/minio-healthcheck b/minio-healthcheck new file mode 100755 index 0000000..6cb7af1 --- /dev/null +++ b/minio-healthcheck @@ -0,0 +1,17 @@ +#!/bin/sh +# Health check for the image: succeeds only when the server answers its readiness probe and, if +# MINIO_DEFAULT_BUCKETS is set, the entrypoint has finished creating those buckets. Compose files can +# therefore use `condition: service_healthy` to mean "server up and buckets in place". +set -eu + +port="$(cat /tmp/minio-api-port 2>/dev/null || echo 9000)" + +# A throwaway config directory works whichever uid the container runs as. +export MC_CONFIG_DIR=/tmp/minio-healthcheck-mc + +# `mc ready` retries forever on its own, so bound it; the health check runs again on the next interval. +MC_HOST_local="http://127.0.0.1:${port}" timeout 3 mc ready local >/dev/null 2>&1 + +if [ -n "${MINIO_DEFAULT_BUCKETS:-}" ] && [ ! -f /tmp/minio-default-buckets.ready ]; then + exit 1 +fi diff --git a/scripts/check_upstream.py b/scripts/check_upstream.py new file mode 100755 index 0000000..16a859f --- /dev/null +++ b/scripts/check_upstream.py @@ -0,0 +1,445 @@ +#!/usr/bin/env python3 +"""Check upstream sources for new releases and decide whether versions.env needs a change. + +Run from anywhere; it reads and (with --write) rewrites the versions.env next to this repository's root. +It needs only the Python 3 standard library. Set GITHUB_TOKEN to avoid GitHub's anonymous rate limit. + + python3 scripts/check_upstream.py --dry-run + python3 scripts/check_upstream.py --force-rebuild --dry-run + python3 scripts/check_upstream.py --write --rebuild-if-older-than 30 --github-output "$GITHUB_OUTPUT" + +What it looks up: + +- The latest GitHub release of the server repository (MINIO_REPO) and the client repository (MC_REPO), + and the commit each release tag points to (annotated tags are dereferenced). +- The `go` directive in both repositories' go.mod at those commits. +- The newest golang:.-alpine image on Docker Hub, keeping the Alpine minor of + the current GO_IMAGE. The Go minor is raised only when a go.mod requires a newer one; otherwise the + newest patch of the current Go minor is used. +- The newest alpine:. image for the Alpine minor of the current RUNTIME_IMAGE. + +How it decides, in order: + +1. New release: if MINIO_TAG or MC_TAG differs from the latest upstream release, all source pins and both + base images move to the newest values and IMAGE_REVISION restarts at 1. +2. Rebuild: otherwise, the current release is rebuilt (IMAGE_REVISION + 1, plus any newer base-image + patches) when any of these holds: + - --force-rebuild is given; + - GO_IMAGE or RUNTIME_IMAGE has a newer patch release; + - --rebuild-if-older-than DAYS is given and the published tag -r (or, if that + tag does not exist yet, ) was pushed to Docker Hub more than DAYS ago. This is what turns a + weekly run into a roughly monthly rebuild that picks up Alpine package fixes. +3. Otherwise nothing changes. + +A release tag that now points to a different commit than the one pinned is treated as an error: tags are +expected to be immutable, and a moved tag needs a human to look at it. + +Exit status: 0 on success (whether or not a change is needed), 1 on any lookup failure or inconsistency. +""" + +from __future__ import annotations + +import argparse +import dataclasses +import datetime +import json +import os +import re +import sys +import typing +import urllib.error +import urllib.parse +import urllib.request +import uuid + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +VERSIONS_FILE = os.path.join(REPO_ROOT, "versions.env") +DEFAULT_IMAGE = "insectai/minio" +USER_AGENT = "minio-image-check-upstream" + +# Keys shown in the old/new table, in this order. +TRACKED_KEYS = [ + "MINIO_TAG", + "MINIO_COMMIT", + "MC_TAG", + "MC_COMMIT", + "GO_IMAGE", + "RUNTIME_IMAGE", + "IMAGE_REVISION", +] + + +class LookupFailed(Exception): + """An upstream API call failed or returned something this script cannot interpret.""" + + +# --------------------------------------------------------------------------------------------------------- +# HTTP helpers + + +def http_get_text(url: str, headers: dict[str, str] | None = None, allow_404: bool = False) -> str | None: + request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT, **(headers or {})}) + try: + with urllib.request.urlopen(request, timeout=30) as response: + return response.read().decode() + except urllib.error.HTTPError as error: + if error.code == 404 and allow_404: + return None + detail = error.read().decode(errors="replace")[:300] + hint = "" + if error.code in (403, 429) and "api.github.com" in url: + hint = " (GitHub rate limit? set GITHUB_TOKEN)" + raise LookupFailed(f"GET {url} returned HTTP {error.code}{hint}: {detail}") from error + except urllib.error.URLError as error: + raise LookupFailed(f"GET {url} failed: {error.reason}") from error + + +def http_get_json(url: str, headers: dict[str, str] | None = None, allow_404: bool = False): + body = http_get_text(url, headers, allow_404) + if body is None: + return None + try: + return json.loads(body) + except json.JSONDecodeError as error: + raise LookupFailed(f"GET {url} did not return JSON") from error + + +def github_get(path: str, raw: bool = False) -> typing.Any: + """GETs an api.github.com path; raw=True returns file contents as text instead of parsed JSON.""" + headers = { + "Accept": "application/vnd.github.raw" if raw else "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + } + token = os.environ.get("GITHUB_TOKEN") + if token: + headers["Authorization"] = f"Bearer {token}" + url = f"https://api.github.com/{path}" + result = http_get_text(url, headers) if raw else http_get_json(url, headers) + if result is None: + raise LookupFailed(f"GitHub API returned no content for {path}") + return result + + +# --------------------------------------------------------------------------------------------------------- +# GitHub lookups + + +def github_slug(repo_url: str) -> str: + """Turns https://github.com/owner/name.git into owner/name.""" + match = re.match(r"https://github\.com/([^/]+/[^/]+?)(?:\.git)?/?$", repo_url) + if not match: + raise LookupFailed(f"not a GitHub repository URL: {repo_url}") + return match.group(1) + + +@dataclasses.dataclass +class Release: + slug: str + tag: str + commit: str + url: str + go_version: tuple[int, int, int] + + +def resolve_tag_commit(slug: str, tag: str) -> str: + ref = github_get(f"repos/{slug}/git/ref/tags/{urllib.parse.quote(tag)}") + obj = ref["object"] + # Annotated tags point to a tag object, which points to the commit (possibly through further tags). + for _ in range(5): + if obj["type"] == "commit": + return obj["sha"] + if obj["type"] != "tag": + raise LookupFailed(f"{slug} tag {tag} points to a {obj['type']}, not a commit") + obj = github_get(f"repos/{slug}/git/tags/{obj['sha']}")["object"] + raise LookupFailed(f"{slug} tag {tag} is nested too deeply") + + +def go_directive(slug: str, commit: str) -> tuple[int, int, int]: + go_mod = github_get(f"repos/{slug}/contents/go.mod?ref={commit}", raw=True) + match = re.search(r"^go\s+(\d+)\.(\d+)(?:\.(\d+))?\s*$", go_mod, re.MULTILINE) + if not match: + raise LookupFailed(f"no go directive in {slug} go.mod at {commit}") + return int(match.group(1)), int(match.group(2)), int(match.group(3) or 0) + + +def latest_release(repo_url: str) -> Release: + slug = github_slug(repo_url) + release = github_get(f"repos/{slug}/releases/latest") + tag = release["tag_name"] + commit = resolve_tag_commit(slug, tag) + return Release(slug, tag, commit, release["html_url"], go_directive(slug, commit)) + + +# --------------------------------------------------------------------------------------------------------- +# Docker Hub lookups + + +def docker_hub_tag_names(repository: str, name_filter: str) -> list[str]: + url = ( + f"https://hub.docker.com/v2/repositories/{repository}/tags" + f"?page_size=100&name={urllib.parse.quote(name_filter)}" + ) + names: list[str] = [] + while url: + page = http_get_json(url) + names.extend(result["name"] for result in page.get("results", [])) + url = page.get("next") + return names + + +def newest_patch(names: list[str], pattern: str) -> tuple[int, str] | None: + """Returns (patch, tag) for the highest patch among tags matching pattern (group 1 = patch number).""" + best: tuple[int, str] | None = None + for name in names: + match = re.fullmatch(pattern, name) + if match and (best is None or int(match.group(1)) > best[0]): + best = (int(match.group(1)), name) + return best + + +def newest_go_image(current: str, required: tuple[int, int, int]) -> str: + match = re.fullmatch(r"golang:(\d+)\.(\d+)\.(\d+)-alpine(\d+\.\d+)", current) + if not match: + raise LookupFailed(f"GO_IMAGE {current!r} is not of the form golang:X.Y.Z-alpineA.B") + major, minor, patch = int(match.group(1)), int(match.group(2)), int(match.group(3)) + alpine = match.group(4) + if (major, minor) < required[:2]: + major, minor = required[:2] + min_patch = required[2] + else: + # Same minor: never go below the current patch, nor below what go.mod asks for. + min_patch = max(patch, required[2]) if (major, minor) == required[:2] else patch + prefix = f"{major}.{minor}." + names = docker_hub_tag_names("library/golang", prefix) + best = newest_patch(names, rf"{major}\.{minor}\.(\d+)-alpine{re.escape(alpine)}") + if best is None or best[0] < min_patch: + raise LookupFailed( + f"no golang:{major}.{minor}.N-alpine{alpine} image with N >= {min_patch} on Docker Hub; " + "update GO_IMAGE by hand (the Alpine minor may need to change)" + ) + return f"golang:{best[1]}" + + +def newest_runtime_image(current: str) -> str: + match = re.fullmatch(r"alpine:(\d+)\.(\d+)\.(\d+)", current) + if not match: + raise LookupFailed(f"RUNTIME_IMAGE {current!r} is not of the form alpine:X.Y.Z") + major, minor, patch = (int(group) for group in match.groups()) + names = docker_hub_tag_names("library/alpine", f"{major}.{minor}.") + best = newest_patch(names, rf"{major}\.{minor}\.(\d+)") + if best is None or best[0] < patch: + return current + return f"alpine:{best[1]}" + + +def published_at(image: str, tag: str) -> datetime.datetime | None: + info = http_get_json( + f"https://hub.docker.com/v2/repositories/{image}/tags/{urllib.parse.quote(tag)}", allow_404=True + ) + if info is None: + return None + stamp = info.get("tag_last_pushed") or info.get("last_updated") + if not stamp: + return None + return datetime.datetime.fromisoformat(stamp.replace("Z", "+00:00")) + + +# --------------------------------------------------------------------------------------------------------- +# versions.env + + +def read_versions(path: str) -> tuple[list[str], dict[str, str]]: + with open(path) as handle: + lines = handle.read().splitlines() + values: dict[str, str] = {} + for line in lines: + if line and not line.startswith("#") and "=" in line: + key, value = line.split("=", 1) + values[key.strip()] = value.strip() + return lines, values + + +def write_versions(path: str, lines: list[str], new_values: dict[str, str]) -> None: + """Rewrites only the KEY=value lines whose value changed; comments and order are kept.""" + out = [] + for line in lines: + if line and not line.startswith("#") and "=" in line: + key = line.split("=", 1)[0].strip() + if key in new_values: + line = f"{key}={new_values[key]}" + out.append(line) + with open(path, "w") as handle: + handle.write("\n".join(out) + "\n") + + +# --------------------------------------------------------------------------------------------------------- +# Decision + + +@dataclasses.dataclass +class Decision: + changed: bool + kind: str # "release", "rebuild" or "none" + title: str + reasons: list[str] + new_values: dict[str, str] + links: list[str] + + +def decide(values: dict[str, str], args: argparse.Namespace) -> Decision: + for key in ("MINIO_REPO", "MC_REPO", *TRACKED_KEYS): + if key not in values: + raise LookupFailed(f"versions.env has no {key}") + revision = int(values["IMAGE_REVISION"]) + + server = latest_release(values["MINIO_REPO"]) + client = latest_release(values["MC_REPO"]) + required_go = max(server.go_version, client.go_version) + go_image = newest_go_image(values["GO_IMAGE"], required_go) + runtime_image = newest_runtime_image(values["RUNTIME_IMAGE"]) + + links = [ + f"Server release: {server.url}", + f"Client release: {client.url}", + ] + new = dict(values) + new["GO_IMAGE"], new["RUNTIME_IMAGE"] = go_image, runtime_image + + if server.tag != values["MINIO_TAG"] or client.tag != values["MC_TAG"]: + new.update( + MINIO_TAG=server.tag, + MINIO_COMMIT=server.commit, + MC_TAG=client.tag, + MC_COMMIT=client.commit, + IMAGE_REVISION="1", + ) + reasons = [] + if server.tag != values["MINIO_TAG"]: + reasons.append(f"New server release {server.tag} in {server.slug}.") + if client.tag != values["MC_TAG"]: + reasons.append(f"New client release {client.tag} in {client.slug}.") + title = f"Update to server {server.tag} and client {client.tag}" + if server.tag == client.tag: + title = f"Update to {server.tag}" + return Decision(True, "release", title, reasons, new, links) + + # Same tags: the pinned commits must still match, or a tag moved upstream. + for label, release, key in (("server", server, "MINIO_COMMIT"), ("client", client, "MC_COMMIT")): + if release.commit != values[key]: + raise LookupFailed( + f"{label} tag {release.tag} in {release.slug} now points to {release.commit}, " + f"but versions.env pins {values[key]}; check upstream before rebuilding" + ) + + reasons = [] + if args.force_rebuild: + reasons.append("A rebuild was requested (--force-rebuild).") + if go_image != values["GO_IMAGE"]: + reasons.append(f"Newer Go toolchain image {go_image}.") + if runtime_image != values["RUNTIME_IMAGE"]: + reasons.append(f"Newer Alpine runtime image {runtime_image}.") + if args.rebuild_if_older_than is not None: + tag = f"{values['MINIO_TAG']}-r{revision}" + pushed = published_at(args.image, tag) + if pushed is None: + tag = values["MINIO_TAG"] + pushed = published_at(args.image, tag) + if pushed is None: + print(f"note: {args.image}:{tag} is not on Docker Hub, so its age is unknown", file=sys.stderr) + else: + age = datetime.datetime.now(datetime.timezone.utc) - pushed + if age > datetime.timedelta(days=args.rebuild_if_older_than): + reasons.append( + f"{args.image}:{tag} was pushed {age.days} days ago " + f"(threshold {args.rebuild_if_older_than} days), so base-image package fixes may be missing." + ) + + if not reasons: + return Decision(False, "none", "No update needed", ["Pins are current."], dict(values), links) + + new["IMAGE_REVISION"] = str(revision + 1) + title = f"Rebuild {values['MINIO_TAG']} as r{revision + 1}" + return Decision(True, "rebuild", title, reasons, new, links) + + +def change_table(old: dict[str, str], new: dict[str, str]) -> str: + rows = ["| Setting | Old | New |", "|---|---|---|"] + for key in TRACKED_KEYS: + marker = "" if old[key] == new[key] else " (changed)" + rows.append(f"| `{key}`{marker} | `{old[key]}` | `{new[key]}` |") + return "\n".join(rows) + + +def markdown_body(decision: Decision, old: dict[str, str], image: str) -> str: + new = decision.new_values + parts = [ + "Why:", + "", + *(f"- {reason}" for reason in decision.reasons), + "", + change_table(old, new), + ] + if decision.changed: + parts += [ + "", + f"New image tags: `{image}:{new['MINIO_TAG']}-r{new['IMAGE_REVISION']}` (immutable), " + f"`{image}:{new['MINIO_TAG']}` and `{image}:latest`.", + ] + parts += [ + "", + "Upstream:", + "", + *(f"- {link}" for link in decision.links), + ] + if decision.kind == "release": + parts += ["", "Read both release notes for behaviour changes before merging."] + return "\n".join(parts) + + +def write_github_output(path: str, decision: Decision, body: str) -> None: + delimiter = f"EOF_{uuid.uuid4().hex}" + with open(path, "a") as handle: + handle.write(f"changed={'true' if decision.changed else 'false'}\n") + handle.write(f"kind={decision.kind}\n") + handle.write(f"title={decision.title}\n") + handle.write(f"body<<{delimiter}\n{body}\n{delimiter}\n") + + +def main() -> int: + parser = argparse.ArgumentParser(description=(__doc__ or "").splitlines()[0]) + mode = parser.add_mutually_exclusive_group() + mode.add_argument("--write", action="store_true", help="rewrite versions.env in place") + mode.add_argument("--dry-run", action="store_true", help="only report the decision (default)") + parser.add_argument( + "--rebuild-if-older-than", type=int, metavar="DAYS", help="rebuild if the published image is older" + ) + parser.add_argument("--force-rebuild", action="store_true", help="bump IMAGE_REVISION even if nothing changed") + parser.add_argument("--github-output", metavar="PATH", help="append changed/kind/title/body outputs to this file") + parser.add_argument("--image", default=DEFAULT_IMAGE, help=f"Docker Hub repository (default {DEFAULT_IMAGE})") + parser.add_argument("--versions-file", default=VERSIONS_FILE, help=argparse.SUPPRESS) + args = parser.parse_args() + + try: + lines, values = read_versions(args.versions_file) + decision = decide(values, args) + except (LookupFailed, ValueError, KeyError) as error: + print(f"error: {error}", file=sys.stderr) + return 1 + + body = markdown_body(decision, values, args.image) + print(f"Decision: {decision.title}") + print() + print(body) + + if decision.changed and args.write: + write_versions(args.versions_file, lines, decision.new_values) + print(f"\nWrote {args.versions_file}") + elif decision.changed: + print("\nDry run: versions.env not modified (use --write).") + if args.github_output: + write_github_output(args.github_output, decision, body) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/test/smoke.sh b/test/smoke.sh new file mode 100755 index 0000000..564ab85 --- /dev/null +++ b/test/smoke.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env sh +# Smoke test for a built image: startup bucket creation, anonymous access policies, the health check, +# clean shutdown, and unchanged behaviour when MINIO_DEFAULT_BUCKETS is not set. +# +# ./build.sh && test/smoke.sh insectai/minio:dev +# +# Needs only Docker. Every container and network it creates is removed on exit. +set -eu + +IMAGE="${1:?usage: test/smoke.sh IMAGE_REF}" +cd "$(dirname "$0")/.." +EXPECTED_TAG="$(sed -n 's/^MINIO_TAG=//p' versions.env)" + +run_id="minio-smoke-$$" +net="$run_id-net" +user=smokeadmin +password=smoke-secret-password + +cleanup() { + docker rm -f "$run_id-buckets" "$run_id-plain" "$run_id-badpolicy" >/dev/null 2>&1 || true + docker network rm "$net" >/dev/null 2>&1 || true +} +trap cleanup EXIT + +pass() { echo "PASS: $*"; } +fail() { echo "FAIL: $*" >&2; exit 1; } + +# Waits until the container's health status is `healthy`; fails on `unhealthy`, exit, or a 90s timeout. +wait_healthy() { + i=0 + while [ "$i" -lt 90 ]; do + state="$(docker inspect -f '{{.State.Status}} {{.State.Health.Status}}' "$1")" + case "$state" in + "running healthy") return 0 ;; + *unhealthy | exited* | dead*) docker logs "$1" >&2; fail "$1 is '$state'" ;; + esac + i=$((i + 1)) + sleep 1 + done + docker logs "$1" >&2 + fail "$1 not healthy after 90s (last state '$state')" +} + +# Runs a shell snippet in a throwaway client container on the test network. +client() { + docker run --rm --network "$net" --entrypoint sh \ + -e SMOKE_USER="$user" -e SMOKE_PASSWORD="$password" "$IMAGE" -c "$1" +} + +docker network create "$net" >/dev/null + +echo "== version" +version="$(docker run --rm "$IMAGE" --version)" +echo "$version" | head -1 +echo "$version" | grep -q "${EXPECTED_TAG}" || fail "minio --version does not report $EXPECTED_TAG" +pass "minio --version reports $EXPECTED_TAG through the entrypoint" +docker run --rm --entrypoint mc "$IMAGE" --version >/dev/null || fail "--entrypoint mc no longer works" +pass "--entrypoint mc still works" + +echo "== startup buckets" +srv="$run_id-buckets" +docker run -d --name "$srv" --network "$net" --network-alias minio \ + -e MINIO_ROOT_USER="$user" -e MINIO_ROOT_PASSWORD="$password" \ + -e MINIO_DEFAULT_BUCKETS="smoke-public:public, smoke-private" \ + "$IMAGE" >/dev/null +wait_healthy "$srv" +pass "container with MINIO_DEFAULT_BUCKETS became healthy" +docker logs "$srv" 2>&1 | grep "minio-default-buckets:" +docker logs "$srv" 2>&1 | grep -q "$password" && fail "the root password appears in the container log" +pass "the root password is not in the container log" + +# shellcheck disable=SC2016 # expanded inside the client container, not here +client ' +set -eu +mc alias set s http://minio:9000 "$SMOKE_USER" "$SMOKE_PASSWORD" >/dev/null +mc ls s +mc ls s | grep -q "smoke-public/" +mc ls s | grep -q "smoke-private/" +echo public-object > /tmp/o && mc cp -q /tmp/o s/smoke-public/o.txt >/dev/null && mc cp -q /tmp/o s/smoke-private/o.txt >/dev/null +mc anonymous get s/smoke-public | grep -q "is .public.$" +mc anonymous get s/smoke-private | grep -q "is .private.$" +' || fail "buckets missing or policies wrong" +pass "both buckets exist with the expected policies" + +# shellcheck disable=SC2016 # expanded inside the client container, not here +client ' +set -eu +test "$(wget -qO- http://minio:9000/smoke-public/o.txt)" = public-object +if wget -qO- http://minio:9000/smoke-private/o.txt >/dev/null 2>&1; then exit 1; fi +' || fail "anonymous access does not match the policies" +pass "anonymous GET works on smoke-public and is denied on smoke-private" + +echo "== restart keeps buckets and re-runs setup" +docker restart "$srv" >/dev/null +wait_healthy "$srv" +pass "container became healthy again after a restart" + +echo "== clean shutdown" +start="$(date +%s)" +docker stop "$srv" >/dev/null +elapsed=$(($(date +%s) - start)) +code="$(docker inspect -f '{{.State.ExitCode}}' "$srv")" +echo "docker stop took ${elapsed}s, exit code $code" +[ "$elapsed" -lt 10 ] || fail "docker stop needed the kill timeout (SIGTERM not forwarded)" +[ "$code" = 0 ] || fail "server exited with $code after SIGTERM" +pass "SIGTERM reaches the server and the container exits 0" + +echo "== bad policy fails fast" +bad="$run_id-badpolicy" +docker run -d --name "$bad" --network "$net" \ + -e MINIO_ROOT_USER="$user" -e MINIO_ROOT_PASSWORD="$password" \ + -e MINIO_DEFAULT_BUCKETS="smoke-bad:everyone" "$IMAGE" >/dev/null +timeout 60 docker wait "$bad" >/dev/null || fail "container with a bad policy kept running" +code="$(docker inspect -f '{{.State.ExitCode}}' "$bad")" +[ "$code" != 0 ] || fail "container with a bad policy exited 0" +docker logs "$bad" 2>&1 | grep "unknown policy" || fail "no error message for the bad policy" +pass "a bad policy stops the container with exit code $code" + +echo "== no MINIO_DEFAULT_BUCKETS (unchanged behaviour)" +plain="$run_id-plain" +docker run -d --name "$plain" --network "$net" \ + -e MINIO_ROOT_USER="$user" -e MINIO_ROOT_PASSWORD="$password" "$IMAGE" >/dev/null +wait_healthy "$plain" +pass "container without MINIO_DEFAULT_BUCKETS became healthy" +[ "$(docker exec "$plain" cat /proc/1/comm)" = minio ] || fail "PID 1 is not the minio server" +pass "the server is PID 1 (entrypoint exec'd it directly)" + +echo "All smoke tests passed for $IMAGE" diff --git a/versions.env b/versions.env index f8121d9..8eb4a21 100644 --- a/versions.env +++ b/versions.env @@ -1,4 +1,5 @@ -# Pinned sources for the image. Bump these four lines together (see README "Bumping versions"). +# Pinned sources for the image. scripts/check_upstream.py updates this file (see README "Bumping versions"). +# Only full-line comments: the workflows load every non-comment line into the environment. # Server: PGSTY Silo, the maintained community fork of the archived MinIO server. MINIO_REPO=https://github.com/pgsty/silo.git MINIO_TAG=RELEASE.2026-09-16T00-00-00Z @@ -10,3 +11,7 @@ MC_COMMIT=e952aa78f10a2b77dd525a2b7e3143bcda0cd377 # Toolchain and runtime base. Keep GO_IMAGE at or above the `go` directive in both go.mod files. GO_IMAGE=golang:1.27.1-alpine3.24 RUNTIME_IMAGE=alpine:3.24.2 +# Build number of this image for the pinned MINIO_TAG. Published as insectai/minio:-r, +# a tag that is never overwritten. Raise it by one to republish the same release (for example with newer +# base images); reset it to 1 when MINIO_TAG changes. +IMAGE_REVISION=1