diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..d5b6bb8 --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,86 @@ +name: Validate and publish + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + validate: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Validate Python + run: | + python -m py_compile digest.py serve.py + python -m unittest discover -s tests -v + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + - name: Build test image + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + with: + context: . + load: true + push: false + tags: myfeed:ci + cache-from: type=gha,scope=myfeed + cache-to: type=gha,mode=max,scope=myfeed + - name: Smoke test + run: | + docker run -d --name myfeed-ci -p 127.0.0.1:8484:8484 myfeed:ci + trap 'docker rm -f myfeed-ci >/dev/null 2>&1 || true' EXIT + for attempt in {1..45}; do + curl --fail --silent http://127.0.0.1:8484/health/ready && exit 0 + sleep 1 + done + docker logs myfeed-ci + exit 1 + + publish: + if: github.event_name != 'pull_request' + needs: validate + runs-on: ubuntu-24.04-arm + timeout-minutes: 20 + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - id: meta + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=raw,value=latest,enable={{is_default_branch}} + type=sha,prefix=sha-,format=short + - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + with: + context: . + platforms: linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + provenance: mode=max + sbom: true + cache-from: type=gha,scope=myfeed + cache-to: type=gha,mode=max,scope=myfeed diff --git a/Dockerfile b/Dockerfile index 87f559f..75fe380 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,4 +5,6 @@ COPY digest.py serve.py feeds.toml tech_pool.json econ_2026.json entrypoint.sh . ENV MYFEED_DATA=/data MYFEED_BIND=0.0.0.0 TZ=America/New_York VOLUME /data EXPOSE 8484 +HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8484/health/ready', timeout=3).read()" || exit 1 CMD ["./entrypoint.sh"] diff --git a/serve.py b/serve.py index 5626d38..f505ef1 100755 --- a/serve.py +++ b/serve.py @@ -19,7 +19,7 @@ HERE = Path(__file__).parent DATA = Path(os.environ.get("MYFEED_DATA", HERE)) CLICKS_LOG = DATA / "clicks.jsonl" -PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8484 +PORT = int(sys.argv[1]) if len(sys.argv) > 1 and sys.argv[1].isdigit() else 8484 BIND = os.environ.get("MYFEED_BIND", "127.0.0.1") @@ -36,7 +36,13 @@ def _send(self, code, body, ctype="text/html; charset=utf-8", extra=None): def do_GET(self): url = urlparse(self.path) - if url.path == "/": + if url.path == "/health/live": + self._send(200, '{"status":"ok"}', "application/json") + elif url.path == "/health/ready": + digest = DATA / "digest.html" + code = 200 if digest.is_file() else 503 + self._send(code, json.dumps({"status": "ready" if code == 200 else "waiting"}), "application/json") + elif url.path == "/": f = DATA / "digest.html" if f.exists(): self._send(200, f.read_bytes(), extra={"Cache-Control": "no-cache"}) diff --git a/tests/test_serve.py b/tests/test_serve.py new file mode 100644 index 0000000..1c5129c --- /dev/null +++ b/tests/test_serve.py @@ -0,0 +1,52 @@ +import json +import tempfile +import threading +import unittest +import urllib.error +import urllib.request +from http.server import ThreadingHTTPServer +from pathlib import Path +from unittest.mock import patch + +import serve + + +class ServeHealthTests(unittest.TestCase): + def setUp(self): + self.temp_dir = tempfile.TemporaryDirectory() + self.data = Path(self.temp_dir.name) + self.data_patch = patch.object(serve, "DATA", self.data) + self.data_patch.start() + self.server = ThreadingHTTPServer(("127.0.0.1", 0), serve.Handler) + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) + self.thread.start() + self.base = f"http://127.0.0.1:{self.server.server_port}" + + def tearDown(self): + self.server.shutdown() + self.server.server_close() + self.data_patch.stop() + self.temp_dir.cleanup() + + def get(self, path): + with urllib.request.urlopen(self.base + path) as response: + return response.status, response.read() + + def test_live_health_does_not_require_digest(self): + status, body = self.get("/health/live") + self.assertEqual(status, 200) + self.assertEqual(json.loads(body), {"status": "ok"}) + + def test_ready_health_requires_digest(self): + with self.assertRaises(urllib.error.HTTPError) as error: + self.get("/health/ready") + self.assertEqual(error.exception.code, 503) + + (self.data / "digest.html").write_text("test") + status, body = self.get("/health/ready") + self.assertEqual(status, 200) + self.assertEqual(json.loads(body), {"status": "ready"}) + + +if __name__ == "__main__": + unittest.main()