Add tested standalone distroless Flood variant
This commit is contained in:
1 parent
8b6742f484
commit
66e87558ce
5 files changed
+236
No files matched your search
@@ -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 }}
|
||||
@@ -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/"]
|
||||
@@ -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.
|
||||
@@ -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}'
|
||||
@@ -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});
|
||||
}
|
||||
})();
|
||||
Reference in new issue
Block a user