From e75cc7468a5f0f760157e15a551821d96fdc4d3e Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Tue, 11 Aug 2026 17:36:02 -0700 Subject: [PATCH] initial commit: ai generated all the things --- .forgejo/workflows/build.yaml | 114 ++++++++++++++++++++++++++++++ Dockerfile | 129 ++++++++++++++++++++++++++++++++++ README.md | 116 ++++++++++++++++++++++++++++++ test/newline-regression.js | 71 +++++++++++++++++++ 4 files changed, 430 insertions(+) create mode 100644 .forgejo/workflows/build.yaml create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 test/newline-regression.js diff --git a/.forgejo/workflows/build.yaml b/.forgejo/workflows/build.yaml new file mode 100644 index 0000000..041ce25 --- /dev/null +++ b/.forgejo/workflows/build.yaml @@ -0,0 +1,114 @@ +name: Build Cronicle image + +on: + workflow_dispatch: + # Nightly upstream check. 13:00 UTC / 6AM Pacific, deliberately offset from + # aws-zig's 12:30 nightly because the runner has capacity: 1. + schedule: + - cron: '0 13 * * *' + push: + branches: + - master + +env: + # Hardcoded rather than derived from ${{ github.repository }}: the packaging + # repo is cronicle-docker, but the image it produces is plain Cronicle. + IMAGE: git.lerch.org/lobo/cronicle + +jobs: + build: + runs-on: ubuntu-latest + container: + image: ghcr.io/catthehacker/ubuntu:act-22.04 + steps: + - name: Check out repository code + uses: actions/checkout@v4 + + # Upstream publishes a GitHub Release per tag, so releases/latest is the + # authoritative "newest stable" pointer. Cronicle is marked + # "private": true in package.json, so npm is not an option -- the + # Dockerfile builds from the release tarball. + - name: Resolve latest upstream Cronicle version + id: upstream + run: | + set -euo pipefail + version="$(curl -fsSL https://api.github.com/repos/jhuckaby/Cronicle/releases/latest \ + | jq -r '.tag_name // empty' \ + | sed 's/^v//')" + if [ -z "$version" ]; then + echo "Could not resolve upstream Cronicle version" >&2 + exit 1 + fi + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "Upstream latest release: v$version" + + - name: Compute image tags + id: tags + run: | + set -euo pipefail + shortsha="$(git rev-parse --short HEAD)" + echo "shortsha=$shortsha" >> "$GITHUB_OUTPUT" + echo "immutable=${{ steps.upstream.outputs.version }}-$shortsha" >> "$GITHUB_OUTPUT" + + - name: Login to Gitea + uses: docker/login-action@v3 + with: + registry: git.lerch.org + username: ${{ github.actor }} + password: ${{ secrets.PACKAGE_PUSH }} + + # Set up before the existence check below, which uses buildx. + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + # The immutable tag encodes BOTH the upstream version and the packaging + # commit, so this single check covers both reasons to rebuild: a new + # Cronicle release, or a change to this repo. Nightly runs that find + # nothing new become a no-op with no state file to maintain. + # + # `buildx imagetools inspect` rather than `docker manifest inspect`: + # Forgejo's registry serves OCI media types + # (application/vnd.oci.image.manifest.v1+json) and authenticates via the + # Bearer token flow. imagetools handles both natively, whereas + # `docker manifest inspect` fails outright on OCI manifests with older + # docker CLIs ("unsupported manifest media type and no default + # available"). imagetools reuses the credentials login-action just wrote. + - name: Skip if this exact image was already built + id: check + run: | + set -uo pipefail + tag="${IMAGE}:${{ steps.tags.outputs.immutable }}" + if docker buildx imagetools inspect "$tag" >/dev/null 2>&1; then + echo "exists=true" >> "$GITHUB_OUTPUT" + echo "$tag is already published; nothing to do." + else + echo "exists=false" >> "$GITHUB_OUTPUT" + echo "$tag not found; building." + fi + + # Three tags: one immutable (what deployments should pin), two moving. + - name: Build and push + if: steps.check.outputs.exists == 'false' + uses: docker/build-push-action@v6 + with: + context: . + push: true + build-args: | + CRONICLE_VERSION=${{ steps.upstream.outputs.version }} + tags: | + ${{ env.IMAGE }}:${{ steps.tags.outputs.immutable }} + ${{ env.IMAGE }}:${{ steps.upstream.outputs.version }} + ${{ env.IMAGE }}:latest + + # Only ping when a build was actually attempted -- a nightly "nothing to + # do" notification 365 times a year is noise. Uses != 'true' rather than + # == 'false' so that a failure *before* the check step still notifies. + - name: Notify + uses: https://git.lerch.org/lobo/action-notify-ntfy@v2 + if: always() && steps.check.outputs.exists != 'true' + with: + host: ${{ secrets.NTFY_HOST }} + topic: ${{ secrets.NTFY_TOPIC }} + status: ${{ job.status }} + user: ${{ secrets.NTFY_USER }} + password: ${{ secrets.NTFY_PASSWORD }} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2b1acc5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,129 @@ +# Vanilla upstream Cronicle, packaged as a container image. +# +# Why this exists: the "official" cronicle/cronicle image is published by the +# cronicle-edge project and has not been rebuilt since 2025-02-24 (:latest == +# :0.9.74) while upstream Cronicle ships roughly monthly. See README.md. +# +# Deliberately reproduces the layout of cronicle/cronicle:0.9.74 (recovered via +# `docker history`) so this is a drop-in replacement. Deviations are called out +# in comments below. + +ARG ALPINE_VERSION=3.22 +FROM alpine:${ALPINE_VERSION} + +# Cronicle release to build, WITHOUT the leading "v" (e.g. 0.9.126). +# CI resolves this from upstream's latest GitHub release. +ARG CRONICLE_VERSION + +# Upstream's exact package list, intentionally unmodified. This image is the +# runtime for every Shell Script job, so the package set is effectively the job +# environment -- trimming it to save a few MB risks breaking job scripts. +# Notably: +# procps - Cronicle shells out to a real `ps` for job CPU/memory +# monitoring; busybox ps is not sufficient. +# tini - reaps zombies left by job child processes (PID 1 duties). +# jq - used directly by at least one job script. +# util-linux, coreutils, bash - job scripts expect GNU/util-linux behaviour +# rather than busybox applets. +RUN apk add --no-cache \ + acl \ + bash \ + coreutils \ + curl \ + git \ + jq \ + nodejs \ + npm \ + openssl \ + procps \ + tar \ + tini \ + util-linux + +# Matches upstream's image env. +# +# CRONICLE_foreground / CRONICLE_echo are pixl-config env overrides (the +# CRONICLE_ prefix maps to config keys); together they keep the process in the +# foreground and stream the log to stdout, which is what makes `docker logs` +# useful. +# +# /usr/local/bin is on PATH because deployments commonly bind-mount a docker +# client there for jobs that drive sibling containers. +# +# DEVIATION: upstream also sets TZ=America/New_York. Omitted on purpose -- this +# image defaults to UTC, and the deployment sets TZ explicitly. +ENV CRONICLE_foreground=1 \ + CRONICLE_echo=1 \ + EDITOR=vi \ + PATH=/opt/cronicle/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin + +RUN addgroup cronicle --gid 1099 \ + && adduser -D -h /opt/cronicle -u 1000 -G cronicle cronicle + +WORKDIR /opt/cronicle + +RUN test -n "$CRONICLE_VERSION" || { \ + echo "ERROR: --build-arg CRONICLE_VERSION= is required" >&2; \ + exit 1; \ + } + +# Built from the GitHub release tarball rather than npm: upstream marked the +# package "private": true as of v0.9.126, so it is not published to the +# registry. -f makes a bad/missing tag fail loudly instead of piping an HTML +# error page into tar. +# +# `busybox tar`, not GNU tar, and deliberately so. Alpine 3.22's GNU tar +# restores directory modes via the fchmodat2() syscall. Docker's default seccomp +# profile denies syscalls it does not know about by returning EPERM, and +# releases before ~25.0 predate fchmodat2 -- so on an older daemon GNU tar +# reports "Cannot change mode ...: Operation not permitted" for every directory +# and then exits 2, failing the build. The files do all extract, but the exit +# code is fatal and the directory modes are left unset. Verified: identical +# extraction under `--security-opt seccomp=unconfined` produces zero errors. +# Busybox tar uses plain fchmodat(), is unaffected, exits 0, and reproduces the +# archive's modes exactly. GNU tar is still installed above for runtime parity. +RUN curl -fsSL "https://github.com/jhuckaby/Cronicle/archive/refs/tags/v${CRONICLE_VERSION}.tar.gz" -o /tmp/cronicle.tar.gz \ + && busybox tar xzf /tmp/cronicle.tar.gz --strip-components 1 -C /opt/cronicle \ + && rm /tmp/cronicle.tar.gz + +# `npm ci`, not upstream's `npm install`, so the build is reproducible from the +# lockfile committed in the release tarball. +# +# --ignore-scripts skips the root package's "postinstall": "pixl-boot install", +# which registers Cronicle as a systemd/init service -- meaningless in a +# container. No *dependency* declares an install script (verified against the +# lockfile), so nothing else is being skipped, and no build toolchain is needed. +# +# --omit=dev drops pixl-unit, the only devDependency, which is otherwise dead +# weight in the runtime image. bin/build.js needs only async, pixl-tools and +# uglify-js, all of which are regular dependencies. +RUN npm ci --ignore-scripts --omit=dev + +# Bundles and minifies the front end into htdocs/js/_combo.js. Required; the UI +# 404s on its assets without it. +RUN node bin/build dist + +# Regression gate -- see test/newline-regression.js for the full explanation. +# Lives inside /opt/cronicle so node resolves pixl-json-stream from the real +# installed tree rather than a copy. +COPY test/newline-regression.js /opt/cronicle/newline-regression.js +RUN node /opt/cronicle/newline-regression.js \ + && rm /opt/cronicle/newline-regression.js + +# Bind mounts normally land on these; create them with tight modes so a missing +# mount does not silently expose job data or secrets. +RUN mkdir -p /opt/cronicle/data /opt/cronicle/conf \ + && chmod 0700 /opt/cronicle/data /opt/cronicle/conf + +LABEL org.opencontainers.image.title="Cronicle" \ + org.opencontainers.image.description="Vanilla upstream Cronicle, built from the GitHub release tarball" \ + org.opencontainers.image.version="${CRONICLE_VERSION}" \ + org.opencontainers.image.url="https://github.com/jhuckaby/Cronicle" \ + org.opencontainers.image.source="https://git.lerch.org/lobo/cronicle-docker" \ + org.opencontainers.image.licenses="MIT" + +# DEVIATION: upstream also COPYs bin/manager and bin/worker into the image. +# Those are cronicle-edge helper entrypoints; vanilla Cronicle is started with +# `control.sh start`, so they are omitted. No CMD is set, matching upstream -- +# the caller supplies it. +ENTRYPOINT ["/sbin/tini", "--"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..9e1f293 --- /dev/null +++ b/README.md @@ -0,0 +1,116 @@ +# cronicle-docker + +Container image for [vanilla upstream Cronicle](https://github.com/jhuckaby/Cronicle), +built from the GitHub release tarball. + +Produces `git.lerch.org/lobo/cronicle`. + +## Why this repo exists + +The `cronicle/cronicle` image on Docker Hub is effectively abandoned: `:latest` +is `:0.9.74`, last pushed 2025-02-24, while upstream Cronicle releases roughly +monthly. It is published by the [cronicle-edge](https://github.com/cronicle-edge/cronicle-edge) +project, whose own fork (`cronicle/edge`) ships regularly -- the "classic" +variant just stopped getting rebuilt. + +The surveyed alternatives were all worse for this purpose: + +| Image | Cronicle version | Last built | Notes | +| --- | --- | --- | --- | +| `cronicle/cronicle` | 0.9.74 | 2025-02-24 | vanilla, abandoned tag | +| `soulteary/cronicle` | 0.9.80 | 2025-06-02 | vanilla, stale | +| `cronicle/edge` | fork v1.14.x | current | actively maintained, but a fork | +| `intelliops`, `bluet`, `nicholasamorim` | 0.8.x | 2019-2021 | ancient | + +Staying on vanilla keeps the MIT license, the existing on-disk data format, and +local-time log annotation. This repo is the minimum needed to do that: the +Dockerfile mirrors `cronicle/cronicle:0.9.74`'s layout (recovered from +`docker history`), with deviations documented inline. + +## Concrete problem it solved + +Cronicle v0.9.74's `package-lock.json` pins `pixl-json-stream` **1.0.9**, which +drops the trailing newline from the last complete line of every read chunk. +Because `bin/shell-plugin.js` appends the line verbatim and relies on the +library for the terminator, job logs silently concatenate: + +``` +[2026/08/11 14:00:02] EDGAR company ticker map okinfo(...): provider data lag ... +``` + +Upstream fixed the library in 1.0.10, and Cronicle v0.9.126's lockfile pins it. +Building current releases therefore fixes the bug -- but since a *lockfile* +delivered it in the first place, `test/newline-regression.js` runs during +`docker build` and fails the build if it ever comes back. See that file for the +full write-up. + +## Build + +`CRONICLE_VERSION` is required and takes no leading `v`: + +```sh +docker build --build-arg CRONICLE_VERSION=0.9.126 -t cronicle:local . +``` + +## Tags + +CI publishes three tags per build: + +| Tag | Mutability | Use | +| --- | --- | --- | +| `0.9.126-a1b2c3d` | immutable | **pin this in deployments** | +| `0.9.126` | moves | latest build of that Cronicle release | +| `latest` | moves | latest build of anything | + +The immutable tag is `-`. + +## CI + +`.forgejo/workflows/build.yaml` runs on push to `master`, on manual dispatch, +and nightly at 13:00 UTC. + +It resolves upstream's latest release, then checks whether the immutable tag +already exists in the registry and skips the build if so. Because that tag +encodes both the upstream version and this repo's commit, a rebuild is triggered +by either a new Cronicle release or a change here -- with no state file to keep +in sync. + +Note: the nightly does **not** rebuild for base-image security patches alone, +since nothing about the tag would change. Force one with a manual dispatch or an +empty commit if a relevant Alpine CVE lands. + +## Running + +No `CMD` is set, matching upstream. Start it with `control.sh start`: + +```sh +docker run -d --name cronicle \ + -h cronicle-master \ + -e TZ="America/Los_Angeles" \ + -v /data/cronicle/data:/opt/cronicle/data \ + -v /data/cronicle/conf:/opt/cronicle/conf \ + git.lerch.org/lobo/cronicle:0.9.126-a1b2c3d control.sh start +``` + +The image defaults to **UTC**, unlike `cronicle/cronicle` which hardcoded +`America/New_York`. Set `TZ` explicitly. + +`CRONICLE_foreground=1` and `CRONICLE_echo=1` are baked in, so the process stays +in the foreground and streams its log to `docker logs`. + +### Behind a reverse proxy + +If the proxy sets `X-Forwarded-Proto: https`, Cronicle reports its `https_port` +in `/api/app/config`, and the live log watcher will try to reach a worker +directly on that port -- which fails when the address is a container-internal IP. +Set `custom_live_log_socket_url` at the **top level** of `config.json` (not +inside `client`, where it is silently overwritten by the server-side default): + +```json +"custom_live_log_socket_url": "https://cronicle.example.org" +``` + +Cronicle polls `config.json` every 10 seconds and hot-reloads it, so this +particular key takes effect without a restart. That is inherited +`pixl-server` behaviour and applies only to values read per-request; anything +consumed at startup (bound ports, storage engine) still needs a restart. diff --git a/test/newline-regression.js b/test/newline-regression.js new file mode 100644 index 0000000..7a36eba --- /dev/null +++ b/test/newline-regression.js @@ -0,0 +1,71 @@ +// Build-time regression gate for the job-log line-concatenation bug. +// +// THE BUG +// +// pixl-json-stream 1.0.9 emitted the last complete line of each read chunk +// WITHOUT a trailing newline: +// +// var text = record + ((idx < len - 1) ? self.EOL : ''); +// +// Cronicle's bin/shell-plugin.js appends whatever the 'text' event hands it, +// verbatim, and relies on the library to supply the terminator: +// +// fs.appendFileSync(job.log_file, line); +// +// So whenever a chunk boundary landed mid-line, the preceding complete line +// lost its newline and the next line was glued onto it. Output arriving over a +// pipe -- e.g. a job running `docker exec ` -- splits mid-line +// constantly, so in practice most of the log ran together: +// +// [2026/08/11 14:00:02] EDGAR company ticker map okinfo(...): provider ... +// +// 1.0.10 restored the unconditional `record + self.EOL`. +// +// WHY THIS FILE EXISTS +// +// This was not a build-time resolution accident that "npm ci" alone prevents. +// Upstream's own package-lock.json pinned the broken version: +// +// Cronicle v0.9.74 -> pixl-json-stream 1.0.9 (broken) +// Cronicle v0.9.126 -> pixl-json-stream 1.0.10 (fixed) +// +// A lockfile delivered the bug, so a lockfile can deliver it again. The failure +// mode is silent: jobs still succeed, logs are just quietly unreadable. Running +// this during `docker build` converts that into a hard build failure. + +const JSONStream = require('pixl-json-stream'); +const { PassThrough } = require('stream'); + +const input = new PassThrough(); +const stream = new JSONStream(input, new PassThrough()); + +let log = ''; +stream.on('text', (line) => { + log += line; +}); + +// Chunk boundaries deliberately fall mid-line ("tw" + "o"), reproducing what a +// real pipe does. A library that only terminates non-final records will drop +// the newline after "one" and after "three". +input.write('one\ntw'); +input.write('o\nthree\nfou'); +input.write('r\n'); + +// 'text' is emitted from the stream's 'data' handler, so let the event loop +// drain before asserting. +setTimeout(() => { + const want = 'one\ntwo\nthree\nfour\n'; + + if (log !== want) { + console.error('FAIL: pixl-json-stream is dropping line terminators.'); + console.error(' got: ' + JSON.stringify(log)); + console.error(' want: ' + JSON.stringify(want)); + console.error(''); + console.error('Cronicle job logs would silently concatenate lines.'); + console.error('See the comment at the top of this file. Refusing to build.'); + process.exit(1); + } + + const version = require('pixl-json-stream/package.json').version; + console.log('OK: job log line terminators intact (pixl-json-stream ' + version + ')'); +}, 200);