From 88e7b7b12e9031f513d69f0214858d142bea6046 Mon Sep 17 00:00:00 2001 From: reachableceo Date: Fri, 28 Aug 2026 21:38:34 -0500 Subject: [PATCH] dev.sh + smoke: docker-routed dev loop and webhook smoke test dev.sh funnels build/vet/test/check/events/shell through the digest-pinned builder (golang:1.26-bookworm @ sha256:e8c859f...; alpine lacks bash, which the exec tool's tests exec), keeping the host toolchain-free per the DESIGN dev-in-docker rule. smoke/smoke.sh runs `harness events` in the container with host port 4100 published and drives it from the host with python3 stdlib urllib (curl is banned on host): 401 unsigned/bad-signature/wrong-secret, 200 stored + duplicate replay, per-provider action mapping, then prints container + JSONL logs. Throwaway smoke state/ literals live under gitignored .smoke/. --- .gitignore | 1 + dev.sh | 61 +++++++++++++++++++++++++ smoke/probe.py | 122 +++++++++++++++++++++++++++++++++++++++++++++++++ smoke/smoke.sh | 74 ++++++++++++++++++++++++++++++ 4 files changed, 258 insertions(+) create mode 100755 dev.sh create mode 100644 smoke/probe.py create mode 100755 smoke/smoke.sh diff --git a/.gitignore b/.gitignore index ad5476f..869fb45 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ harness.toml reports/ bin/ *.test +.smoke/ diff --git a/dev.sh b/dev.sh new file mode 100755 index 0000000..abced0e --- /dev/null +++ b/dev.sh @@ -0,0 +1,61 @@ +#!/bin/sh +# MOPAC harness dev wrapper. EVERY compile/vet/test path routes through the +# digest-pinned Docker builder (DESIGN "ALL dev work in Docker" — big rule); +# the host runs containers, never toolchains. +# +# Usage: ./dev.sh {build|vet|test|check|events|smoke|shell} [args...] +# +# build compile ./cmd/harness into bin/harness +# vet go vet ./... +# test go test ./... +# check build + vet + test (the pre-commit gate) +# events run `harness events` with host port 4100 published (LAN smoke) +# smoke full webhook smoke: starts events in a container, POSTs via +# python urllib (curl is banned on host), shows 401/200 + JSONL, +# tears the container down +# shell interactive sh inside the builder +set -e + +IMAGE="golang@sha256:e8c859f5632dcfde7b32d2012b4351728f6437930887c2f6a91ea242459e5514" # = golang:1.26-bookworm (bash present; alpine lacks it) + +run() { + docker run --rm -v "$PWD:/h" -w /h \ + -u "$(id -u):$(id -g)" -e HOME=/tmp -e GOFLAGS=-buildvcs=false \ + "$IMAGE" "$@" +} + +cmd=${1:-check} +shift || true + +case "$cmd" in +build) + run go build -o bin/harness ./cmd/harness + ;; +vet) + run go vet ./... + ;; +test) + run go test "$@" ./... + ;; +check) + run sh -c 'go build -o bin/harness ./cmd/harness && go vet ./... && go test ./...' + ;; +events) + # Publish 4100 on the host LAN; state dir bind-mounted from the repo. + exec docker run --rm --name mopac-events -p 4100:4100 \ + -v "$PWD:/h" -w /h -u "$(id -u):$(id -g)" -e HOME=/tmp \ + -e HARNESS_REDMINE_WEBHOOK_SECRET -e HARNESS_DISCOURSE_WEBHOOK_SECRET \ + -e HARNESS_GITEA_WEBHOOK_SECRET \ + "$IMAGE" /h/bin/harness events -listen ":4100" "$@" + ;; +smoke) + ./smoke/smoke.sh + ;; +shell) + run sh + ;; +*) + echo "dev.sh: unknown command $cmd (build|vet|test|check|events|smoke|shell)" >&2 + exit 1 + ;; +esac diff --git a/smoke/probe.py b/smoke/probe.py new file mode 100644 index 0000000..531d499 --- /dev/null +++ b/smoke/probe.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +"""Webhook smoke probe for `harness events`. + +Drives the receiver on BASE (default http://127.0.0.1:4100) with plain +stdlib urllib: the host has no curl by policy. Checks: 401 without secrets, +200 + stored with correct secrets, duplicate replay, per-provider action +mapping, healthz. Prints one PASS/FAIL line per check; exits non-zero on +any failure. +""" + +import hashlib +import hmac +import json +import os +import sys +import time +import urllib.error +import urllib.request + +BASE = os.environ.get("BASE_URL", "http://127.0.0.1:4100") +GITEA_SECRET = os.environ.get("GITEA_SECRET", "smoke-gitea-secret") +REDMINE_SECRET = os.environ.get("REDMINE_SECRET", "smoke-redmine-secret") +DISCOURSE_SECRET = os.environ.get("DISCOURSE_SECRET", "smoke-discourse-secret") + +failures = 0 + + +def req(method, path, body=b"", headers=None): + r = urllib.request.Request(BASE + path, data=body if method == "POST" else None, + method=method, headers=headers or {}) + try: + with urllib.request.urlopen(r, timeout=5) as resp: + return resp.status, resp.read().decode() + except urllib.error.HTTPError as e: + return e.code, e.read().decode() + + +def check(name, cond, detail=""): + global failures + print(("PASS" if cond else "FAIL"), name, ("- " + detail) if detail else "") + if not cond: + failures += 1 + + +def wait_healthz(tries=50): + for _ in range(tries): + try: + code, _ = req("GET", "/healthz") + if code == 200: + return True + except Exception: + pass + time.sleep(0.2) + return False + + +def main(): + check("healthz answers", wait_healthz()) + + gitea_body = json.dumps({ + "action": "approved", "number": 5, + "pull_request": {"number": 5, "title": "smoke PR", "merged": False}, + "repository": {"full_name": "ukrrs/MOPAC"}, + "sender": {"login": "smoke-charles"}, + }).encode() + + code, text = req("POST", "/hooks/gitea", gitea_body) + check("gitea unsigned rejected 401", code == 401, text.strip()) + + bad_sig = "0" * 64 + code, text = req("POST", "/hooks/gitea", gitea_body, + {"X-Gitea-Signature": bad_sig}) + check("gitea bad signature rejected 401", code == 401, text.strip()) + + sig = hmac.new(GITEA_SECRET.encode(), gitea_body, hashlib.sha256).hexdigest() + hdrs = {"X-Gitea-Signature": sig, "X-Gitea-Delivery": "smoke-delivery-1"} + code, text = req("POST", "/hooks/gitea", gitea_body, hdrs) + flat = text.replace(" ", "") + check("gitea signed 200 stored", code == 200 and '"status":"stored"' in flat, text.strip()) + check("gitea maps to pipeline_step", '"action":"pipeline_step"' in flat, text.strip()) + + code, text = req("POST", "/hooks/gitea", gitea_body, hdrs) + flat = text.replace(" ", "") + check("gitea replay deduped", code == 200 and '"status":"duplicate"' in flat, text.strip()) + + redmine_body = json.dumps({ + "event_name": "issue_updated", + "payload": {"issue": {"id": 42, "subject": "smoke issue"}, + "user": {"login": "smoke-charles"}}, + }).encode() + code, text = req("POST", "/hooks/redmine", redmine_body, + {"X-Redmine-Webhook-Secret": REDMINE_SECRET}) + flat = text.replace(" ", "") + check("redmine shared secret 200 stored", code == 200 and '"status":"stored"' in flat, text.strip()) + check("redmine maps to dispatch_turn", '"action":"dispatch_turn"' in flat, text.strip()) + + code, text = req("POST", "/hooks/redmine", redmine_body, + {"X-Redmine-Webhook-Secret": "wrong-secret"}) + check("redmine wrong secret rejected 401", code == 401, text.strip()) + + discourse_body = json.dumps({ + "post": {"id": 9, "topic_id": 7, "username": "smoke-charles", + "topic_title": "smoke topic"}, + }).encode() + code, text = req("POST", "/hooks/discourse", discourse_body, { + "X-Discourse-Webhook-Secret": DISCOURSE_SECRET, + "X-Discourse-Event": "post_created", + "X-Discourse-Event-Id": "smoke-post-9", + }) + flat = text.replace(" ", "") + check("discourse shared secret 200 stored", code == 200 and '"status":"stored"' in flat, text.strip()) + check("discourse maps to respond_turn", '"action":"respond_turn"' in flat, text.strip()) + + code, _ = req("GET", "/hooks/gitea") + check("GET hook rejected 405", code == 405) + + print("probe:", "OK" if failures == 0 else f"{failures} FAILURE(S)") + return 0 if failures == 0 else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/smoke/smoke.sh b/smoke/smoke.sh new file mode 100755 index 0000000..647a9d4 --- /dev/null +++ b/smoke/smoke.sh @@ -0,0 +1,74 @@ +#!/bin/sh +# Webhook receiver smoke test: starts `harness events` in the digest-pinned +# builder container with host port 4100 published, drives it from the host +# with python urllib (curl is banned on the host by policy), prints the +# container log + the JSONL event record, then tears everything down. +set -e +cd "$(dirname "$0")/.." + +IMAGE="golang@sha256:e8c859f5632dcfde7b32d2012b4351728f6437930887c2f6a91ea242459e5514" # = golang:1.26-bookworm (bash present; alpine lacks it) +NAME="mopac-events-smoke" +PORT="${PORT:-4100}" + +./dev.sh build + +rm -rf .smoke +mkdir -p .smoke +cat > .smoke/harness.toml <<'EOF' +# smoke config: throwaway literals only, state kept under .smoke/ +vertical = "smoke" +work_root = "." +report_dir = ".smoke/reports" + +[loop] +max_rounds = 2 + +[litellm] +base_url = "http://127.0.0.1:9" +key_ref = "literal:unused-smoke-key" + +[models] +mopac-primary = "glm-5.3" +default_tier = "mopac-primary" + +[demo] +id = "smoke" +subject = "smoke" +prompt = "smoke" +class = "primary" + +[events] +listen = ":4100" +state_dir = ".smoke/state" + +[events.redmine] +secret_ref = "literal:smoke-redmine-secret" + +[events.discourse] +secret_ref = "literal:smoke-discourse-secret" + +[events.gitea] +secret_ref = "literal:smoke-gitea-secret" +EOF + +docker rm -f "$NAME" >/dev/null 2>&1 || true +docker run -d --name "$NAME" -p "$PORT:4100" \ + -v "$PWD:/h" -w /h -u "$(id -u):$(id -g)" -e HOME=/tmp \ + "$IMAGE" /h/bin/harness events -config /h/.smoke/harness.toml -listen ":4100" + +cleanup() { + docker rm -f "$NAME" >/dev/null 2>&1 || true +} +trap cleanup EXIT + +echo "--- probing http://127.0.0.1:$PORT (python urllib; curl banned on host) ---" +BASE_URL="http://127.0.0.1:$PORT" python3 smoke/probe.py +probe_rc=$? + +echo "--- container log ---" +docker logs "$NAME" 2>&1 + +echo "--- event log (.smoke/state/events.jsonl) ---" +cat .smoke/state/events.jsonl + +exit "$probe_rc"