Skip to content

Commit 1e1ef1c

Browse files
rvalitovSamNet-dev
authored andcommitted
ci: add a PR test pipeline across distros and init systems (#153)
- Add .github/workflows/ci.yml with syntax/lint, unit test matrix (Debian, Ubuntu, Alpine, Fedora), and systemd/OpenRC integration jobs. - Add tests/run-all.sh aggregate test runner with quarantine support. - Add tests/integration/autostart_systemd.sh. - Add tests/integration/autostart_openrc.sh. Closes #153
1 parent 3050614 commit 1e1ef1c

4 files changed

Lines changed: 740 additions & 0 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
name: CI
2+
3+
# Runs on every pull request that touches shell code. The workflow deliberately requests
4+
# no secrets and a read-only token: it executes code from pull requests, including forks,
5+
# so it uses `pull_request` (never `pull_request_target`) and grants nothing beyond
6+
# reading the repository.
7+
on:
8+
push:
9+
branches: [main]
10+
paths:
11+
- '**.sh'
12+
- 'tests/**'
13+
- '.github/workflows/ci.yml'
14+
pull_request:
15+
paths:
16+
- '**.sh'
17+
- 'tests/**'
18+
- '.github/workflows/ci.yml'
19+
workflow_dispatch:
20+
21+
permissions:
22+
contents: read
23+
24+
concurrency:
25+
group: ci-${{ github.workflow }}-${{ github.ref }}
26+
cancel-in-progress: true
27+
28+
jobs:
29+
lint:
30+
name: Syntax and lint
31+
runs-on: ubuntu-latest
32+
timeout-minutes: 10
33+
steps:
34+
- uses: actions/checkout@v6
35+
36+
# Pure parser check — reports the file and line of any syntax error without
37+
# executing anything. This cannot false-positive, so it is a hard gate.
38+
- name: Parse every shell script
39+
run: |
40+
set -euo pipefail
41+
for f in mtproxymax.sh install.sh tests/*.sh tests/integration/*.sh; do
42+
bash -n "$f"
43+
echo "ok $f"
44+
done
45+
46+
# Errors only. A 19k-line script carries a large backlog of style warnings, and
47+
# gating on those on day one would make this job permanently red and therefore
48+
# ignored. Warnings are still worth having locally: run `shellcheck mtproxymax.sh`
49+
# without -S to see them. Tighten this to `warning` once the backlog is worked down.
50+
- name: shellcheck (errors only)
51+
run: |
52+
shellcheck -S error mtproxymax.sh install.sh tests/*.sh tests/integration/*.sh
53+
54+
unit-tests:
55+
name: Unit tests (${{ matrix.image }})
56+
runs-on: ubuntu-latest
57+
timeout-minutes: 15
58+
strategy:
59+
# Report every distro rather than stopping at the first one that fails — the
60+
# interesting signal is which platforms differ.
61+
fail-fast: false
62+
matrix:
63+
# The install command is per-image and explicit rather than auto-detected: some
64+
# images ship bash but still lack tools the suite needs (fedora:41 has bash but no
65+
# `diff`, which silently failed one assertion until it was added here).
66+
#
67+
# Alpine deliberately installs ONLY bash. Adding coreutils/diffutils would shadow
68+
# busybox and hide exactly the differences this row exists to catch.
69+
#
70+
# `quarantine` lists tests already known to fail on that image. They still run and
71+
# their failures are printed, but they do not fail the build. Every entry here is a
72+
# real, unfixed defect — delete the entry when the underlying bug is fixed.
73+
include:
74+
- image: debian:12
75+
install: 'apt-get update -qq && apt-get install -y -qq bash diffutils'
76+
quarantine: 'test_client_mss.sh'
77+
- image: ubuntu:22.04
78+
install: 'apt-get update -qq && apt-get install -y -qq bash diffutils'
79+
quarantine: 'test_client_mss.sh'
80+
- image: ubuntu:24.04
81+
install: 'apt-get update -qq && apt-get install -y -qq bash diffutils'
82+
quarantine: 'test_client_mss.sh'
83+
# Alpine carries three known failures, all busybox divergences on a platform the
84+
# README lists as supported. Each is fixed by an open PR — remove the entry once
85+
# that PR has merged:
86+
# test_client_mss.sh — broken on every distro, not a platform difference: it
87+
# asserts on the stdout of generate_telemt_config, which
88+
# takes a destination path and writes
89+
# ${CONFIG_DIR}/config.toml instead. Fixed by #145.
90+
# test_traffic_reset.sh — busybox `flock` has no -w, so `flock -w 5 9` fails
91+
# while `command -v flock` succeeds, so the guard never
92+
# fires. Two call sites failed OPEN and silently wrote
93+
# nothing. Fixed by #146.
94+
# test_guest.sh — `date -d "+24 hours"` is invalid on busybox and the
95+
# `date -r <epoch>` fallback fails as well (busybox -r
96+
# means reference file), so expiring guest links got no
97+
# expiry. Fixed by #148.
98+
#
99+
# Leaving an entry in place after its fix has landed is harmless — a quarantined
100+
# test that passes is reported as PASS — so this list is correct in any merge
101+
# order. run-all.sh flags such entries as "! still listed as quarantined" so a
102+
# stale one is visible rather than silently ignored.
103+
- image: alpine:3.20
104+
install: 'apk add --no-cache bash'
105+
quarantine: 'test_client_mss.sh,test_traffic_reset.sh,test_guest.sh'
106+
- image: fedora:41
107+
install: 'dnf install -y -q bash diffutils'
108+
quarantine: 'test_client_mss.sh'
109+
steps:
110+
- uses: actions/checkout@v6
111+
112+
# Each distro runs the same suite in a container. Alpine is the row that earns its
113+
# keep: its busybox userland (sed, grep, mktemp, date) differs from GNU, and Alpine
114+
# is a documented supported platform that has never been exercised by CI.
115+
#
116+
# Note this uses `docker run` rather than the job-level `container:` key: JavaScript
117+
# actions such as actions/checkout run with a `node` binary *inside* the job
118+
# container, and the runner does not inject one, so a `container:` job on these
119+
# images fails before it can install anything.
120+
- name: Run suite in ${{ matrix.image }}
121+
run: |
122+
docker run --rm -v "$PWD:/src" -w /src \
123+
-e MTPROXYMAX_QUARANTINE="${{ matrix.quarantine }}" \
124+
"${{ matrix.image }}" sh -c '
125+
set -e
126+
${{ matrix.install }}
127+
bash tests/run-all.sh
128+
'
129+
130+
systemd-integration:
131+
name: Init integration (systemd)
132+
# ubuntu-24.04 is a full VM with systemd as PID 1 and Docker already installed, so the
133+
# generated unit's `Requires=docker.service` resolves against the real unit. Running
134+
# this in a container instead would need --privileged --cgroupns=host, and a systemd
135+
# container is not a faithful enough substitute for the real thing.
136+
runs-on: ubuntu-24.04
137+
timeout-minutes: 10
138+
steps:
139+
- uses: actions/checkout@v6
140+
141+
- name: Confirm the runner really is systemd
142+
# If this ever prints something other than "systemd", the job below would be
143+
# testing nothing, so make that visible up front.
144+
run: |
145+
ps -p 1 -o comm=
146+
systemctl is-system-running || true
147+
sudo systemctl start docker || true
148+
149+
- name: Run systemd integration test
150+
run: sudo bash tests/integration/autostart_systemd.sh
151+
152+
openrc-integration:
153+
name: Init integration (OpenRC)
154+
runs-on: ubuntu-latest
155+
timeout-minutes: 10
156+
steps:
157+
- uses: actions/checkout@v6
158+
159+
# OpenRC in a container needs the softlevel marker (its verify_boot() otherwise
160+
# refuses to run any service) and a bash since the image ships only busybox ash;
161+
# the test script sets both up itself.
162+
- name: Run OpenRC integration test (Alpine)
163+
run: |
164+
docker run --rm -v "$PWD:/src" -w /src alpine:3.20 sh -c '
165+
set -e
166+
apk add --no-cache bash openrc
167+
bash tests/integration/autostart_openrc.sh
168+
'
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
#!/bin/bash
2+
# Integration test: setup_autostart() / main_service_remove() against a REAL OpenRC.
3+
#
4+
# Covers the non-systemd path added for Alpine (#130). tests/test_telegram_service_openrc.sh
5+
# verifies that the right rc-update/rc-service commands *would* be invoked, using stubs.
6+
# This runs the real thing: the init script is written to the real /etc/init.d, rc-update
7+
# really registers it in a runlevel, and rc-service really executes it.
8+
#
9+
# Intended to run in a throwaway Alpine container:
10+
# docker run --rm -v "$PWD:/src" -w /src alpine:3.20 sh -c '
11+
# apk add --no-cache bash openrc && bash tests/integration/autostart_openrc.sh'
12+
#
13+
# Prints SKIP and exits 0 when OpenRC is not present, so it is harmless to run anywhere.
14+
15+
set -o pipefail
16+
17+
if [ "${BASH_VERSINFO[0]:-0}" -lt 4 ]; then
18+
echo "SKIP: bash 4+ required (got ${BASH_VERSION:-unknown})" >&2
19+
exit 0
20+
fi
21+
22+
if [ ! -x /sbin/openrc-run ] || ! command -v rc-service >/dev/null 2>&1; then
23+
echo "SKIP: OpenRC not present on this host" >&2
24+
exit 0
25+
fi
26+
27+
if [ "$(id -u)" -ne 0 ]; then
28+
echo "ERROR: must run as root — this writes to /etc/init.d." >&2
29+
echo " sudo bash $0" >&2
30+
exit 1
31+
fi
32+
33+
# OpenRC's verify_boot() refuses to run any service unless it believes the system was
34+
# booted by OpenRC. In a container nothing booted it, so the marker has to be created.
35+
SOFTLEVEL_CREATED=0
36+
if [ ! -e /run/openrc/softlevel ]; then
37+
mkdir -p /run/openrc
38+
: >/run/openrc/softlevel
39+
SOFTLEVEL_CREATED=1
40+
fi
41+
42+
REPO_ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
43+
INITD="/etc/init.d/mtproxymax"
44+
RUNLEVEL_LINK="/etc/runlevels/default/mtproxymax"
45+
DOCKER_STUB="/etc/init.d/docker"
46+
DOCKER_STUB_CREATED=0
47+
FAKE="/usr/local/bin/mtproxymax"
48+
FAKE_LOG="/tmp/mtproxymax-fake.log"
49+
FAKE_BACKUP="/tmp/mtproxymax-fake.backup"
50+
51+
FAKE_WAS_PRESENT=0
52+
if [ -e "$FAKE" ]; then
53+
cp -a "$FAKE" "$FAKE_BACKUP" 2>/dev/null && FAKE_WAS_PRESENT=1
54+
fi
55+
56+
# The generated init script declares `depend() { need docker; }`. OpenRC refuses to start
57+
# a service whose hard dependencies cannot be resolved (`ERROR: mtproxymax needs service(s)
58+
# docker`), so a stub is required for the start assertion below to exercise OUR script
59+
# rather than failing on the host's dependency graph.
60+
if [ ! -e "$DOCKER_STUB" ]; then
61+
cat >"$DOCKER_STUB" <<'DOCKER_EOF'
62+
#!/sbin/openrc-run
63+
description="Docker stub (MTProxyMax integration test)"
64+
65+
start() {
66+
ebegin "Starting docker (stub)"
67+
eend 0
68+
}
69+
70+
stop() {
71+
ebegin "Stopping docker (stub)"
72+
eend 0
73+
}
74+
DOCKER_EOF
75+
chmod +x "$DOCKER_STUB"
76+
DOCKER_STUB_CREATED=1
77+
fi
78+
79+
cleanup() {
80+
rc-service mtproxymax stop >/dev/null 2>&1
81+
rc-update del mtproxymax default >/dev/null 2>&1
82+
rm -f "$INITD" "$RUNLEVEL_LINK"
83+
[ "$DOCKER_STUB_CREATED" -eq 1 ] && rm -f "$DOCKER_STUB"
84+
[ "$SOFTLEVEL_CREATED" -eq 1 ] && rm -f /run/openrc/softlevel
85+
if [ "$FAKE_WAS_PRESENT" -eq 1 ]; then
86+
cp -a "$FAKE_BACKUP" "$FAKE" 2>/dev/null
87+
else
88+
rm -f "$FAKE"
89+
fi
90+
rm -f "$FAKE_BACKUP"
91+
}
92+
trap cleanup EXIT
93+
94+
cat >"$FAKE" <<'FAKE_EOF'
95+
#!/bin/bash
96+
# Test double installed by tests/integration/autostart_openrc.sh, so that the generated
97+
# init script's start()/stop() have something real to invoke.
98+
printf '%s\n' "$*" >> /tmp/mtproxymax-fake.log
99+
exit 0
100+
FAKE_EOF
101+
chmod +x "$FAKE"
102+
: >"$FAKE_LOG"
103+
104+
MTPROXYMAX_SOURCE_ONLY=true source "$REPO_ROOT/mtproxymax.sh"
105+
set +e
106+
107+
TESTS_RUN=0
108+
TESTS_FAILED=0
109+
110+
assert_eq() {
111+
local name="$1" want="$2" got="$3"
112+
TESTS_RUN=$((TESTS_RUN + 1))
113+
if [ "$got" = "$want" ]; then
114+
printf ' PASS %s\n' "$name"
115+
else
116+
printf ' FAIL %s (got=%q want=%q)\n' "$name" "$got" "$want"
117+
TESTS_FAILED=$((TESTS_FAILED + 1))
118+
fi
119+
}
120+
121+
assert_ne() {
122+
local name="$1" unwanted="$2" got="$3"
123+
TESTS_RUN=$((TESTS_RUN + 1))
124+
if [ "$got" != "$unwanted" ]; then
125+
printf ' PASS %s\n' "$name"
126+
else
127+
printf ' FAIL %s (unexpectedly %q)\n' "$name" "$got"
128+
TESTS_FAILED=$((TESTS_FAILED + 1))
129+
fi
130+
}
131+
132+
assert_cmd_ok() {
133+
local name="$1"
134+
shift
135+
local out rc
136+
out=$("$@" 2>&1)
137+
rc=$?
138+
# OpenRC tries to enrol each service in cgroup v1 hierarchies that are read-only in a
139+
# container, emitting one "can't create /sys/fs/cgroup/.../tasks" line per controller.
140+
# That is environmental noise rather than test signal, and it buries the real error.
141+
out=$(printf '%s\n' "$out" | grep -v '/sys/fs/cgroup/.*/tasks')
142+
TESTS_RUN=$((TESTS_RUN + 1))
143+
if [ "$rc" -eq 0 ]; then
144+
printf ' PASS %s\n' "$name"
145+
else
146+
printf ' FAIL %s (exit %d)\n' "$name" "$rc"
147+
printf '%s\n' "$out" | sed 's/^/ | /'
148+
TESTS_FAILED=$((TESTS_FAILED + 1))
149+
fi
150+
}
151+
152+
assert_file_contains() {
153+
local name="$1" needle="$2" file="$3"
154+
TESTS_RUN=$((TESTS_RUN + 1))
155+
if grep -qF -- "$needle" "$file" 2>/dev/null; then
156+
printf ' PASS %s\n' "$name"
157+
else
158+
printf ' FAIL %s (missing %q in %s)\n' "$name" "$needle" "$file"
159+
TESTS_FAILED=$((TESTS_FAILED + 1))
160+
fi
161+
}
162+
163+
file_state() { [ -e "$1" ] && echo present || echo absent; }
164+
165+
# --- the host must actually be what we think it is ----------------------------
166+
assert_eq "host detects openrc" "openrc" "$(detect_init_system)"
167+
168+
# --- install ------------------------------------------------------------------
169+
setup_autostart >/dev/null 2>&1
170+
assert_eq "setup_autostart succeeds" "0" "$?"
171+
assert_eq "init script written" "present" "$(file_state "$INITD")"
172+
assert_eq "init script is executable" "yes" "$([ -x "$INITD" ] && echo yes || echo no)"
173+
assert_eq "init script has openrc-run shebang" "#!/sbin/openrc-run" "$(head -n1 "$INITD" 2>/dev/null)"
174+
175+
# The dependency declarations matter independently of whether they can be resolved here;
176+
# rc-service refuses to start a service with unmet hard deps, so this is load-bearing.
177+
assert_file_contains "declares need docker" "need docker" "$INITD"
178+
assert_file_contains "declares need net" "need net" "$INITD"
179+
180+
# The invariant openrc_enable_service() checks: rc-update's exit status is not trusted,
181+
# the runlevel symlink actually landing is. Guards the #130 bug class.
182+
assert_eq "runlevel symlink created" "present" "$(file_state "$RUNLEVEL_LINK")"
183+
184+
# --- start / stop -------------------------------------------------------------
185+
# OpenRC keeps a dependency cache; without it a service's state is indeterminate and
186+
# rc-service refuses every start with "already starting". On a real host the cache is
187+
# built during boot; in a container nothing boots OpenRC, so it has to be built by hand.
188+
# Note this must come *after* setup_autostart, so the new service is in the graph.
189+
rc-update -u >/dev/null 2>&1
190+
191+
assert_cmd_ok "rc-service start succeeds" rc-service mtproxymax start
192+
assert_eq "start() reached the mtproxymax binary" "start" "$(head -n1 "$FAKE_LOG" 2>/dev/null)"
193+
194+
assert_cmd_ok "rc-service stop succeeds" rc-service mtproxymax stop
195+
assert_eq "stop() reached the mtproxymax binary" "stop" "$(tail -n1 "$FAKE_LOG" 2>/dev/null)"
196+
197+
# --- remove -------------------------------------------------------------------
198+
main_service_remove >/dev/null 2>&1
199+
assert_eq "main_service_remove succeeds" "0" "$?"
200+
assert_eq "init script removed" "absent" "$(file_state "$INITD")"
201+
assert_eq "runlevel symlink removed" "absent" "$(file_state "$RUNLEVEL_LINK")"
202+
assert_ne "service no longer registered with rc-update" "mtproxymax" \
203+
"$(rc-update show default 2>/dev/null | awk '{print $1}' | grep -x mtproxymax)"
204+
205+
printf '\n%d tests, %d failures\n' "$TESTS_RUN" "$TESTS_FAILED"
206+
[ "$TESTS_FAILED" -eq 0 ]

0 commit comments

Comments
 (0)