initial commit: ai generated all the things
Some checks failed
Build Cronicle image / build (push) Failing after 29s
Some checks failed
Build Cronicle image / build (push) Failing after 29s
This commit is contained in:
commit
e75cc7468a
4 changed files with 430 additions and 0 deletions
114
.forgejo/workflows/build.yaml
Normal file
114
.forgejo/workflows/build.yaml
Normal file
|
|
@ -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 }}
|
||||
129
Dockerfile
Normal file
129
Dockerfile
Normal file
|
|
@ -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=<x.y.z> 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", "--"]
|
||||
116
README.md
Normal file
116
README.md
Normal file
|
|
@ -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 `<cronicle-version>-<short-sha-of-this-repo>`.
|
||||
|
||||
## 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.
|
||||
71
test/newline-regression.js
Normal file
71
test/newline-regression.js
Normal file
|
|
@ -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 <container> <cmd>` -- 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);
|
||||
Loading…
Add table
Reference in a new issue