diff --git a/.github/workflows/godir.yml b/.github/workflows/godir.yml new file mode 100644 index 0000000..f965ff5 --- /dev/null +++ b/.github/workflows/godir.yml @@ -0,0 +1,182 @@ +name: godir + +on: + push: + branches: + - main + - master + paths: + - 'godir/**' + - '.github/workflows/godir.yml' + tags: + - 'godir/v*' + pull_request: + paths: + - 'godir/**' + - '.github/workflows/godir.yml' + +permissions: + contents: read + packages: write + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository_owner }}/godir + +jobs: + test: + runs-on: ubuntu-latest + defaults: + run: + working-directory: godir + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.25' + cache-dependency-path: godir/go.sum + + - name: Verify dependencies + run: go mod verify + + - name: Run go vet + run: go vet ./... + + - name: Run tests + run: go test -v ./... + + build: + needs: test + runs-on: ubuntu-latest + if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/godir/v')) + outputs: + image_tag: ${{ steps.meta.outputs.version }} + steps: + - uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=ref,event=branch + type=match,pattern=godir/v(.*),group=1 + type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/godir/v') }} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: godir + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + container-test: + needs: build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Log in to Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Pull image + run: docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.build.outputs.image_tag }} + + - name: Start container + run: | + docker run -d --name godir-test \ + -p 8080:8080 \ + -v ${{ github.workspace }}/perl_mongers.xml:/perl_mongers.xml:ro \ + -e ROOT=/ \ + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.build.outputs.image_tag }} + sleep 3 + + - name: Test health endpoint + run: | + response=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/health) + if [ "$response" != "200" ]; then + echo "Health check failed with status $response" + docker logs godir-test + exit 1 + fi + echo "Health check passed" + + - name: Test redirect functionality + run: | + response=$(curl -s -o /dev/null -w "%{http_code}" -H "Host: tokyo.pm.org" http://localhost:8080/) + if [ "$response" != "301" ]; then + echo "Redirect test failed with status $response" + docker logs godir-test + exit 1 + fi + echo "Redirect test passed" + + - name: Stop container + if: always() + run: docker stop godir-test || true + + # Build preview images for PRs when labeled + build-preview: + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'deploy-preview') + environment: preview + steps: + - uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push preview + uses: docker/build-push-action@v6 + with: + context: godir + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:pr-${{ github.event.pull_request.number }} + cache-from: type=gha + cache-to: type=gha,mode=max + + # Cleanup PR images when PR is closed + cleanup-preview: + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' && github.event.action == 'closed' + permissions: + packages: write + steps: + - name: Delete PR image + uses: actions/delete-package-versions@v5 + with: + package-name: godir + package-type: container + delete-only-untagged-versions: false + min-versions-to-keep: 0 + ignore-versions: '^(?!pr-${{ github.event.pull_request.number }}$).*$' + continue-on-error: true diff --git a/godir/Dockerfile b/godir/Dockerfile index f2935ed..d0e9dfe 100644 --- a/godir/Dockerfile +++ b/godir/Dockerfile @@ -1,15 +1,17 @@ -FROM golang:1.23-alpine AS builder +FROM golang:1.25-alpine AS builder WORKDIR /go/src/app -COPY go.mod . -#COPY go.sum . +COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED=0 GOOS=linux go build -o /tmp/godir . - + FROM scratch +LABEL org.opencontainers.image.source="https://github.com/perlorg/www.pm.org" +LABEL org.opencontainers.image.description="Perl Mongers redirect service" + COPY --from=builder /tmp/godir /godir -CMD ["/godir"] +ENTRYPOINT ["/godir"] diff --git a/godir/Makefile b/godir/Makefile index e7d2a37..5f7954f 100644 --- a/godir/Makefile +++ b/godir/Makefile @@ -1,5 +1,5 @@ -PACKAGE=/godir +PACKAGE=ghcr.io/perlorg/godir VERSION=1.0 @@ -7,7 +7,7 @@ VERSION=1.0 docker-image: .docker-image -.docker-image: Dockerfile *.go go.mod #go.sum +.docker-image: Dockerfile *.go go.mod go.sum docker build -t $(PACKAGE):latest . touch .docker-image diff --git a/godir/README.md b/godir/README.md index 940e995..825caf6 100644 --- a/godir/README.md +++ b/godir/README.md @@ -1,6 +1,41 @@ # godir +[![Build Status](https://github.com/perlorg/www.pm.org/actions/workflows/godir.yml/badge.svg)](https://github.com/perlorg/www.pm.org/actions/workflows/godir.yml) + godir is a server that reads `perl_mongers.xml`, and serves redirects if the `` element points away from pm.org. -Config lives in the perl k8s repo. +## Container Image + +``` +ghcr.io/perlorg/godir +``` + +## Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `PORT` | `8080` | HTTP port to listen on | +| `ROOT` | `.` | Directory containing `perl_mongers.xml` | +| `LOG_LEVEL` | `INFO` | Logging verbosity: DEBUG, INFO, WARN, ERROR | + +## Endpoints + +| Path | Description | +|------|-------------| +| `/` | Redirect handler - extracts subdomain and redirects to group's web URL | +| `/health` | Health check - returns `200 OK` with body "ok" | + +## Running + +```bash +# Local development +ROOT=.. go run . + +# Docker +docker run -p 8080:8080 -v /path/to/perl_mongers.xml:/perl_mongers.xml -e ROOT=/ ghcr.io/perlorg/godir +``` + +## Config + +Kubernetes config lives in the perl k8s repo. diff --git a/godir/go.mod b/godir/go.mod index 4b4b313..6e2348b 100644 --- a/godir/go.mod +++ b/godir/go.mod @@ -1,14 +1,43 @@ module pm.org/godir -go 1.22.5 +go 1.25 require ( github.com/kouhin/envflag v0.0.0-20150818174321-0e9a86061649 - github.com/samber/slog-http v1.4.2 + go.ntppool.org/common v0.7.1 ) require ( + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/stdr v1.2.2 // indirect github.com/google/uuid v1.6.0 // indirect - go.opentelemetry.io/otel v1.19.0 // indirect - go.opentelemetry.io/otel/trace v1.19.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1 // indirect + github.com/remychantenay/slog-otel v1.3.2 // indirect + github.com/samber/lo v1.47.0 // indirect + github.com/samber/slog-multi v1.2.4 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/contrib/bridges/otelslog v0.8.0 // indirect + go.opentelemetry.io/otel v1.33.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.9.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.9.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.33.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.33.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.33.0 // indirect + go.opentelemetry.io/otel/log v0.9.0 // indirect + go.opentelemetry.io/otel/metric v1.33.0 // indirect + go.opentelemetry.io/otel/sdk v1.33.0 // indirect + go.opentelemetry.io/otel/sdk/log v0.9.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.33.0 // indirect + go.opentelemetry.io/otel/trace v1.33.0 // indirect + go.opentelemetry.io/proto/otlp v1.4.0 // indirect + golang.org/x/net v0.44.0 // indirect + golang.org/x/sys v0.36.0 // indirect + golang.org/x/text v0.29.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20241223144023-3abc09e42ca8 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20241223144023-3abc09e42ca8 // indirect + google.golang.org/grpc v1.69.2 // indirect + google.golang.org/protobuf v1.36.1 // indirect ) diff --git a/godir/go.sum b/godir/go.sum index 16e06b2..0964af2 100644 --- a/godir/go.sum +++ b/godir/go.sum @@ -1,20 +1,83 @@ +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1 h1:VNqngBF40hVlDloBruUehVYC3ArSgIyScOAyMRqBxRg= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1/go.mod h1:RBRO7fro65R6tjKzYgLAFo0t1QEXY1Dp+i/bvpRiqiQ= github.com/kouhin/envflag v0.0.0-20150818174321-0e9a86061649 h1:l95EUBxc0iMtMeam3pHFb9jko9ntaLYe2Nc+2evKElM= github.com/kouhin/envflag v0.0.0-20150818174321-0e9a86061649/go.mod h1:BT0PpXv8Y4EL/WUsQmYsQ2FSB9HwQXIuvY+pElZVdFg= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/samber/slog-http v1.4.2 h1:tOOhwE/rFpDzaSxdzttMFFaMDUM+ah7h2zppZ4UCNC0= -github.com/samber/slog-http v1.4.2/go.mod h1:n6h4x2ZBeTgLqMKf95EuNlU6mcJF1b/RVLxo1od5+V0= -github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -go.opentelemetry.io/otel v1.19.0 h1:MuS/TNf4/j4IXsZuJegVzI1cwut7Qc00344rgH7p8bs= -go.opentelemetry.io/otel v1.19.0/go.mod h1:i0QyjOq3UPoTzff0PJB2N66fb4S0+rSbSB15/oyH9fY= -go.opentelemetry.io/otel/trace v1.19.0 h1:DFVQmlVbfVeOuBRrwdtaehRrWiL1JoVs9CPIQ1Dzxpg= -go.opentelemetry.io/otel/trace v1.19.0/go.mod h1:mfaSyvGyEJEI0nyV2I4qhNQnbBOUUmYZpYojqMnX2vo= +github.com/remychantenay/slog-otel v1.3.2 h1:ZBx8qnwfLJ6e18Vba4e9Xp9B7khTmpIwFsU1sAmActw= +github.com/remychantenay/slog-otel v1.3.2/go.mod h1:gKW4tQ8cGOKoA+bi7wtYba/tcJ6Tc9XyQ/EW8gHA/2E= +github.com/samber/lo v1.47.0 h1:z7RynLwP5nbyRscyvcD043DWYoOcYRv3mV8lBeqOCLc= +github.com/samber/lo v1.47.0/go.mod h1:RmDH9Ct32Qy3gduHQuKJ3gW1fMHAnE/fAzQuf6He5cU= +github.com/samber/slog-multi v1.2.4 h1:k9x3JAWKJFPKffx+oXZ8TasaNuorIW4tG+TXxkt6Ry4= +github.com/samber/slog-multi v1.2.4/go.mod h1:ACuZ5B6heK57TfMVkVknN2UZHoFfjCwRxR0Q2OXKHlo= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +go.ntppool.org/common v0.7.1 h1:JiIU9rkqm0AFO8CaivOZuwDasRdsB3jDWRhO56SieWQ= +go.ntppool.org/common v0.7.1/go.mod h1:Dkc2P5+aaCseC/cs0uD9elh4yTllqvyeZ1NNT/G/414= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/contrib/bridges/otelslog v0.8.0 h1:G3sKsNueSdxuACINFxKrQeimAIst0A5ytA2YJH+3e1c= +go.opentelemetry.io/contrib/bridges/otelslog v0.8.0/go.mod h1:ptJm3wizguEPurZgarDAwOeX7O0iMR7l+QvIVenhYdE= +go.opentelemetry.io/otel v1.33.0 h1:/FerN9bax5LoK51X/sI0SVYrjSE0/yUL7DpxW4K3FWw= +go.opentelemetry.io/otel v1.33.0/go.mod h1:SUUkR6csvUQl+yjReHu5uM3EtVV7MBm5FHKRlNx4I8I= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.9.0 h1:gA2gh+3B3NDvRFP30Ufh7CC3TtJRbUSf2TTD0LbCagw= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.9.0/go.mod h1:smRTR+02OtrVGjvWE1sQxhuazozKc/BXvvqqnmOxy+s= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.9.0 h1:Za0Z/j9Gf3Z9DKQ1choU9xI2noCxlkcyFFP2Ob3miEQ= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.9.0/go.mod h1:jMRB8N75meTNjDFQyJBA/2Z9en21CsxwMctn08NHY6c= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.33.0 h1:7F29RDmnlqk6B5d+sUqemt8TBfDqxryYW5gX6L74RFA= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.33.0/go.mod h1:ZiGDq7xwDMKmWDrN1XsXAj0iC7hns+2DhxBFSncNHSE= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.33.0 h1:bSjzTvsXZbLSWU8hnZXcKmEVaJjjnandxD0PxThhVU8= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.33.0/go.mod h1:aj2rilHL8WjXY1I5V+ra+z8FELtk681deydgYT8ikxU= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 h1:Vh5HayB/0HHfOQA7Ctx69E/Y/DcQSMPpKANYVMQ7fBA= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0/go.mod h1:cpgtDBaqD/6ok/UG0jT15/uKjAY8mRA53diogHBg3UI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0 h1:5pojmb1U1AogINhN3SurB+zm/nIcusopeBNp42f45QM= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0/go.mod h1:57gTHJSE5S1tqg+EKsLPlTWhpHMsWlVmer+LA926XiA= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.33.0 h1:wpMfgF8E1rkrT1Z6meFh1NDtownE9Ii3n3X2GJYjsaU= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.33.0/go.mod h1:wAy0T/dUbs468uOlkT31xjvqQgEVXv58BRFWEgn5v/0= +go.opentelemetry.io/otel/log v0.9.0 h1:0OiWRefqJ2QszpCiqwGO0u9ajMPe17q6IscQvvp3czY= +go.opentelemetry.io/otel/log v0.9.0/go.mod h1:WPP4OJ+RBkQ416jrFCQFuFKtXKD6mOoYCQm6ykK8VaU= +go.opentelemetry.io/otel/metric v1.33.0 h1:r+JOocAyeRVXD8lZpjdQjzMadVZp2M4WmQ+5WtEnklQ= +go.opentelemetry.io/otel/metric v1.33.0/go.mod h1:L9+Fyctbp6HFTddIxClbQkjtubW6O9QS3Ann/M82u6M= +go.opentelemetry.io/otel/sdk v1.33.0 h1:iax7M131HuAm9QkZotNHEfstof92xM+N8sr3uHXc2IM= +go.opentelemetry.io/otel/sdk v1.33.0/go.mod h1:A1Q5oi7/9XaMlIWzPSxLRWOI8nG3FnzHJNbiENQuihM= +go.opentelemetry.io/otel/sdk/log v0.9.0 h1:YPCi6W1Eg0vwT/XJWsv2/PaQ2nyAJYuF7UUjQSBe3bc= +go.opentelemetry.io/otel/sdk/log v0.9.0/go.mod h1:y0HdrOz7OkXQBuc2yjiqnEHc+CRKeVhRE3hx4RwTmV4= +go.opentelemetry.io/otel/sdk/metric v1.33.0 h1:Gs5VK9/WUJhNXZgn8MR6ITatvAmKeIuCtNbsP3JkNqU= +go.opentelemetry.io/otel/sdk/metric v1.33.0/go.mod h1:dL5ykHZmm1B1nVRk9dDjChwDmt81MjVp3gLkQRwKf/Q= +go.opentelemetry.io/otel/trace v1.33.0 h1:cCJuF7LRjUFso9LPnEAHJDB2pqzp+hbO8eu1qqW2d/s= +go.opentelemetry.io/otel/trace v1.33.0/go.mod h1:uIcdVUZMpTAmz0tI1z04GoVSezK37CbGV4fr1f2nBck= +go.opentelemetry.io/proto/otlp v1.4.0 h1:TA9WRvW6zMwP+Ssb6fLoUIuirti1gGbP28GcKG1jgeg= +go.opentelemetry.io/proto/otlp v1.4.0/go.mod h1:PPBWZIP98o2ElSqI35IHfu7hIhSwvc5N38Jw8pXuGFY= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I= +golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +google.golang.org/genproto/googleapis/api v0.0.0-20241223144023-3abc09e42ca8 h1:st3LcW/BPi75W4q1jJTEor/QWwbNlPlDG0JTn6XhZu0= +google.golang.org/genproto/googleapis/api v0.0.0-20241223144023-3abc09e42ca8/go.mod h1:klhJGKFyG8Tn50enBn7gizg4nXGXJ+jqEREdCWaPcV4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20241223144023-3abc09e42ca8 h1:TqExAhdPaB60Ux47Cn0oLV07rGnxZzIsaRhQaqS666A= +google.golang.org/genproto/googleapis/rpc v0.0.0-20241223144023-3abc09e42ca8/go.mod h1:lcTa1sDdWEIHMWlITnIczmw5w60CF9ffkb8Z+DVmmjA= +google.golang.org/grpc v1.69.2 h1:U3S9QEtbXC0bYNvRtcoklF3xGtLViumSYxWykJS+7AU= +google.golang.org/grpc v1.69.2/go.mod h1:vyjdE6jLBI76dgpDojsFGNaHlxdjXN9ghpnd2o7JGZ4= +google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk= +google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/godir/main.go b/godir/main.go index 85b5613..d78f4ab 100644 --- a/godir/main.go +++ b/godir/main.go @@ -4,17 +4,19 @@ import ( "encoding/xml" "flag" "fmt" - "log" + "io" "log/slog" "net/http" "os" "path/filepath" "regexp" + "runtime/debug" "strings" + "sync" "time" "github.com/kouhin/envflag" - sloghttp "github.com/samber/slog-http" + "go.ntppool.org/common/logger" ) var ( @@ -22,43 +24,64 @@ var ( port = flag.Int("port", 8080, "port number") ) +const reloadCooldown = 30 * time.Second + func main() { if err := envflag.Parse(); err != nil { panic(err) } - logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) - slog.SetDefault(logger) - - // TODO: Add a refresher to reload if the file has changed. - path := filepath.Join(*root, "perl_mongers.xml") - pmr := &PerlMongersReloading{path: path} - var h http.Handler = &handler{pm: pmr} + log := logger.Setup() - slogConfig := sloghttp.Config{ - WithUserAgent: true, + path := filepath.Join(*root, "perl_mongers.xml") + pmr := &PerlMongersReloading{path: path, log: log} + + // Initial load + pmr.mu.Lock() + if err := pmr.reload(); err != nil { + pmr.mu.Unlock() + log.Error("failed to load perl_mongers.xml", "error", err) + os.Exit(1) } - h = sloghttp.Recovery(h) - h = sloghttp.NewWithConfig(logger, slogConfig)(h) + pmr.mu.Unlock() - http.Handle("/", h) + mux := http.NewServeMux() + mux.Handle("/", recoveryMiddleware(&handler{pm: pmr, log: log}, log)) + mux.HandleFunc("/health", healthHandler) - slog.Info("Serving", - "port", port) + log.Info("Serving", "port", *port) portStr := fmt.Sprintf(":%d", *port) - log.Fatal(http.ListenAndServe(portStr, nil)) + if err := http.ListenAndServe(portStr, mux); err != nil { + log.Error("server error", "error", err) + os.Exit(1) + } +} + +func healthHandler(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, "ok") +} +func recoveryMiddleware(next http.Handler, log *slog.Logger) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + if err := recover(); err != nil { + log.Error("panic recovered", "error", err, "path", r.URL.Path, "stack", string(debug.Stack())) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + } + }() + next.ServeHTTP(w, r) + }) } func readPerlMongers(filename string) (*PerlMongers, error) { - // Open the XML file file, err := os.Open(filename) if err != nil { return nil, err } defer file.Close() - // Decode the XML data into a PerlMongers struct var pm PerlMongers decoder := xml.NewDecoder(file) err = decoder.Decode(&pm) @@ -131,35 +154,51 @@ func (pm PerlMongers) Group(id string) (*Group, error) { } type handler struct { - pm Grouper + pm Grouper + log *slog.Logger } -var isLocalRegexp = regexp.MustCompile(`https?://\w+\.pm\.org/`) +var isLocalRegexp = regexp.MustCompile(`^https?://\w+\.pm\.org(/|$)`) func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { w.Header().Set("Server", "pmorg") - sloghttp.AddCustomAttributes(r, slog.String("X-Forwarded-For", r.Header.Get("X-Forwarded-For"))) + + log := h.log.With( + "method", r.Method, + "host", r.Host, + "path", r.URL.Path, + "remote", r.RemoteAddr, + "x-forwarded-for", r.Header.Get("X-Forwarded-For"), + ) + dot := strings.Index(r.Host, ".") if dot == -1 { + log.Warn("bad request: no dot in host") http.Error(w, "Bad Request", http.StatusBadRequest) return } - g, err := h.pm.Group(r.Host[:dot]) + groupName := r.Host[:dot] + g, err := h.pm.Group(groupName) if err != nil { + log.Info("group not found", "group", groupName) http.Error(w, "Not Found", http.StatusNotFound) return } if g.Status != "active" { + log.Info("group not active", "group", groupName, "status", g.Status) http.Error(w, "Gone", http.StatusGone) return } if isLocalRegexp.MatchString(g.Web) { + log.Warn("misdirected request: web URL points to pm.org", "group", groupName, "web", g.Web) http.Error(w, "Misdirected Request", http.StatusMisdirectedRequest) return } + + log.Info("redirecting", "group", groupName, "target", g.Web) http.Redirect(w, r, g.Web, http.StatusMovedPermanently) } @@ -168,14 +207,18 @@ type Grouper interface { } type PerlMongersReloading struct { + mu sync.RWMutex pm *PerlMongers path string lastUpdate time.Time + lastCheck time.Time + log *slog.Logger } +// reload updates the perl mongers data from disk. +// Called during initial load (before server starts) and from Group (with pmr.mu held). func (pmr *PerlMongersReloading) reload() error { - slog.Info("Reloading PerlMongers", - "path", pmr.path) + pmr.log.Info("reloading PerlMongers", "path", pmr.path) pm, err := readPerlMongers(pmr.path) if err != nil { return err @@ -185,23 +228,36 @@ func (pmr *PerlMongersReloading) reload() error { return nil } -func (pmr *PerlMongersReloading) maybeReload() error { - fi, err := os.Stat(pmr.path) - if err != nil { - return err - } - if fi == nil { - return fmt.Errorf(pmr.path + " does not exist") - } - if fi.ModTime().After(pmr.lastUpdate) { - return pmr.reload() +func (pmr *PerlMongersReloading) Group(id string) (*Group, error) { + now := time.Now() + + // Fast path: RLock to check cooldown and get pm reference + pmr.mu.RLock() + needCheck := now.Sub(pmr.lastCheck) >= reloadCooldown + pm := pmr.pm + pmr.mu.RUnlock() + + if needCheck { + // Slow path: upgrade to write lock for potential reload + pmr.mu.Lock() + // Double-check after acquiring write lock + if now.Sub(pmr.lastCheck) >= reloadCooldown { + pmr.lastCheck = now + fi, err := os.Stat(pmr.path) + if err != nil { + pmr.log.Warn("failed to stat perl_mongers.xml, serving cached data", "error", err) + } else if fi.ModTime().After(pmr.lastUpdate) { + if err := pmr.reload(); err != nil { + pmr.log.Warn("failed to reload perl_mongers.xml, serving stale data", "error", err) + } + } + } + pm = pmr.pm + pmr.mu.Unlock() } - return nil -} -func (pmr *PerlMongersReloading) Group(id string) (*Group, error) { - if err := pmr.maybeReload(); err != nil { - log.Fatal(err) + if pm == nil { + return nil, fmt.Errorf("perl_mongers.xml not loaded") } - return pmr.pm.Group(id) + return pm.Group(id) } diff --git a/godir/main_test.go b/godir/main_test.go new file mode 100644 index 0000000..267d692 --- /dev/null +++ b/godir/main_test.go @@ -0,0 +1,183 @@ +package main + +import ( + "io" + "log/slog" + "net/http" + "net/http/httptest" + "testing" +) + +// testLogger returns a logger that discards output for tests +func testLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(io.Discard, nil)) +} + +func TestHealthEndpoint(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/health", nil) + w := httptest.NewRecorder() + + healthHandler(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200, got %d", w.Code) + } + if w.Body.String() != "ok" { + t.Errorf("expected body 'ok', got %q", w.Body.String()) + } +} + +func TestXMLLoading(t *testing.T) { + pm, err := readPerlMongers("../perl_mongers.xml") + if err != nil { + t.Fatalf("failed to load perl_mongers.xml: %v", err) + } + if len(pm.Groups) == 0 { + t.Error("expected at least one group") + } +} + +func TestHandlerActiveGroupRedirect(t *testing.T) { + pm, err := readPerlMongers("../perl_mongers.xml") + if err != nil { + t.Fatalf("failed to load perl_mongers.xml: %v", err) + } + + // Find a few active groups to test + var activeGroups []Group + for _, g := range pm.Groups { + if g.Status == "active" && g.Web != "" && !isLocalRegexp.MatchString(g.Web) { + activeGroups = append(activeGroups, g) + if len(activeGroups) >= 3 { + break + } + } + } + + if len(activeGroups) == 0 { + t.Fatal("no active groups with non-pm.org web URLs found") + } + + h := &handler{pm: pm, log: testLogger()} + + for _, g := range activeGroups { + t.Run(g.Name, func(t *testing.T) { + // Extract subdomain from group name (e.g., "NY.pm" -> "ny") + subdomain := g.Name + if len(subdomain) > 3 && subdomain[len(subdomain)-3:] == ".pm" { + subdomain = subdomain[:len(subdomain)-3] + } + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Host = subdomain + ".pm.org" + w := httptest.NewRecorder() + + h.ServeHTTP(w, req) + + if w.Code != http.StatusMovedPermanently { + t.Errorf("expected status 301, got %d", w.Code) + } + location := w.Header().Get("Location") + if location != g.Web { + t.Errorf("expected redirect to %q, got %q", g.Web, location) + } + }) + } +} + +func TestHandlerUnknownGroup(t *testing.T) { + pm, err := readPerlMongers("../perl_mongers.xml") + if err != nil { + t.Fatalf("failed to load perl_mongers.xml: %v", err) + } + + h := &handler{pm: pm, log: testLogger()} + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Host = "nonexistent-group-xyz.pm.org" + w := httptest.NewRecorder() + + h.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("expected status 404, got %d", w.Code) + } +} + +func TestHandlerInactiveGroup(t *testing.T) { + pm, err := readPerlMongers("../perl_mongers.xml") + if err != nil { + t.Fatalf("failed to load perl_mongers.xml: %v", err) + } + + // Find an inactive group + var inactiveGroup Group + found := false + for _, g := range pm.Groups { + if g.Status == "inactive" { + inactiveGroup = g + found = true + break + } + } + + if !found { + t.Skip("no inactive groups found") + } + + h := &handler{pm: pm, log: testLogger()} + + subdomain := inactiveGroup.Name + if len(subdomain) > 3 && subdomain[len(subdomain)-3:] == ".pm" { + subdomain = subdomain[:len(subdomain)-3] + } + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Host = subdomain + ".pm.org" + w := httptest.NewRecorder() + + h.ServeHTTP(w, req) + + if w.Code != http.StatusGone { + t.Errorf("expected status 410 Gone, got %d", w.Code) + } +} + +func TestHandlerMisdirectedRequest(t *testing.T) { + pm, err := readPerlMongers("../perl_mongers.xml") + if err != nil { + t.Fatalf("failed to load perl_mongers.xml: %v", err) + } + + h := &handler{pm: pm, log: testLogger()} + + // Test the zztestloop.pm group which has a pm.org URL + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Host = "zztestloop.pm.org" + w := httptest.NewRecorder() + + h.ServeHTTP(w, req) + + if w.Code != http.StatusMisdirectedRequest { + t.Errorf("expected status 421 Misdirected Request, got %d", w.Code) + } +} + +func TestHandlerBadRequest(t *testing.T) { + pm, err := readPerlMongers("../perl_mongers.xml") + if err != nil { + t.Fatalf("failed to load perl_mongers.xml: %v", err) + } + + h := &handler{pm: pm, log: testLogger()} + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Host = "nodotinhost" + w := httptest.NewRecorder() + + h.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected status 400, got %d", w.Code) + } +} diff --git a/perl_mongers.xml b/perl_mongers.xml index c9b4552..c8347d6 100644 --- a/perl_mongers.xml +++ b/perl_mongers.xml @@ -19165,4 +19165,15 @@ https://kichijojipm.connpass.com/ 20140926 + + + zztestloop.pm + + Test + + Test + Test + + https://zztestloop.pm.org/ +