diff --git a/.gitea/workflows/build-distroless.yml b/.gitea/workflows/build-distroless.yml deleted file mode 100644 index 62e88d0..0000000 --- a/.gitea/workflows/build-distroless.yml +++ /dev/null @@ -1,71 +0,0 @@ -name: Build Distroless Flood - -on: - workflow_dispatch: - inputs: - flood_version: - description: 'jesec/flood release tag' - required: true - default: 'v4.16.2' - type: string - -jobs: - build: - runs-on: kube - if: github.ref == 'refs/heads/main' - env: - DOCKER_BUILD_RECORD_UPLOAD: 'false' - DOCKER_BUILD_SUMMARY: 'false' - steps: - - name: Checkout code - uses: https://github.com/actions/checkout@v4 - with: - persist-credentials: false - - - name: Fetch Flood release - env: - FLOOD_VERSION: ${{ github.event.inputs.flood_version }} - run: | - test -n "$FLOOD_VERSION" || { echo "flood_version is required" >&2; exit 1; } - git check-ref-format "refs/tags/$FLOOD_VERSION" - git init flood-repo - git -C flood-repo fetch --depth=1 https://github.com/jesec/flood.git "refs/tags/$FLOOD_VERSION" - git -C flood-repo checkout --detach FETCH_HEAD - cp Dockerfile.distroless flood-repo/Dockerfile.distroless - cp build/runtime-root.py flood-repo/.ci-runtime-root.py - cp tests/startup-distroless.cjs flood-repo/.ci-startup-distroless.cjs - printf '\n.git\n.github\n.env\n' >> flood-repo/.dockerignore - - - name: Install Docker CLI - run: | - apt-get update - apt-get install -y --no-install-recommends docker.io - rm -rf /var/lib/apt/lists/* - - - name: Log in to Docker Hub - uses: https://github.com/docker/login-action@v3 - with: - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_TOKEN }} - - - name: Set up temporary builder - uses: https://github.com/docker/setup-buildx-action@v3 - with: - driver: docker-container - cache-binary: false - keep-state: false - cleanup: true - - - name: Build and push to Docker Hub - uses: https://github.com/docker/build-push-action@v6 - with: - context: ./flood-repo - file: ./flood-repo/Dockerfile.distroless - platforms: linux/amd64 - pull: true - push: true - load: false - tags: docker.io/k2patel/floodui:distroless - labels: | - org.opencontainers.image.source=https://git.k2patel.in/k2patel/docker-flood-client - org.opencontainers.image.revision=${{ github.sha }} diff --git a/.gitea/workflows/build-push.yml b/.gitea/workflows/build-push.yml index fb9dc20..f12efe6 100644 --- a/.gitea/workflows/build-push.yml +++ b/.gitea/workflows/build-push.yml @@ -1,4 +1,4 @@ -name: Build and Push Custom Flood Image +name: Build and Push Distroless Flood on: workflow_dispatch: @@ -32,6 +32,7 @@ jobs: git -C flood-repo fetch --depth=1 https://github.com/jesec/flood.git "refs/tags/$FLOOD_VERSION" git -C flood-repo checkout --detach FETCH_HEAD cp Dockerfile.client flood-repo/Dockerfile.client + cp build/runtime-root.py flood-repo/.ci-runtime-root.py cp tests/startup.cjs flood-repo/.ci-startup.cjs printf '\n.git\n.github\n.env\n' >> flood-repo/.dockerignore diff --git a/Dockerfile.client b/Dockerfile.client index 8433cd6..ece3727 100644 --- a/Dockerfile.client +++ b/Dockerfile.client @@ -1,55 +1,27 @@ -# Flood UI only: connects to an external torrent service. No bundled rTorrent. -# Build from a clean upstream release checkout using the Gitea workflow. - -ARG BUILDPLATFORM=amd64 +# Alpine-built binaries and musl stay together; no shell/package manager in final image. ARG NODE_IMAGE=docker.io/node:24.18.1-alpine3.24 - -FROM --platform=$BUILDPLATFORM ${NODE_IMAGE} as nodebuild - -WORKDIR /usr/src/app/ - -# Copy project files +FROM ${NODE_IMAGE} AS nodebuild +WORKDIR /usr/src/app COPY . ./ - -# Use the package manager and lockfile declared by the upstream release. -RUN npm install -g corepack && corepack enable && corepack install -RUN pnpm install --frozen-lockfile - -# Build Flood server and client assets. +RUN npm install -g corepack && corepack enable && corepack install \ + && pnpm install --frozen-lockfile RUN npm run build -# Now get the clean Node.js image -FROM ${NODE_IMAGE} as flood +FROM ${NODE_IMAGE} AS runtime +RUN apk add --no-cache mediainfo coreutils ca-certificates tzdata \ + lddtreepax python3 +COPY .ci-runtime-root.py /build/runtime-root.py +RUN python3 /build/runtime-root.py /runtime /usr/local/bin/node \ + /usr/bin/mediainfo "$(command -v df)" +COPY --from=nodebuild /usr/src/app/dist /runtime/usr/src/app/dist +COPY --from=nodebuild /usr/src/app/package.json /runtime/usr/src/app/package.json +COPY --from=nodebuild /usr/src/app/LICENSE /runtime/usr/src/app/LICENSE -WORKDIR /usr/src/app/ - -# Copy sources -COPY --from=nodebuild /usr/src/app ./ - -# Install runtime dependencies -RUN apk --no-cache add \ - mediainfo - -# Create "download" user -# RUN adduser -h /home/download -s /sbin/nologin --disabled-password download - -# Run as "download" user -# USER download - -# Expose port 3000 and 4200 +FROM scratch +COPY --from=runtime /runtime/ / +ENV NODE_ENV=production HOME=/root PATH=/usr/local/bin:/usr/bin:/bin +WORKDIR /usr/src/app +RUN --mount=type=bind,from=nodebuild,source=/usr/src/app/.ci-startup.cjs,target=/ci-startup.cjs ["/usr/local/bin/node", "/ci-startup.cjs"] +VOLUME ["/server/db"] EXPOSE 3000 -EXPOSE 4200 - -# Creating mountpoint for config -RUN mkdir -p /server/db/ -RUN apk update -RUN apk add coreutils - -# System local configuration -VOLUME /server/db/ - -# Fail the build before publication if the actual server cannot start. -RUN node .ci-startup.cjs && rm .ci-startup.cjs - -# Start Flood only, using the upstream v4.16.2 production entrypoint. -ENTRYPOINT ["npm", "--prefix=/usr/src/app/", "run", "start", "--", "--host=0.0.0.0", "--rundir=/server/"] +ENTRYPOINT ["/usr/local/bin/node", "--enable-source-maps", "--use_strict", "dist/index.js", "--host=0.0.0.0", "--rundir=/server/"] diff --git a/Dockerfile.distroless b/Dockerfile.distroless deleted file mode 100644 index 43817f6..0000000 --- a/Dockerfile.distroless +++ /dev/null @@ -1,27 +0,0 @@ -# Alpine-built binaries and musl stay together; no shell/package manager in final image. -ARG NODE_IMAGE=docker.io/node:24.18.1-alpine3.24 -FROM ${NODE_IMAGE} AS nodebuild -WORKDIR /usr/src/app -COPY . ./ -RUN npm install -g corepack && corepack enable && corepack install \ - && pnpm install --frozen-lockfile -RUN npm run build - -FROM ${NODE_IMAGE} AS runtime -RUN apk add --no-cache mediainfo coreutils ca-certificates tzdata \ - lddtreepax python3 -COPY .ci-runtime-root.py /build/runtime-root.py -RUN python3 /build/runtime-root.py /runtime /usr/local/bin/node \ - /usr/bin/mediainfo "$(command -v df)" -COPY --from=nodebuild /usr/src/app/dist /runtime/usr/src/app/dist -COPY --from=nodebuild /usr/src/app/package.json /runtime/usr/src/app/package.json -COPY --from=nodebuild /usr/src/app/LICENSE /runtime/usr/src/app/LICENSE - -FROM scratch -COPY --from=runtime /runtime/ / -ENV NODE_ENV=production HOME=/root PATH=/usr/local/bin:/usr/bin:/bin -WORKDIR /usr/src/app -RUN --mount=type=bind,from=nodebuild,source=/usr/src/app/.ci-startup-distroless.cjs,target=/ci-startup.cjs ["/usr/local/bin/node", "/ci-startup.cjs"] -VOLUME ["/server/db"] -EXPOSE 3000 -ENTRYPOINT ["/usr/local/bin/node", "--enable-source-maps", "--use_strict", "dist/index.js", "--host=0.0.0.0", "--rundir=/server/"] diff --git a/README.md b/README.md index 2f8f86d..08351e4 100644 --- a/README.md +++ b/README.md @@ -1,55 +1,38 @@ # docker-flood-client -Flood client image. Source: https://git.k2patel.in/k2patel/docker-flood-client +Source: https://git.k2patel.in/k2patel/docker-flood-client -Public access is read-only; account registration on this server is disabled. -Report issues at https://github.com/k2patel/docker-flood-client/issues. -For contributions, open an issue with a link to your branch or patch on another -Git provider. Changes are reviewed and applied by the maintainer on Gitea. +`docker.io/k2patel/floodui:latest` is the only maintained image variant and is +an Alpine/musl distroless runtime. The separate `:distroless` tag is retired. +It runs the Flood web UI/API and connects to an external torrent service; +it does not bundle or start rTorrent. -Gitea Actions publishes `docker.io/k2patel/floodui:latest` only on manual runs. -Select `main` in Actions → Build and Push Custom Flood Image → Run workflow -and set the required `flood_version` release tag. The configured release is -`v4.16.2`. Repository pushes do not trigger builds. +## Build and publication -This is a Flood-only image using the custom `Dockerfile.client`: it runs the -Flood web UI/API and connects to an external torrent service. It does not bundle -or start rTorrent. The Dockerfile uses the upstream pnpm lockfile and builds the server and client -assets; changing release families may require updating the build tooling. +Gitea Actions publishes only on manual runs. Select `main` in Actions → +**Build and Push Distroless Flood** → Run workflow and set the required +`flood_version` release tag (default `v4.16.2`). Pushes do not trigger builds. +The upstream pnpm lockfile builds the server and UI; switching release families +may require changes to the build tooling. -Set Actions secrets `DOCKER_USERNAME` and `DOCKER_TOKEN` with Docker Hub write -access to `k2patel/floodui`. Builds run remotely on the Kubernetes runner for -`linux/amd64`, pushing directly to Docker Hub. Images are not loaded into the -runner image store, and the temporary builder and cache are removed afterward. -No image archives or build records are uploaded to Gitea, and no local container -builds are required. +The workflow uses `Dockerfile.client`, Node 24.18.1 on Alpine 3.24, and a scratch +final image. It includes Node, the bundled server/UI, mediainfo, GNU df, their +musl libraries, CA certificates and timezone data. There is no shell, npm/pnpm, +package manager, source tree, node_modules or compiler in the runtime. +Node starts directly with `--host=0.0.0.0 --rundir=/server/`. -Base image: Node.js 24.18.1 on Alpine Linux 3.24 stable (`node:24.18.1-alpine3.24`), -with the Alpine stable series explicitly selected. +CI starts Flood in the stripped filesystem and requires HTTP 200, working +mediainfo/df, and absence of shell/package-manager binaries before publication. +Builds use the Kubernetes runner for `linux/amd64`, pushing directly to Docker +Hub; temporary builders and caches are removed afterward. Configure Actions +secrets `DOCKER_USERNAME` and `DOCKER_TOKEN` with write access to `k2patel/floodui`. -The image build starts the actual Flood server and requires HTTP 200 before -publishing; it does not start a torrent daemon. +## Updating an existing deployment -The entrypoint uses `npm run start`, matching upstream v4.16.2, while retaining -`--host=0.0.0.0` and `--rundir=/server/`. The upstream combined rTorrent stage -is deliberately excluded. +Use `k2patel/floodui:latest`, pull, and recreate the service. Keep existing +connection settings and mounts. Root UID, port 3000 and `/server/db` are unchanged. +The default Compose file in docker-rtorrent uses distroless `:latest` for both +services and supplies rTorrent's native health check; no overlay is needed. -## Distroless variant - -The manual **Build Distroless Flood** workflow publishes `k2patel/floodui:distroless` -for the selected Flood release (default `v4.16.2`). It does not change `latest`. - -The Alpine 3.24-derived runtime contains Node 24.18.1, the bundled Flood server/UI, -mediainfo, GNU df, their musl libraries, CA certificates and timezone data. The -final image is built from scratch, with no shell, npm/pnpm, package manager, -node_modules, source tree or build tools. It starts Node directly and remains -separate from rTorrent. Root UID, port 3000, and `/server/db` stay compatible. - -Before publication, CI starts Flood inside the exact stripped filesystem and -requires an HTTP 200 response; it also checks mediainfo and df can execute and -that shell/package-manager binaries are absent. All builds run remotely, and -the temporary builder/cache is removed afterward. - -Change only the Compose image tag to `k2patel/floodui:distroless`; keep existing -mounts and connection settings. Revert to `latest` to roll back. A matching -rTorrent Compose overlay is provided in the docker-rtorrent repository. +Public access is read-only; account registration is disabled. Maintainer-applied +contributions can be based on a branch or patch against the current Gitea source. diff --git a/tests/startup-distroless.cjs b/tests/startup-distroless.cjs deleted file mode 100644 index 8b340d3..0000000 --- a/tests/startup-distroless.cjs +++ /dev/null @@ -1,57 +0,0 @@ -const {spawn, execFileSync} = require('node:child_process'); -const http = require('node:http'); -const fs = require('node:fs'); -const os = require('node:os'); -const path = require('node:path'); -for (const file of ['/bin/sh', '/bin/bash', '/bin/busybox', '/sbin/apk', '/usr/local/bin/npm']) { - if (fs.existsSync(file)) throw new Error(`Unexpected runtime tool: ${file}`); -} -execFileSync('mediainfo', ['--Version'], {stdio: 'inherit'}); -execFileSync('df', ['--version'], {stdio: 'inherit'}); -const runDir = fs.mkdtempSync(path.join(os.tmpdir(), 'flood-smoke-')); -const child = spawn(process.execPath, ['--enable-source-maps', '--use_strict', 'dist/index.js', - '--host=127.0.0.1', '--port=3000', `--rundir=${runDir}`], - {cwd: '/usr/src/app', stdio: 'inherit', detached: true}); -let startupError; -child.on('error', error => { startupError = error; }); -const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); -const check = () => new Promise(resolve => { - const req = http.get('http://127.0.0.1:3000/', response => { - let bytes = 0; - response.on('data', chunk => { bytes += chunk.length; }); - response.on('end', () => resolve(response.statusCode === 200 && bytes > 0)); - }); - req.setTimeout(1000, () => req.destroy()); - req.on('error', () => resolve(false)); -}); -(async () => { - try { - const deadline = Date.now() + 60000; - while (Date.now() < deadline) { - if (startupError) throw startupError; - if (child.exitCode !== null || child.signalCode !== null) { - throw new Error(`Flood exited before serving HTTP: ${child.exitCode ?? child.signalCode}`); - } - if (await check()) { - console.log('Flood startup smoke test passed: HTTP 200 with response body'); - return; - } - await sleep(500); - } - throw new Error('Flood did not serve HTTP within 60 seconds'); - } catch (error) { - console.error(error); - process.exitCode = 1; - } finally { - if (child.pid) { - try { process.kill(-child.pid, 'SIGTERM'); } catch (error) { - if (error.code !== 'ESRCH') throw error; - } - await sleep(500); - try { process.kill(-child.pid, 'SIGKILL'); } catch (error) { - if (error.code !== 'ESRCH') throw error; - } - } - fs.rmSync(runDir, {recursive: true, force: true}); - } -})(); diff --git a/tests/startup.cjs b/tests/startup.cjs index 1916001..8b340d3 100644 --- a/tests/startup.cjs +++ b/tests/startup.cjs @@ -1,12 +1,17 @@ -const {spawn} = require('node:child_process'); +const {spawn, execFileSync} = require('node:child_process'); const http = require('node:http'); const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); +for (const file of ['/bin/sh', '/bin/bash', '/bin/busybox', '/sbin/apk', '/usr/local/bin/npm']) { + if (fs.existsSync(file)) throw new Error(`Unexpected runtime tool: ${file}`); +} +execFileSync('mediainfo', ['--Version'], {stdio: 'inherit'}); +execFileSync('df', ['--version'], {stdio: 'inherit'}); const runDir = fs.mkdtempSync(path.join(os.tmpdir(), 'flood-smoke-')); -const child = spawn('npm', ['run', 'start', '--', +const child = spawn(process.execPath, ['--enable-source-maps', '--use_strict', 'dist/index.js', '--host=127.0.0.1', '--port=3000', `--rundir=${runDir}`], - {stdio: 'inherit', detached: true}); + {cwd: '/usr/src/app', stdio: 'inherit', detached: true}); let startupError; child.on('error', error => { startupError = error; }); const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));