Add tested standalone distroless Flood variant

This commit is contained in:
Ketan Patel committed 2026-09-30 21:51:46 -04:00
1 parent 8b6742f484
commit 66e87558ce
5 files changed
+236

No files matched your search

+71
View File
@@ -0,0 +1,71 @@
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 }}
+27
View File
@@ -0,0 +1,27 @@
# 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/"]
+20
View File
@@ -33,3 +33,23 @@ publishing; it does not start a torrent daemon.
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.
## 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.
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env python3
"""Assemble an Alpine-derived, shell-free runtime from an explicit ELF allowlist."""
import os
from pathlib import Path
import shutil
import subprocess
import sys
root = Path(sys.argv[1])
root.mkdir(parents=True, exist_ok=True)
copied = set()
def copy(source):
source = Path(os.path.normpath(source))
if source in copied:
return
copied.add(source)
target = root / str(source).lstrip('/')
target.parent.mkdir(parents=True, exist_ok=True)
if source.is_symlink():
link = os.readlink(source)
if not target.is_symlink():
target.symlink_to(link)
# Preserve every hop, not just the final file in a multi-link chain.
copy(link if os.path.isabs(link) else source.parent / link)
elif source.is_dir():
target.mkdir(exist_ok=True)
for child in source.iterdir():
copy(child)
else:
shutil.copy2(source, target)
for executable in sys.argv[2:]:
result = subprocess.run(['lddtreepax', '-l', executable], check=True, text=True, capture_output=True)
paths = result.stdout.splitlines()
if not paths or any(not p.startswith('/') or not Path(p).exists() for p in paths):
raise SystemExit(f'Unresolved runtime dependency for {executable}: {result.stdout}')
copy(executable)
for path in paths:
copy(path)
# musl uses this file for nonstandard library directories (for example Lua).
for search_path in Path('/etc').glob('ld-musl-*.path'):
copy(search_path)
for data in ['/etc/ssl/certs/ca-certificates.crt', '/etc/ssl/cert.pem',
'/etc/ssl/openssl.cnf', '/usr/lib/ossl-modules', '/usr/share/zoneinfo',
'/etc/terminfo', '/usr/share/terminfo', '/etc/services', '/etc/protocols',
'/etc/alpine-release', '/etc/os-release']:
if Path(data).exists():
copy(data)
for directory in ['tmp', 'root', 'myconfig', 'home/nfs_download/rsession', 'server/db', 'config', 'etc']:
(root / directory).mkdir(parents=True, exist_ok=True)
os.chmod(root / 'tmp', 0o1777)
(root / 'root/.rtorrent.rc').touch()
(root / 'etc/passwd').write_text('root:x:0:0:root:/root:/sbin/nologin\n')
(root / 'etc/group').write_text('root:x:0:\n')
for forbidden in ['bin/sh', 'bin/bash', 'bin/busybox', 'sbin/apk', 'usr/bin/apk',
'usr/bin/npm', 'usr/local/bin/npm', 'usr/bin/python3']:
assert not (root / forbidden).exists(), f'Unexpected runtime tool: {forbidden}'
+57
View File
@@ -0,0 +1,57 @@
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});
}
})();