From 25ef69d9a073a0b1e3c7512216bd8198960576a9 Mon Sep 17 00:00:00 2001 From: ssavutu Date: Fri, 24 Jul 2026 23:59:13 -0400 Subject: [PATCH 1/6] refactor(activity): move activity storage to MariaDB --- .env.example | 1 - docker-compose.yml | 3 - scripts/generate_certs.sh | 7 +- server/Dockerfile | 26 ++- server/go.mod | 19 +-- server/go.sum | 34 +--- server/internal/activity/badger_store.go | 161 ------------------- server/internal/activity/handler.go | 8 + server/internal/activity/sql_store.go | 192 +++++++++++++++++++++++ server/main.go | 31 ++-- 10 files changed, 253 insertions(+), 229 deletions(-) delete mode 100644 server/internal/activity/badger_store.go create mode 100644 server/internal/activity/sql_store.go diff --git a/.env.example b/.env.example index 1a8c7a9..505684f 100644 --- a/.env.example +++ b/.env.example @@ -10,7 +10,6 @@ OIDC_CLIENT_ID= OIDC_CLIENT_SECRET= CMS_AUTO_PROMOTE_ALL_ADMINS=false CMS_REBUILD_TAXONOMY_COUNTS_ON_STARTUP=false -ACTIVITY_DB_PATH=./data/activity # Optional overrides # MARIADB_DATABASE=triangle diff --git a/docker-compose.yml b/docker-compose.yml index 8c01292..a2db20e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -40,7 +40,6 @@ services: DB_PORT: 3306 TLS_CERT_FILE: /app/certs/localhost.crt TLS_KEY_FILE: /app/certs/localhost.key - ACTIVITY_DB_PATH: /app/data/activity OIDC_ISSUER_URL: ${OIDC_ISSUER_URL:-} OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-} CMS_AUTO_PROMOTE_ALL_ADMINS: ${CMS_AUTO_PROMOTE_ALL_ADMINS:-false} @@ -57,7 +56,6 @@ services: ports: - "8080:8080" volumes: - - cms_activity_data:/app/data # TLS certs are mounted at runtime (not baked into the image). Generate # local dev certs with scripts/generate_certs.sh. In production these are # provided by the host / replaced by Nginx TLS termination. @@ -120,7 +118,6 @@ services: volumes: mariadb_data: - cms_activity_data: loki_data: promtail_positions: grafana_data: diff --git a/scripts/generate_certs.sh b/scripts/generate_certs.sh index 4d99092..78716f5 100755 --- a/scripts/generate_certs.sh +++ b/scripts/generate_certs.sh @@ -34,7 +34,12 @@ openssl req -x509 -newkey rsa:2048 -nodes \ -subj "/CN=localhost" \ -addext "subjectAltName=DNS:localhost,IP:127.0.0.1" -chmod 600 "$key" +# World-readable: this is a disposable, non-committed, self-signed localhost dev +# key. The CMS container runs as a non-root user (UID 10001, see server/Dockerfile) +# and reads this file over a bind mount, where its host owner UID won't match, so +# 0600 would deny access and break TLS startup. Production keys are provisioned by +# ops with ownership/perms scoped to the runtime user (or TLS is terminated at Nginx). +chmod 644 "$key" echo "Generated self-signed dev cert:" echo " ${crt}" echo " ${key}" diff --git a/server/Dockerfile b/server/Dockerfile index bda9bf3..b1234ae 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -3,20 +3,40 @@ WORKDIR /app COPY go.mod go.sum ./ RUN go mod download -RUN go install github.com/swaggo/swag/cmd/swag@latest +# Pin swag to the version declared in go.mod so doc generation is reproducible +# (an unpinned @latest silently changes the build over time). +RUN go install github.com/swaggo/swag/cmd/swag@v1.16.6 COPY . . RUN swag init --parseDependency --parseInternal -RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /bin/cms ./main.go +# TARGETARCH is provided automatically by Buildx for the target platform; fall +# back to amd64 for a plain `docker build` where it is unset. +ARG TARGETARCH +RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH:-amd64} go build -o /bin/cms ./main.go FROM alpine:3.21 WORKDIR /app -COPY --from=builder /bin/cms /app/cms +# ca-certificates: the CMS makes outbound HTTPS calls at startup (OIDC provider +# discovery against Authentik). The base alpine image ships no root CA bundle, so +# without this the TLS handshake fails with "x509: certificate signed by unknown +# authority" and the process exits. tzdata is included so time zone conversions +# resolve against real zone data rather than UTC-only fallbacks. +RUN apk add --no-cache ca-certificates tzdata + +# Run as an unprivileged user. The process needs no write access at runtime +# (activity/audit state now lives in MariaDB, not on local disk) and binds only +# the unprivileged port 8080, so root is unnecessary. TLS key/cert files mounted +# at runtime must be readable by this UID (10001). +RUN addgroup -S app && adduser -S -G app -u 10001 app + +COPY --from=builder --chown=app:app /bin/cms /app/cms # TLS certificates are NOT baked into the image. They are environment-specific # secrets and are provided at runtime (bind mount / Nginx termination). See # docker-compose.yml and scripts/generate_certs.sh. +USER app + EXPOSE 8080 ENTRYPOINT ["/app/cms"] diff --git a/server/go.mod b/server/go.mod index d07bedc..509d9c1 100644 --- a/server/go.mod +++ b/server/go.mod @@ -4,7 +4,6 @@ go 1.25.0 require ( github.com/coreos/go-oidc/v3 v3.18.0 - github.com/dgraph-io/badger/v4 v4.8.0 github.com/go-sql-driver/mysql v1.9.3 github.com/swaggo/http-swagger v1.3.4 github.com/swaggo/swag v1.16.6 @@ -12,32 +11,24 @@ require ( require ( github.com/KyleBanks/depth v1.2.1 // indirect - github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/dgraph-io/ristretto/v2 v2.2.0 // indirect - github.com/dustin/go-humanize v1.0.1 // indirect github.com/go-jose/go-jose/v4 v4.1.4 // indirect - github.com/go-logr/logr v1.4.3 // indirect - github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/jsonpointer v0.19.5 // indirect github.com/go-openapi/jsonreference v0.20.0 // indirect github.com/go-openapi/spec v0.20.6 // indirect github.com/go-openapi/swag v0.19.15 // indirect - github.com/google/flatbuffers v25.2.10+incompatible // indirect + github.com/google/go-cmp v0.7.0 // indirect github.com/josharian/intern v1.0.0 // indirect - github.com/klauspost/compress v1.18.0 // indirect + github.com/kr/pretty v0.3.1 // indirect github.com/mailru/easyjson v0.7.6 // indirect + github.com/rogpeppe/go-internal v1.13.1 // indirect + github.com/stretchr/testify v1.10.0 // indirect github.com/swaggo/files v1.0.1 // indirect - go.opentelemetry.io/auto/sdk v1.1.0 // indirect - go.opentelemetry.io/otel v1.37.0 // indirect - go.opentelemetry.io/otel/metric v1.37.0 // indirect - go.opentelemetry.io/otel/trace v1.37.0 // indirect golang.org/x/mod v0.25.0 // indirect golang.org/x/net v0.41.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sync v0.15.0 // indirect - golang.org/x/sys v0.34.0 // indirect golang.org/x/tools v0.33.0 // indirect - google.golang.org/protobuf v1.36.6 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v2 v2.4.0 // indirect ) diff --git a/server/go.sum b/server/go.sum index eb0d900..cb04b50 100644 --- a/server/go.sum +++ b/server/go.sum @@ -2,29 +2,14 @@ filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= -github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= -github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A= github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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/dgraph-io/badger/v4 v4.8.0 h1:JYph1ChBijCw8SLeybvPINizbDKWZ5n/GYbz2yhN/bs= -github.com/dgraph-io/badger/v4 v4.8.0/go.mod h1:U6on6e8k/RTbUWxqKR0MvugJuVmkxSNc79ap4917h4w= -github.com/dgraph-io/ristretto/v2 v2.2.0 h1:bkY3XzJcXoMuELV8F+vS8kzNgicwQFAaGINAEJdWGOM= -github.com/dgraph-io/ristretto/v2 v2.2.0/go.mod h1:RZrm63UmcBAaYWC1DotLYBmTvgkrs0+XhBd7Npn7/zI= -github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da h1:aIftn67I1fkbMa512G+w+Pxci9hJPB8oMnkcP3iZF38= -github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= -github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= -github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= -github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= -github.com/go-logr/logr v1.4.3/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/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY= github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= @@ -37,17 +22,14 @@ github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyr github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= -github.com/google/flatbuffers v25.2.10+incompatible h1:F3vclr7C3HpB1k9mxCGRMXq6FdUalZ6H/pNX4FP1v0Q= -github.com/google/flatbuffers v25.2.10+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= -github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= -github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= @@ -59,8 +41,10 @@ github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA= github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= 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/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -75,14 +59,6 @@ github.com/swaggo/http-swagger v1.3.4/go.mod h1:9dAh0unqMBAlbp1uE2Uc2mQTxNMU/ha4 github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI= github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -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/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= -go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= -go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= -go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= -go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= -go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= @@ -106,8 +82,6 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= -golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -121,8 +95,6 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc= golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= -google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/server/internal/activity/badger_store.go b/server/internal/activity/badger_store.go deleted file mode 100644 index f9893b3..0000000 --- a/server/internal/activity/badger_store.go +++ /dev/null @@ -1,161 +0,0 @@ -package activity - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "os" - "path/filepath" - "strings" - "sync/atomic" - "time" - - "github.com/dgraph-io/badger/v4" -) - -const ( - defaultListLimit = 100 - maxListLimit = 500 - logKeyPrefix = "logs/" -) - -type BadgerStore struct { - db *badger.DB - seq uint64 -} - -func OpenBadgerStore(path string) (*BadgerStore, error) { - cleanPath := strings.TrimSpace(path) - if cleanPath == "" { - return nil, fmt.Errorf("badger path is required") - } - if err := os.MkdirAll(filepath.Clean(cleanPath), 0o755); err != nil { - return nil, fmt.Errorf("create badger directory: %w", err) - } - - opts := badger.DefaultOptions(cleanPath) - opts.Logger = nil - - db, err := badger.Open(opts) - if err != nil { - return nil, fmt.Errorf("open badger: %w", err) - } - - return &BadgerStore{db: db}, nil -} - -func (s *BadgerStore) Close() error { - if s == nil || s.db == nil { - return nil - } - return s.db.Close() -} - -func (s *BadgerStore) Write(ctx context.Context, entry Entry) error { - if s == nil || s.db == nil { - return ErrStoreUnavailable - } - - if entry.Timestamp.IsZero() { - entry.Timestamp = time.Now().UTC() - } else { - entry.Timestamp = entry.Timestamp.UTC() - } - if entry.ID == "" { - entry.ID = s.nextID(entry.Timestamp) - } - if entry.Kind == "" { - if entry.Action != "" { - entry.Kind = "activity" - } else { - entry.Kind = "log" - } - } - - payload, err := json.Marshal(entry) - if err != nil { - return fmt.Errorf("marshal activity entry: %w", err) - } - - return s.db.Update(func(txn *badger.Txn) error { - select { - case <-ctx.Done(): - return ctx.Err() - default: - } - return txn.Set([]byte(s.keyFor(entry.Timestamp, entry.ID)), payload) - }) -} - -func (s *BadgerStore) List(ctx context.Context, query Query) (ListResult, error) { - if s == nil || s.db == nil { - return ListResult{}, ErrStoreUnavailable - } - - limit := query.Limit - if limit <= 0 { - limit = defaultListLimit - } - if limit > maxListLimit { - limit = maxListLimit - } - - result := ListResult{ - Entries: make([]Entry, 0, limit), - } - prefix := []byte(logKeyPrefix) - seekKey := append(append([]byte{}, prefix...), 0xFF) - - err := s.db.View(func(txn *badger.Txn) error { - opts := badger.DefaultIteratorOptions - opts.PrefetchValues = true - opts.Reverse = true - - it := txn.NewIterator(opts) - defer it.Close() - - for it.Seek(seekKey); it.ValidForPrefix(prefix); it.Next() { - select { - case <-ctx.Done(): - return ctx.Err() - default: - } - - item := it.Item() - var entry Entry - err := item.Value(func(val []byte) error { - return json.Unmarshal(val, &entry) - }) - if err != nil { - return fmt.Errorf("decode activity entry: %w", err) - } - if query.Kind != "" && entry.Kind != query.Kind { - continue - } - result.TotalCount++ - if len(result.Entries) < limit { - result.Entries = append(result.Entries, entry) - } - } - return nil - }) - if err != nil { - return ListResult{}, err - } - - return result, nil -} - -func (s *BadgerStore) keyFor(ts time.Time, id string) string { - return fmt.Sprintf("%s%020d/%s", logKeyPrefix, ts.UTC().UnixNano(), id) -} - -func (s *BadgerStore) nextID(ts time.Time) string { - seq := atomic.AddUint64(&s.seq, 1) - return fmt.Sprintf("%020d-%020d", ts.UTC().UnixNano(), seq) -} - -func isLogKey(key []byte) bool { - return bytes.HasPrefix(key, []byte(logKeyPrefix)) -} diff --git a/server/internal/activity/handler.go b/server/internal/activity/handler.go index 2b62a1d..f89348c 100644 --- a/server/internal/activity/handler.go +++ b/server/internal/activity/handler.go @@ -123,6 +123,14 @@ func (h *StoreHandler) Handle(ctx context.Context, record slog.Record) error { } } + // Only audit events are persisted to the store. General log records still + // reach stdout (and Loki) via the other tee'd handler; duplicating them in + // the database would be unbounded write-amplification for data no reader + // queries — /v1/activity only ever lists kind="activity". + if entry.Kind != "activity" { + return nil + } + return h.store.Write(ctx, entry) } diff --git a/server/internal/activity/sql_store.go b/server/internal/activity/sql_store.go new file mode 100644 index 0000000..efcfcdf --- /dev/null +++ b/server/internal/activity/sql_store.go @@ -0,0 +1,192 @@ +package activity + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "strconv" + "time" +) + +const ( + defaultListLimit = 100 + maxListLimit = 500 + activityTable = "cms_activity" +) + +// SQLStore persists activity/log entries in a shared MariaDB table. Unlike the +// former embedded Badger store it holds no on-disk state and no directory lock, +// so multiple CMS processes (e.g. blue/green deploy slots) can write to and read +// from the same history concurrently. +type SQLStore struct { + db *sql.DB +} + +// NewSQLStore ensures the activity table exists and returns a store backed by +// the provided connection. It does not take ownership of db; Close is a no-op. +func NewSQLStore(ctx context.Context, db *sql.DB) (*SQLStore, error) { + if db == nil { + return nil, fmt.Errorf("activity: nil database connection") + } + if err := ensureActivityTable(ctx, db); err != nil { + return nil, err + } + return &SQLStore{db: db}, nil +} + +func ensureActivityTable(ctx context.Context, db *sql.DB) error { + _, err := db.ExecContext(ctx, ` + CREATE TABLE IF NOT EXISTS `+activityTable+` ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + ts DATETIME(6) NOT NULL, + level VARCHAR(16) NOT NULL DEFAULT '', + message TEXT NULL, + kind VARCHAR(32) NOT NULL DEFAULT '', + action VARCHAR(255) NOT NULL DEFAULT '', + actor_id BIGINT NOT NULL DEFAULT 0, + actor_name VARCHAR(255) NOT NULL DEFAULT '', + actor_role VARCHAR(64) NOT NULL DEFAULT '', + target TEXT NULL, + method VARCHAR(16) NOT NULL DEFAULT '', + path VARCHAR(512) NOT NULL DEFAULT '', + status INT NOT NULL DEFAULT 0, + attributes JSON NULL, + KEY idx_cms_activity_kind_id (kind, id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + `) + return err +} + +// Close is a no-op: the store borrows a *sql.DB owned by the caller. +func (s *SQLStore) Close() error { return nil } + +func (s *SQLStore) Write(ctx context.Context, entry Entry) error { + if s == nil || s.db == nil { + return ErrStoreUnavailable + } + + if entry.Timestamp.IsZero() { + entry.Timestamp = time.Now().UTC() + } else { + entry.Timestamp = entry.Timestamp.UTC() + } + if entry.Kind == "" { + if entry.Action != "" { + entry.Kind = "activity" + } else { + entry.Kind = "log" + } + } + + var attributes any + if len(entry.Attributes) > 0 { + payload, err := json.Marshal(entry.Attributes) + if err != nil { + return fmt.Errorf("marshal activity attributes: %w", err) + } + attributes = string(payload) + } + + // Note: do not log from this path — the default logger tees into this store, + // so logging here would recurse. + _, err := s.db.ExecContext(ctx, ` + INSERT INTO `+activityTable+` + (ts, level, message, kind, action, actor_id, actor_name, actor_role, target, method, path, status, attributes) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + entry.Timestamp, entry.Level, nullableString(entry.Message), entry.Kind, entry.Action, + entry.ActorID, entry.ActorName, entry.ActorRole, nullableString(entry.Target), + entry.Method, entry.Path, entry.Status, attributes, + ) + if err != nil { + return fmt.Errorf("insert activity entry: %w", err) + } + return nil +} + +func (s *SQLStore) List(ctx context.Context, query Query) (ListResult, error) { + if s == nil || s.db == nil { + return ListResult{}, ErrStoreUnavailable + } + + limit := query.Limit + if limit <= 0 { + limit = defaultListLimit + } + if limit > maxListLimit { + limit = maxListLimit + } + + where := "" + var args []any + if query.Kind != "" { + where = " WHERE kind = ?" + args = append(args, query.Kind) + } + + result := ListResult{Entries: make([]Entry, 0, limit)} + + if err := s.db.QueryRowContext(ctx, + "SELECT COUNT(*) FROM "+activityTable+where, args..., + ).Scan(&result.TotalCount); err != nil { + return ListResult{}, fmt.Errorf("count activity entries: %w", err) + } + + rows, err := s.db.QueryContext(ctx, + "SELECT id, ts, level, message, kind, action, actor_id, actor_name, actor_role, target, method, path, status, attributes "+ + "FROM "+activityTable+where+" ORDER BY id DESC LIMIT ?", + append(args, limit)..., + ) + if err != nil { + return ListResult{}, fmt.Errorf("query activity entries: %w", err) + } + defer rows.Close() + + for rows.Next() { + entry, err := scanActivityRow(rows) + if err != nil { + return ListResult{}, err + } + result.Entries = append(result.Entries, entry) + } + if err := rows.Err(); err != nil { + return ListResult{}, fmt.Errorf("iterate activity entries: %w", err) + } + + return result, nil +} + +func scanActivityRow(rows *sql.Rows) (Entry, error) { + var ( + entry Entry + id int64 + message sql.NullString + target sql.NullString + attributes sql.NullString + ) + if err := rows.Scan( + &id, &entry.Timestamp, &entry.Level, &message, &entry.Kind, &entry.Action, + &entry.ActorID, &entry.ActorName, &entry.ActorRole, &target, + &entry.Method, &entry.Path, &entry.Status, &attributes, + ); err != nil { + return Entry{}, fmt.Errorf("scan activity entry: %w", err) + } + + entry.ID = strconv.FormatInt(id, 10) + entry.Message = message.String + entry.Target = target.String + if attributes.Valid && attributes.String != "" { + if err := json.Unmarshal([]byte(attributes.String), &entry.Attributes); err != nil { + return Entry{}, fmt.Errorf("decode activity attributes: %w", err) + } + } + return entry, nil +} + +func nullableString(s string) any { + if s == "" { + return nil + } + return s +} diff --git a/server/main.go b/server/main.go index 8109c0b..4553ce1 100644 --- a/server/main.go +++ b/server/main.go @@ -32,9 +32,7 @@ const ( keyFilePath = "./certs/localhost.key" certFileEnv = "TLS_CERT_FILE" keyFileEnv = "TLS_KEY_FILE" - activityDBPathEnv = "ACTIVITY_DB_PATH" defaultShutdownTimeout = 10 * time.Second - defaultActivityDBPath = "./data/activity" ) type httpsServer interface { @@ -77,7 +75,22 @@ type runDeps struct { func main() { godotenv.Load(".env") - activityStore, err := activity.OpenBadgerStore(getenvOrDefault(activityDBPathEnv, defaultActivityDBPath)) + dbName, user, password, host, port, err := dbConfigFromEnv() + if err != nil { + slog.Error("invalid database configuration", "error", err) + os.Exit(1) + } + + db, err := database.InitializeConnection(context.Background(), dbName, user, password, host, port) + if err != nil { + slog.Error("database initialization failed", "error", err, "host", host, "port", port, "db_name", dbName) + os.Exit(1) + } + + // Activity/audit log is stored in the shared MariaDB database (not an embedded + // on-disk store), so multiple CMS instances can record and read the same + // history without holding an exclusive directory lock. + activityStore, err := activity.NewSQLStore(context.Background(), db) if err != nil { slog.Error("failed to initialize activity store", "error", err) os.Exit(1) @@ -91,18 +104,6 @@ func main() { )).With("service", "cms") slog.SetDefault(logger) - dbName, user, password, host, port, err := dbConfigFromEnv() - if err != nil { - slog.Error("invalid database configuration", "error", err) - os.Exit(1) - } - - db, err := database.InitializeConnection(context.Background(), dbName, user, password, host, port) - if err != nil { - slog.Error("database initialization failed", "error", err, "host", host, "port", port, "db_name", dbName) - os.Exit(1) - } - if err := database.EnsureArticlesSchema(context.Background(), db); err != nil { slog.Error("failed to migrate articles schema", "error", err) os.Exit(1) From 4eeeb398d35fa8ed66846306c9dc0ca2019a304f Mon Sep 17 00:00:00 2001 From: ssavutu Date: Fri, 24 Jul 2026 23:59:46 -0400 Subject: [PATCH 2/6] feat(editor): add article edit leases --- frontend/src/pages/editArticleView.tsx | 81 ++++++++++- server/internal/handlers/article_edit_lock.go | 90 ++++++++++++ server/internal/handlers/article_locks.go | 129 ++++++++++++++++++ .../internal/handlers/article_locks_test.go | 52 +++++++ server/internal/handlers/handlers.go | 8 ++ server/internal/routes/routes.go | 2 + 6 files changed, 361 insertions(+), 1 deletion(-) create mode 100644 server/internal/handlers/article_edit_lock.go create mode 100644 server/internal/handlers/article_locks.go create mode 100644 server/internal/handlers/article_locks_test.go diff --git a/frontend/src/pages/editArticleView.tsx b/frontend/src/pages/editArticleView.tsx index 7db5b0c..168cd49 100644 --- a/frontend/src/pages/editArticleView.tsx +++ b/frontend/src/pages/editArticleView.tsx @@ -1,4 +1,4 @@ -import { lazy, Suspense, useEffect, useMemo, useRef, useState } from "react" +import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react" import { ArrowLeft, Save, Image, Search, X } from "lucide-react" import { useNavigate, useParams } from "react-router-dom" import { useApiFetch } from "../hooks/useApiFetch" @@ -131,6 +131,11 @@ function EditArticleView() { const [isSaving, setIsSaving] = useState(false) const [error, setError] = useState(null) const [successMessage, setSuccessMessage] = useState(null) + // Name of another editor currently holding the edit lock, or null when we + // hold it (or it's a new article). When set, editing is blocked so nobody + // starts work they won't be able to save. + const [lockedBy, setLockedBy] = useState(null) + const [lockChecking, setLockChecking] = useState(false) const [title, setTitle] = useState("") const [excerpt, setExcerpt] = useState("") @@ -222,6 +227,54 @@ function EditArticleView() { } }, [apiFetch, slug, isNew]) + // Try to claim an advisory edit lock while this article is open. If someone + // else already holds it we surface who, block editing, and keep re-checking + // so the editor unblocks automatically once they leave. The lock is released + // on unmount (and on tab close via keepalive) and refreshed on a heartbeat so + // an abandoned session frees it after the server-side TTL. + const acquireLock = useCallback(async (): Promise => { + if (isNew || !slug) return + setLockChecking(true) + try { + const response = await apiFetch(`/v1/articles/${encodeURIComponent(slug)}/edit-lock`, { method: "PUT" }) + if (response.status === 409) { + const payload = (await response.json().catch(() => null)) as { holder_name?: string } | null + setLockedBy(payload?.holder_name?.trim() || "another editor") + return + } + // On success (or an unexpected error) don't block on an advisory lock. + setLockedBy(null) + } catch { + setLockedBy(null) + } finally { + setLockChecking(false) + } + }, [apiFetch, slug, isNew]) + + useEffect(() => { + if (isNew || !slug) return + let released = false + + void acquireLock() + const heartbeat = window.setInterval(() => { void acquireLock() }, 30_000) + + const release = () => { + if (released) return + released = true + void apiFetch(`/v1/articles/${encodeURIComponent(slug)}/edit-lock`, { + method: "DELETE", + keepalive: true, + }).catch(() => {}) + } + window.addEventListener("beforeunload", release) + + return () => { + window.clearInterval(heartbeat) + window.removeEventListener("beforeunload", release) + release() + } + }, [apiFetch, slug, isNew, acquireLock]) + useEffect(() => { let cancelled = false @@ -423,6 +476,32 @@ function EditArticleView() {
Loading article...
+ ) : lockedBy ? ( +
+

This article is being edited

+

+ {lockedBy} is currently editing this article. + To avoid overwriting each other's work, editing is locked until they're done. This page will + unlock automatically once they leave. +

+
+ + +
+
) : (
{/* Main content */} diff --git a/server/internal/handlers/article_edit_lock.go b/server/internal/handlers/article_edit_lock.go new file mode 100644 index 0000000..08319c8 --- /dev/null +++ b/server/internal/handlers/article_edit_lock.go @@ -0,0 +1,90 @@ +package handlers + +import ( + "database/sql" + "net/http" + "strings" + "time" + + "server/internal/middleware" +) + +// articleEditLockResponse describes who currently holds the advisory editing +// lease for an article. +type articleEditLockResponse struct { + Slug string `json:"slug"` + HeldBySelf bool `json:"held_by_self"` + HolderID int64 `json:"holder_id"` + HolderName string `json:"holder_name"` + ExpiresAt string `json:"expires_at"` +} + +// AcquireArticleEditLock grants or refreshes the caller's editing lease for an +// article. The editor calls this when it opens (and periodically after) so a +// second person is told up front that someone else is already editing, instead +// of doing work they can't save. It returns 200 when the caller holds the lease +// and 409 (with the current holder) when someone else does. +// +// @Summary Acquire or refresh the editing lock for an article +// @Tags articles +// @Produce json +// @Param slug path string true "Article slug" +// @Success 200 {object} articleEditLockResponse +// @Failure 400 {object} models.ErrorResponse +// @Failure 409 {object} articleEditLockResponse +// @Security BearerAuth +// @Router /v1/articles/{slug}/edit-lock [put] +func AcquireArticleEditLock(conn *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + slug := strings.TrimSpace(r.PathValue("slug")) + if !isValidCanonicalSlug(slug) { + writeError(w, http.StatusBadRequest, "slug must be canonical") + return + } + user, ok := middleware.UserFromContext(r.Context()) + if !ok { + writeError(w, http.StatusForbidden, "forbidden") + return + } + lease, granted := articleEditLeases.Acquire(slug, user.ID, user.Name) + status := http.StatusOK + if !granted { + status = http.StatusConflict + } + writeJSON(w, status, articleEditLockResponse{ + Slug: slug, + HeldBySelf: granted, + HolderID: lease.HolderID, + HolderName: lease.HolderName, + ExpiresAt: lease.ExpiresAt.UTC().Format(time.RFC3339), + }) + } +} + +// ReleaseArticleEditLock drops the caller's editing lease for an article, called +// when the editor is closed. It is idempotent and only releases a lease the +// caller actually holds. +// +// @Summary Release the editing lock for an article +// @Tags articles +// @Param slug path string true "Article slug" +// @Success 204 +// @Failure 400 {object} models.ErrorResponse +// @Security BearerAuth +// @Router /v1/articles/{slug}/edit-lock [delete] +func ReleaseArticleEditLock(conn *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + slug := strings.TrimSpace(r.PathValue("slug")) + if !isValidCanonicalSlug(slug) { + writeError(w, http.StatusBadRequest, "slug must be canonical") + return + } + user, ok := middleware.UserFromContext(r.Context()) + if !ok { + writeError(w, http.StatusForbidden, "forbidden") + return + } + articleEditLeases.Release(slug, user.ID) + w.WriteHeader(http.StatusNoContent) + } +} diff --git a/server/internal/handlers/article_locks.go b/server/internal/handlers/article_locks.go new file mode 100644 index 0000000..5dd8ce0 --- /dev/null +++ b/server/internal/handlers/article_locks.go @@ -0,0 +1,129 @@ +package handlers + +import ( + "sync" + "time" +) + +// articleLocks provides per-slug mutual exclusion so that concurrent edits to +// the same article are serialized instead of racing on the row and its derived +// taxonomy counts. Locks are keyed by slug and reference-counted so idle slugs +// don't accumulate in memory. +type articleLockRegistry struct { + mu sync.Mutex + locks map[string]*articleLock +} + +type articleLock struct { + mu sync.Mutex + refs int +} + +func newArticleLockRegistry() *articleLockRegistry { + return &articleLockRegistry{locks: make(map[string]*articleLock)} +} + +// Lock acquires the mutex for the given slug and returns an unlock function that +// must be called to release it. +func (r *articleLockRegistry) Lock(slug string) func() { + r.mu.Lock() + l, ok := r.locks[slug] + if !ok { + l = &articleLock{} + r.locks[slug] = l + } + l.refs++ + r.mu.Unlock() + + l.mu.Lock() + + return func() { + l.mu.Unlock() + + r.mu.Lock() + l.refs-- + if l.refs == 0 { + delete(r.locks, slug) + } + r.mu.Unlock() + } +} + +// articleEditLocks is the process-wide registry guarding article mutations. +var articleEditLocks = newArticleLockRegistry() + +// articleEditLeaseTTL is how long an editing lease stays valid without a +// heartbeat. The frontend refreshes well within this window; once it lapses +// (e.g. the editor closed the tab without releasing), the lease is treated as +// free so the article isn't locked forever. +const articleEditLeaseTTL = 90 * time.Second + +// articleEditLease records who currently holds the advisory editing lease for +// an article and when it expires. +type articleEditLease struct { + HolderID int64 + HolderName string + ExpiresAt time.Time +} + +// articleEditLeaseRegistry tracks advisory "someone is editing this" leases, +// keyed by slug. It is separate from articleLockRegistry: the lock serializes +// the brief write path, while a lease spans a human's whole editing session so +// a second editor is warned before they start rather than at save time. +type articleEditLeaseRegistry struct { + mu sync.Mutex + leases map[string]articleEditLease +} + +func newArticleEditLeaseRegistry() *articleEditLeaseRegistry { + return &articleEditLeaseRegistry{leases: make(map[string]articleEditLease)} +} + +// Acquire grants or refreshes the lease for slug to the given holder. It returns +// the current lease and whether the caller now holds it. When another holder's +// lease is still valid, the caller is not granted the lease and the existing +// holder's lease is returned so the caller can be told who to wait for. +func (r *articleEditLeaseRegistry) Acquire(slug string, holderID int64, holderName string) (articleEditLease, bool) { + now := time.Now() + + r.mu.Lock() + defer r.mu.Unlock() + + r.sweepExpiredLocked(now) + + if existing, ok := r.leases[slug]; ok && existing.HolderID != holderID && existing.ExpiresAt.After(now) { + return existing, false + } + + lease := articleEditLease{ + HolderID: holderID, + HolderName: holderName, + ExpiresAt: now.Add(articleEditLeaseTTL), + } + r.leases[slug] = lease + return lease, true +} + +// Release drops the lease for slug only if it is held by holderID, so a stale +// release from a previous holder can't free a lease someone else has taken over. +func (r *articleEditLeaseRegistry) Release(slug string, holderID int64) { + r.mu.Lock() + defer r.mu.Unlock() + + if existing, ok := r.leases[slug]; ok && existing.HolderID == holderID { + delete(r.leases, slug) + } +} + +// sweepExpiredLocked removes lapsed leases so abandoned slugs don't accumulate. +// Callers must hold r.mu. +func (r *articleEditLeaseRegistry) sweepExpiredLocked(now time.Time) { + for slug, lease := range r.leases { + if !lease.ExpiresAt.After(now) { + delete(r.leases, slug) + } + } +} + +// articleEditLeases is the process-wide registry of editing leases. +var articleEditLeases = newArticleEditLeaseRegistry() diff --git a/server/internal/handlers/article_locks_test.go b/server/internal/handlers/article_locks_test.go new file mode 100644 index 0000000..89f1dd3 --- /dev/null +++ b/server/internal/handlers/article_locks_test.go @@ -0,0 +1,52 @@ +package handlers + +import ( + "testing" + "time" +) + +func TestArticleEditLeaseAcquireAndRelease(t *testing.T) { + r := newArticleEditLeaseRegistry() + + // First holder gets the lease. + if _, granted := r.Acquire("slug-a", 1, "Alice"); !granted { + t.Fatal("first acquire should be granted") + } + + // A different user is blocked and sees the current holder. + lease, granted := r.Acquire("slug-a", 2, "Bob") + if granted { + t.Fatal("second holder should be blocked") + } + if lease.HolderID != 1 || lease.HolderName != "Alice" { + t.Fatalf("expected Alice to hold the lease, got id=%d name=%q", lease.HolderID, lease.HolderName) + } + + // The holder can refresh their own lease. + if _, granted := r.Acquire("slug-a", 1, "Alice"); !granted { + t.Fatal("holder should be able to refresh") + } + + // A stale release from a non-holder must not free the lease. + r.Release("slug-a", 2) + if _, granted := r.Acquire("slug-a", 3, "Carol"); granted { + t.Fatal("non-holder release should not have freed the lease") + } + + // The real holder releasing frees it for the next editor. + r.Release("slug-a", 1) + if _, granted := r.Acquire("slug-a", 3, "Carol"); !granted { + t.Fatal("lease should be free after holder released") + } +} + +func TestArticleEditLeaseExpiry(t *testing.T) { + r := newArticleEditLeaseRegistry() + + // Seed an already-expired lease for another holder. + r.leases["slug-b"] = articleEditLease{HolderID: 9, HolderName: "Old", ExpiresAt: time.Now().Add(-time.Minute)} + + if _, granted := r.Acquire("slug-b", 5, "New"); !granted { + t.Fatal("expired lease should be treated as free") + } +} diff --git a/server/internal/handlers/handlers.go b/server/internal/handlers/handlers.go index 9220b07..952fb91 100644 --- a/server/internal/handlers/handlers.go +++ b/server/internal/handlers/handlers.go @@ -1526,6 +1526,8 @@ func PutArticle(conn *sql.DB) http.HandlerFunc { writeError(w, http.StatusBadRequest, "slug must be canonical") return } + unlock := articleEditLocks.Lock(slug) + defer unlock() if user, ok := middleware.UserFromContext(r.Context()); ok && user.Role != models.RoleAdmin { if user.AuthorID == nil { writeError(w, http.StatusForbidden, "forbidden") @@ -1614,6 +1616,8 @@ func PatchArticle(conn *sql.DB) http.HandlerFunc { writeError(w, http.StatusBadRequest, "slug must be canonical") return } + unlock := articleEditLocks.Lock(slug) + defer unlock() if user, ok := middleware.UserFromContext(r.Context()); ok && user.Role != models.RoleAdmin { if user.AuthorID == nil { writeError(w, http.StatusForbidden, "forbidden") @@ -1811,6 +1815,8 @@ func DeleteArticle(conn *sql.DB) http.HandlerFunc { writeError(w, http.StatusBadRequest, "slug must be canonical") return } + unlock := articleEditLocks.Lock(slug) + defer unlock() user, ok := middleware.UserFromContext(r.Context()) if !ok || (user.Role != models.RoleAdmin && user.Role != models.RoleEditor) { writeError(w, http.StatusForbidden, "forbidden") @@ -1858,6 +1864,8 @@ func RestoreArticle(conn *sql.DB) http.HandlerFunc { writeError(w, http.StatusBadRequest, "slug must be canonical") return } + unlock := articleEditLocks.Lock(slug) + defer unlock() user, ok := middleware.UserFromContext(r.Context()) if !ok || (user.Role != models.RoleAdmin && user.Role != models.RoleEditor) { writeError(w, http.StatusForbidden, "forbidden") diff --git a/server/internal/routes/routes.go b/server/internal/routes/routes.go index 3ea5136..c329095 100644 --- a/server/internal/routes/routes.go +++ b/server/internal/routes/routes.go @@ -84,5 +84,7 @@ func Register(mux *http.ServeMux, conn *sql.DB, verifier *oidc.IDTokenVerifier, mux.Handle("PATCH /v1/articles/{slug}", authMW(handlers.PatchArticle(conn))) mux.Handle("PATCH /v1/articles/{slug}/restore", authMW(handlers.RestoreArticle(conn))) mux.Handle("DELETE /v1/articles/{slug}", authMW(handlers.DeleteArticle(conn))) + mux.Handle("PUT /v1/articles/{slug}/edit-lock", authMW(handlers.AcquireArticleEditLock(conn))) + mux.Handle("DELETE /v1/articles/{slug}/edit-lock", authMW(handlers.ReleaseArticleEditLock(conn))) } } From fea89a5f30e6229c5e04ec58ff855c46346c2438 Mon Sep 17 00:00:00 2001 From: ssavutu Date: Sat, 25 Jul 2026 00:00:12 -0400 Subject: [PATCH 3/6] feat(server): support internal HTTP behind reverse proxy --- server/main.go | 51 ++++++++++++++++++++++++++----------- server/main_test.go | 61 ++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 94 insertions(+), 18 deletions(-) diff --git a/server/main.go b/server/main.go index 4553ce1..af5631e 100644 --- a/server/main.go +++ b/server/main.go @@ -32,10 +32,14 @@ const ( keyFilePath = "./certs/localhost.key" certFileEnv = "TLS_CERT_FILE" keyFileEnv = "TLS_KEY_FILE" + serverModeEnv = "CMS_SERVER_MODE" + serverModeHTTPS = "https" + serverModeInternalHTTP = "internal-http" defaultShutdownTimeout = 10 * time.Second ) type httpsServer interface { + ListenAndServe() error ListenAndServeTLS(certFile, keyFile string) error Shutdown(ctx context.Context) error Close() error @@ -47,7 +51,7 @@ type stdHTTPServer struct { type runDeps struct { loadX509KeyPair func(certFile, keyFile string) (tls.Certificate, error) - newServer func(cert tls.Certificate, mux *http.ServeMux, logger *slog.Logger) httpsServer + newServer func(cert *tls.Certificate, mux *http.ServeMux, logger *slog.Logger) httpsServer signalCh <-chan os.Signal signalNotify func(c chan<- os.Signal, sig ...os.Signal) signalStop func(c chan<- os.Signal) @@ -236,15 +240,17 @@ func defaultRunDeps(verifier *oidc.IDTokenVerifier, oidcCfg auth.OIDCConfig) run } } -func newDefaultServer(cert tls.Certificate, mux *http.ServeMux, logger *slog.Logger) httpsServer { +func newDefaultServer(cert *tls.Certificate, mux *http.ServeMux, logger *slog.Logger) httpsServer { + tlsConfig := (*tls.Config)(nil) + if cert != nil { + tlsConfig = &tls.Config{Certificates: []tls.Certificate{*cert}} + } return &stdHTTPServer{ Server: &http.Server{ - Addr: ":8080", - Handler: middleware.Chain(mux, middleware.Logging, middleware.Recovery), - TLSConfig: &tls.Config{ - Certificates: []tls.Certificate{cert}, - }, - ErrorLog: slog.NewLogLogger(logger.Handler(), slog.LevelError), + Addr: ":8080", + Handler: middleware.Chain(mux, middleware.Logging, middleware.Recovery), + TLSConfig: tlsConfig, + ErrorLog: slog.NewLogLogger(logger.Handler(), slog.LevelError), }, } } @@ -266,12 +272,24 @@ func run(deps runDeps, conn *sql.DB) error { deps.shutdownTimeout = defaultShutdownTimeout } - certPath := getenvOrDefault(certFileEnv, certFilePath) - keyPath := getenvOrDefault(keyFileEnv, keyFilePath) + mode := strings.TrimSpace(os.Getenv(serverModeEnv)) + if mode == "" { + mode = serverModeHTTPS + } + if mode != serverModeHTTPS && mode != serverModeInternalHTTP { + return fmt.Errorf("%s must be %q or %q, got %q", serverModeEnv, serverModeHTTPS, serverModeInternalHTTP, mode) + } - cert, err := deps.loadX509KeyPair(certPath, keyPath) - if err != nil { - return fmt.Errorf("tls certificate load failed: %w", err) + var cert *tls.Certificate + if mode == serverModeHTTPS { + certPath := getenvOrDefault(certFileEnv, certFilePath) + keyPath := getenvOrDefault(keyFileEnv, keyFilePath) + + loadedCert, err := deps.loadX509KeyPair(certPath, keyPath) + if err != nil { + return fmt.Errorf("tls certificate load failed: %w", err) + } + cert = &loadedCert } mux := http.NewServeMux() @@ -280,7 +298,12 @@ func run(deps runDeps, conn *sql.DB) error { serverErr := make(chan error, 1) go func() { - err := server.ListenAndServeTLS("", "") + var err error + if mode == serverModeInternalHTTP { + err = server.ListenAndServe() + } else { + err = server.ListenAndServeTLS("", "") + } if err != nil && !errors.Is(err, http.ErrServerClosed) { serverErr <- err return diff --git a/server/main_test.go b/server/main_test.go index ce7f3da..4801f03 100644 --- a/server/main_test.go +++ b/server/main_test.go @@ -14,6 +14,7 @@ import ( ) type fakeServer struct { + listenHTTPFn func() error listenFn func(certFile, keyFile string) error shutdownFn func(ctx context.Context) error closeFn func() error @@ -21,6 +22,13 @@ type fakeServer struct { closeCalled bool } +func (f *fakeServer) ListenAndServe() error { + if f.listenHTTPFn != nil { + return f.listenHTTPFn() + } + return nil +} + func (f *fakeServer) ListenAndServeTLS(certFile, keyFile string) error { if f.listenFn != nil { return f.listenFn(certFile, keyFile) @@ -50,7 +58,7 @@ func TestRun_TLSLoadFailure(t *testing.T) { loadX509KeyPair: func(certFile, keyFile string) (tls.Certificate, error) { return tls.Certificate{}, errors.New("bad certificate") }, - newServer: func(cert tls.Certificate, mux *http.ServeMux, logger *slog.Logger) httpsServer { + newServer: func(cert *tls.Certificate, mux *http.ServeMux, logger *slog.Logger) httpsServer { newServerCalled = true return &fakeServer{} }, @@ -68,6 +76,51 @@ func TestRun_TLSLoadFailure(t *testing.T) { } } +func TestRun_InternalHTTPModeSkipsTLSLoad(t *testing.T) { + t.Setenv(serverModeEnv, serverModeInternalHTTP) + + loadTLSCalled := false + var gotCert *tls.Certificate + srv := &fakeServer{} + + err := run(runDeps{ + loadX509KeyPair: func(certFile, keyFile string) (tls.Certificate, error) { + loadTLSCalled = true + return tls.Certificate{}, nil + }, + newServer: func(cert *tls.Certificate, mux *http.ServeMux, logger *slog.Logger) httpsServer { + gotCert = cert + return srv + }, + signalCh: make(chan os.Signal, 1), + }, nil) + + if err != nil { + t.Fatalf("expected nil error, got %v", err) + } + if loadTLSCalled { + t.Fatal("expected internal HTTP mode not to load TLS certificates") + } + if gotCert != nil { + t.Fatal("expected no TLS certificate in internal HTTP mode") + } +} + +func TestRun_InvalidServerMode(t *testing.T) { + t.Setenv(serverModeEnv, "plain-http") + + err := run(runDeps{ + signalCh: make(chan os.Signal, 1), + }, nil) + + if err == nil { + t.Fatal("expected an error, got nil") + } + if !strings.Contains(err.Error(), serverModeEnv) { + t.Fatalf("expected server mode error, got %v", err) + } +} + func TestRun_TLSLoadPathsFromEnv(t *testing.T) { t.Setenv(certFileEnv, "/tmp/custom-cert.crt") t.Setenv(keyFileEnv, "/tmp/custom-key.key") @@ -105,7 +158,7 @@ func TestRun_ServerExitError(t *testing.T) { loadX509KeyPair: func(certFile, keyFile string) (tls.Certificate, error) { return tls.Certificate{}, nil }, - newServer: func(cert tls.Certificate, mux *http.ServeMux, logger *slog.Logger) httpsServer { + newServer: func(cert *tls.Certificate, mux *http.ServeMux, logger *slog.Logger) httpsServer { return srv }, signalCh: make(chan os.Signal, 1), @@ -142,7 +195,7 @@ func TestRun_GracefulShutdownOnSignal(t *testing.T) { loadX509KeyPair: func(certFile, keyFile string) (tls.Certificate, error) { return tls.Certificate{}, nil }, - newServer: func(cert tls.Certificate, mux *http.ServeMux, logger *slog.Logger) httpsServer { + newServer: func(cert *tls.Certificate, mux *http.ServeMux, logger *slog.Logger) httpsServer { return srv }, signalCh: sigCh, @@ -183,7 +236,7 @@ func TestRun_ShutdownFailureCallsClose(t *testing.T) { loadX509KeyPair: func(certFile, keyFile string) (tls.Certificate, error) { return tls.Certificate{}, nil }, - newServer: func(cert tls.Certificate, mux *http.ServeMux, logger *slog.Logger) httpsServer { + newServer: func(cert *tls.Certificate, mux *http.ServeMux, logger *slog.Logger) httpsServer { return srv }, signalCh: sigCh, From d2ba74e18e1cab84e0b46c7828366e467c899ef3 Mon Sep 17 00:00:00 2001 From: ssavutu Date: Sat, 25 Jul 2026 00:00:53 -0400 Subject: [PATCH 4/6] build(frontend): add production container image --- frontend/.dockerignore | 7 +++++++ frontend/Dockerfile | 19 +++++++++++++++++++ frontend/nginx.conf | 17 +++++++++++++++++ frontend/src/auth/urls.ts | 2 +- 4 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 frontend/.dockerignore create mode 100644 frontend/Dockerfile create mode 100644 frontend/nginx.conf diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..bf23364 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,7 @@ +node_modules/ +dist/ +.env +.env.* +npm-debug.log* +Dockerfile +README.md diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..4543977 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,19 @@ +FROM node:20-alpine AS builder +WORKDIR /app + +ARG VITE_PUBLIC_SITE_URL="https://www.thetriangle.org" +ENV VITE_PUBLIC_SITE_URL=${VITE_PUBLIC_SITE_URL} + +COPY package.json package-lock.json ./ +RUN npm ci --include=dev --no-audit --no-fund + +COPY . . +RUN npm run build + +FROM nginx:1.27-alpine +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=builder /app/dist /usr/share/nginx/html + +EXPOSE 8080 +HEALTHCHECK --interval=20s --timeout=5s --retries=5 --start-period=10s \ + CMD wget -q -O /dev/null http://127.0.0.1:8080/healthz || exit 1 diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..70647ab --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,17 @@ +server { + listen 8080; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + location = /healthz { + access_log off; + add_header Content-Type text/plain; + return 204; + } + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/frontend/src/auth/urls.ts b/frontend/src/auth/urls.ts index ff838c5..df3a2d0 100644 --- a/frontend/src/auth/urls.ts +++ b/frontend/src/auth/urls.ts @@ -7,7 +7,7 @@ export function apiBaseUrl() { } export function authBaseUrl() { - return trimTrailingSlashes(import.meta.env.VITE_AUTH_BASE_URL ?? "https://localhost:8080") + return trimTrailingSlashes(import.meta.env.VITE_AUTH_BASE_URL ?? "") } // Public-facing site origin, used to build article permalinks (e.g. so Yoast can From 9c4cf1778fc06cf9550323efae81fe8dbcaeaff1 Mon Sep 17 00:00:00 2001 From: ssavutu Date: Sat, 25 Jul 2026 00:02:08 -0400 Subject: [PATCH 5/6] feat(deploy): add transactional blue-green deployment --- .env.example | 9 +- .gitignore | 3 + deploy/README.md | 166 +++++++++++ deploy/cms.env.example | 16 ++ deploy/compose.cms.yml | 77 ++++++ deploy/github-production.env.example | 3 + deploy/mariadb/README.md | 113 ++++++++ ...triangle-cms-active-upstreams.conf.example | 5 + deploy/nginx/triangle-cms.conf | 48 ++++ deploy/scripts/common.sh | 261 ++++++++++++++++++ deploy/scripts/deploy.sh | 54 ++++ deploy/scripts/deploy_scripts_test.sh | 216 +++++++++++++++ deploy/scripts/rollback.sh | 36 +++ 13 files changed, 1004 insertions(+), 3 deletions(-) create mode 100644 deploy/README.md create mode 100644 deploy/cms.env.example create mode 100644 deploy/compose.cms.yml create mode 100644 deploy/github-production.env.example create mode 100644 deploy/mariadb/README.md create mode 100644 deploy/nginx/triangle-cms-active-upstreams.conf.example create mode 100644 deploy/nginx/triangle-cms.conf create mode 100755 deploy/scripts/common.sh create mode 100755 deploy/scripts/deploy.sh create mode 100755 deploy/scripts/deploy_scripts_test.sh create mode 100755 deploy/scripts/rollback.sh diff --git a/.env.example b/.env.example index 505684f..1b0533b 100644 --- a/.env.example +++ b/.env.example @@ -3,14 +3,17 @@ MARIADB_PASSWORD=change-this-db-password GRAFANA_ADMIN_USER=change-this-admin-user GRAFANA_ADMIN_PASSWORD=change-this-admin-password -# Authentik OIDC — required for auth on write endpoints -# Get these from your Authentik OAuth2/OIDC provider application -OIDC_ISSUER_URL=https://auth.thetriangle.org/application/o// +# Authentik OIDC - required for auth on write endpoints. +OIDC_ISSUER_URL= OIDC_CLIENT_ID= OIDC_CLIENT_SECRET= CMS_AUTO_PROMOTE_ALL_ADMINS=false CMS_REBUILD_TAXONOMY_COUNTS_ON_STARTUP=false +# Delta production variables live in deploy/cms.env.example. Do not put +# production server addresses, DB passwords, OIDC secrets, runner tokens, +# certificates, or deployment env files in git. + # Optional overrides # MARIADB_DATABASE=triangle # MARIADB_USER=triangle_user diff --git a/.gitignore b/.gitignore index 580246a..dad0e62 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,7 @@ /.env frontend/.env server/data/ +deploy/*.env +!deploy/*.env.example +deploy/runner/ ..env.un~ diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..fcb7302 --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,166 @@ +# Triangle CMS Delta Deployment + +This directory describes the production deployment shape for Delta. It is split +from local development and from database/proxy infrastructure on purpose. + +## Runtime Topology + +Delta runs: + +- Host Nginx on HTTP port 80. +- Self-hosted GitHub Actions runner with labels `drexel-vpn`, `delta`, and + `triangle-cms`. +- Blue frontend container on `127.0.0.1:8091`. +- Blue backend container on `127.0.0.1:8081`. +- Green frontend container on `127.0.0.1:8092`. +- Green backend container on `127.0.0.1:8082`. + +Delta does not run MariaDB, MaxScale, Grafana, Loki, or Promtail in the CMS +deployment Compose project. The backend connects to the external database/proxy +endpoint supplied by `DB_HOST` and `DB_PORT` in the host-only `cms.env`. + +Nginx serves whichever frontend slot is active and proxies `/v1`, `/swagger`, +and `/swagger/` to the matching backend slot. The initial Nginx config listens on +HTTP with `server_name _`, so it works through Delta's VPN IP or hostname before +a public domain exists. + +Later, when `cms.thetriangle.org` is ready, update the host Nginx site with that +`server_name`, configure HTTPS certificates, update `FRONTEND_ORIGIN` and +`OIDC_REDIRECT_URI` in `cms.env`, and update the GitHub environment variable +`DELTA_PUBLIC_BASE_URL`. The backend should remain in `CMS_SERVER_MODE=internal-http` +behind Nginx. + +## Files + +- `compose.cms.yml` - Delta-only blue/green frontend/backend slots. +- `cms.env.example` - sanitized variable-name-only production env template. +- `nginx/triangle-cms.conf` - host Nginx site template. +- `nginx/triangle-cms-active-upstreams.conf.example` - generated include seed. +- `scripts/deploy.sh` - deploy exact SHA to inactive slot, switch, smoke test. +- `scripts/rollback.sh` - explicit rollback to the other slot or named slot. + +## One-Time Server Bootstrap + +A GitHub workflow cannot safely install and register its own runner. Bootstrap is +a manual server task: + +1. Install Docker, the Docker Compose plugin, Nginx, curl or wget, and flock. +2. Create a least-privilege local user for deployments. +3. Register a self-hosted GitHub Actions runner on Delta inside the Drexel VPN. + Apply the labels `drexel-vpn`, `delta`, and `triangle-cms`. +4. Allow the runner user to run Docker and reload/test Nginx. Prefer narrow + sudoers rules for `nginx -t` and `nginx -s reload`. +5. Place the host-only production env file at the path configured by + `DELTA_CMS_ENV_FILE`. Do not put it in git. +6. Install `nginx/triangle-cms.conf` as an enabled Nginx site. +7. Seed `/etc/nginx/triangle-cms-active-upstreams.conf` from the example include. +8. Validate Nginx and reload it once during bootstrap. +9. Confirm the runner can pull GHCR images and run `docker compose`. + +## Required Host Environment + +Copy `cms.env.example` to the private host env path and fill it with real values. +The file must contain the exact immutable image tag for the active deployment: + +- `CMS_IMAGE_TAG` +- `CMS_BACKEND_IMAGE` +- `CMS_FRONTEND_IMAGE` +- `DB_NAME` +- `DB_USER` +- `DB_PASSWORD` +- `DB_HOST` +- `DB_PORT` +- `OIDC_ISSUER_URL` +- `OIDC_CLIENT_ID` +- `OIDC_CLIENT_SECRET` +- `FRONTEND_ORIGIN` +- `OIDC_REDIRECT_URI` +- `CMS_SESSION_TTL_SECONDS` +- `CMS_AUTO_PROMOTE_ALL_ADMINS` +- `CMS_REBUILD_TAXONOMY_COUNTS_ON_STARTUP` + +Keep `CMS_AUTO_PROMOTE_ALL_ADMINS=false` and +`CMS_REBUILD_TAXONOMY_COUNTS_ON_STARTUP=false` in production. Rebuild taxonomy +through the admin endpoint after deploys when needed. + +## Required GitHub Production Environment Variables + +Configure these as GitHub Environment variables for `production`: + +- `DELTA_CMS_ENV_FILE` - absolute path to the host-only `cms.env`. +- `DELTA_NGINX_ACTIVE_INCLUDE` - usually `/etc/nginx/triangle-cms-active-upstreams.conf`. +- `DELTA_PUBLIC_BASE_URL` - initial HTTP VPN URL or hostname for smoke tests. + +Production database passwords, OIDC secrets, runner registration tokens, +certificates, and server addresses must not be exposed to pull-request workflows. +The deploy workflow runs only on the labelled self-hosted runner and uses the +host env file. + +## Deployment + +Images are immutable and tagged only with the full commit SHA: + +- `ghcr.io/drexeltriangle/triangle-cms-backend:` +- `ghcr.io/drexeltriangle/triangle-cms-frontend:` + +Automatic publish runs only after a successful CI workflow for a trusted push to +`main`. It publishes backend and frontend images tagged with that exact commit +SHA. Manual publish is intentionally unsupported. + +The deploy workflow checks out trusted deployment code from the protected default +branch. The image SHA is data only: it is never used as an Actions checkout ref, +script path, Compose-file source, env-file source, or executable source. + +The deploy workflow runs: + +```bash +deploy/scripts/deploy.sh +``` + +The script: + +- Acquires an exclusive `flock`. +- Reads the active slot from the Nginx include. +- Pulls the exact frontend/backend SHA images. +- Starts only the inactive frontend/backend services. +- Waits for backend `/v1/health/db` and frontend `/healthz`. +- Writes the active Nginx include atomically. +- Runs `nginx -t` and gracefully reloads Nginx. +- Runs public smoke tests through Nginx. +- Switches back automatically if post-switch smoke tests fail. +- Keeps the previous slot running for fast rollback. + +It never runs `docker compose down -v` and never deletes persistent data. + +## Rollback + +Rollback switches Nginx back to the previous running slot: + +```bash +deploy/scripts/rollback.sh +``` + +You can also name a target slot: + +```bash +deploy/scripts/rollback.sh blue +deploy/scripts/rollback.sh green +``` + +For recovery to an older image SHA, manually run the deploy workflow with that +full SHA. The backend and frontend images for that SHA must already exist in +GHCR. Manual deployment starts the inactive slot with those immutable images and +then switches traffic after health checks. + +## Stateful and Rollback Notes + +The backend runs additive, idempotent startup schema operations such as +`CREATE TABLE IF NOT EXISTS`, `ADD COLUMN IF NOT EXISTS`, and one guarded SEO +backfill. Rollback is safe only while database changes stay backward-compatible. +Do not deploy destructive migrations without a backup and a tested restoration +plan. + +Activity/audit state is in MariaDB. Article edit leases and IP rate-limit +counters remain in process memory; they can reset during a release switch. Shared +locking/rate limiting should move to MariaDB or Redis later, but that is outside +this CI/CD implementation. diff --git a/deploy/cms.env.example b/deploy/cms.env.example new file mode 100644 index 0000000..f69e63d --- /dev/null +++ b/deploy/cms.env.example @@ -0,0 +1,16 @@ +CMS_IMAGE_TAG= +CMS_BACKEND_IMAGE= +CMS_FRONTEND_IMAGE= +DB_NAME= +DB_USER= +DB_PASSWORD= +DB_HOST= +DB_PORT= +OIDC_ISSUER_URL= +OIDC_CLIENT_ID= +OIDC_CLIENT_SECRET= +FRONTEND_ORIGIN= +OIDC_REDIRECT_URI= +CMS_SESSION_TTL_SECONDS= +CMS_AUTO_PROMOTE_ALL_ADMINS= +CMS_REBUILD_TAXONOMY_COUNTS_ON_STARTUP= diff --git a/deploy/compose.cms.yml b/deploy/compose.cms.yml new file mode 100644 index 0000000..5858821 --- /dev/null +++ b/deploy/compose.cms.yml @@ -0,0 +1,77 @@ +# Delta application stack: two complete release slots. Delta runs only the +# frontend containers, backend containers, host Nginx, and the self-hosted +# GitHub Actions runner. MariaDB/MaxScale live elsewhere; the backend receives +# their reachable address through DB_* environment variables. +# +# Deployments start the inactive slot, wait for both container health checks, +# then atomically switch host Nginx to the matching frontend/backend pair. +# +# Required host-only env file: cms.env. It must not be committed. + +name: triangle-cms + +x-backend-base: &backend-base + image: ${CMS_BACKEND_IMAGE:-ghcr.io/drexeltriangle/triangle-cms-backend}:${CMS_IMAGE_TAG:?CMS_IMAGE_TAG is required} + restart: unless-stopped + stop_grace_period: 15s + environment: + CMS_SERVER_MODE: internal-http + DB_NAME: ${DB_NAME:?DB_NAME is required} + DB_USER: ${DB_USER:?DB_USER is required} + DB_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required} + DB_HOST: ${DB_HOST:?DB_HOST is required} + DB_PORT: ${DB_PORT:-3306} + OIDC_ISSUER_URL: ${OIDC_ISSUER_URL:?OIDC_ISSUER_URL is required} + OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:?OIDC_CLIENT_ID is required} + OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:?OIDC_CLIENT_SECRET is required} + FRONTEND_ORIGIN: ${FRONTEND_ORIGIN:?FRONTEND_ORIGIN is required} + OIDC_REDIRECT_URI: ${OIDC_REDIRECT_URI:?OIDC_REDIRECT_URI is required} + CMS_SESSION_TTL_SECONDS: ${CMS_SESSION_TTL_SECONDS:-604800} + CMS_AUTO_PROMOTE_ALL_ADMINS: ${CMS_AUTO_PROMOTE_ALL_ADMINS:-false} + CMS_REBUILD_TAXONOMY_COUNTS_ON_STARTUP: ${CMS_REBUILD_TAXONOMY_COUNTS_ON_STARTUP:-false} + healthcheck: + test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:8080/v1/health/db || exit 1"] + interval: 10s + timeout: 5s + retries: 12 + start_period: 20s + networks: + - triangle_net + +x-frontend-base: &frontend-base + image: ${CMS_FRONTEND_IMAGE:-ghcr.io/drexeltriangle/triangle-cms-frontend}:${CMS_IMAGE_TAG:?CMS_IMAGE_TAG is required} + restart: unless-stopped + stop_grace_period: 10s + healthcheck: + test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:8080/healthz || exit 1"] + interval: 10s + timeout: 5s + retries: 12 + start_period: 10s + networks: + - triangle_net + +services: + backend-blue: + <<: *backend-base + ports: + - "127.0.0.1:8081:8080" + + frontend-blue: + <<: *frontend-base + ports: + - "127.0.0.1:8091:8080" + + backend-green: + <<: *backend-base + ports: + - "127.0.0.1:8082:8080" + + frontend-green: + <<: *frontend-base + ports: + - "127.0.0.1:8092:8080" + +networks: + triangle_net: + driver: bridge diff --git a/deploy/github-production.env.example b/deploy/github-production.env.example new file mode 100644 index 0000000..080d503 --- /dev/null +++ b/deploy/github-production.env.example @@ -0,0 +1,3 @@ +DELTA_CMS_ENV_FILE= +DELTA_NGINX_ACTIVE_INCLUDE= +DELTA_PUBLIC_BASE_URL= diff --git a/deploy/mariadb/README.md b/deploy/mariadb/README.md new file mode 100644 index 0000000..c1264a2 --- /dev/null +++ b/deploy/mariadb/README.md @@ -0,0 +1,113 @@ +# Production MariaDB — primary + read replica behind MaxScale + +These database/proxy tiers run outside Delta. The CMS opens a single connection +to the externally managed endpoint configured as `DB_HOST`/`DB_PORT` in Delta's +host-only `cms.env`. When that endpoint is **MaxScale**, it splits writes to the +primary and reads to the replica: + +``` + CMS / app host DB primary host DB replica host + ┌───────────────────────┐ ┌────────────────┐ ┌────────────────┐ + │ cms-blue cms-green │ │ mariadb-primary│ GTID│ mariadb-replica│ + │ │ │ │ writes │ (server_id 1) │────▶│ (server_id 2) │ + │ └────┬───┘ │────────▶│ binlog+ACID │async│ read_only │ + │ maxscale ───────┼──reads──┼────────────────┼────▶│ │ + │ (rwsplit :4006) │ └────────────────┘ └────────────────┘ + └───────────────────────┘ +``` + +- **Primary** — [compose.mariadb-primary.yml](../compose.mariadb-primary.yml) + `mariadb-primary`, tuned by [primary.cnf](primary.cnf). Full ACID + (`innodb_flush_log_at_trx_commit=1`, `sync_binlog=1`), binary log + GTID, + 7-day binlog retention. Init scripts create the replication and MaxScale users. +- **Replica** — [compose.mariadb-replica.yml](../compose.mariadb-replica.yml) + `mariadb-replica`, tuned by [replica.cnf](replica.cnf). `read_only`, relaxed + durability (re-syncs from the primary on crash), parallel apply for low lag. +- **MaxScale** — if used, run it outside Delta. The sample config in + [maxscale.cnf](../maxscale/maxscale.cnf) uses the `readwritesplit` router + + `mariadbmon` monitor. `causal_reads=local` guarantees a session sees its own + writes despite replica lag, so **no app-level split is needed**. + +Buffer pool is sized for **dedicated 8 GB** DB hosts (`innodb_buffer_pool_size=5G`). +Lower it if a node shares its host. + +## Secrets (`cms.env`, never committed) + +``` +MARIADB_ROOT_PASSWORD=... +MARIADB_PASSWORD=... # app user (triangle_user) password +REPL_USER=repl +REPL_PASSWORD=... +MAXSCALE_USER=maxscale +MAXSCALE_PASSWORD=... +MARIADB_BIND_ADDR=10.0.0.10 # primary internal NIC IP +MARIADB_REPLICA_BIND_ADDR=10.0.0.11 # replica internal NIC IP +PRIMARY_HOST=10.0.0.10 # what MaxScale dials for the primary +REPLICA_HOST=10.0.0.11 # what MaxScale dials for the replica +``` + +**Firewall:** primary 3306 reachable only from the replica host and the MaxScale +host; replica 3306 reachable only from the MaxScale host. Replication and +proxy↔backend traffic are unencrypted here — keep them on the trusted internal +network (or add TLS). + +## Bring-up order + +1. **Primary** (DB primary host) — creates the `repl` and `maxscale` users on + first init: + ``` + docker compose -f compose.mariadb-primary.yml --env-file cms.env up -d + ``` + +2. **Replica** (DB replica host): + ``` + docker compose -f compose.mariadb-replica.yml --env-file cms.env up -d + docker compose -f compose.mariadb-replica.yml exec \ + -e PRIMARY_HOST=$MARIADB_BIND_ADDR -e REPL_USER=$REPL_USER \ + -e REPL_PASSWORD=$REPL_PASSWORD -e MARIADB_DATABASE=triangle \ + mariadb-replica sh /opt/setup-replica.sh + ``` + Verify `SHOW SLAVE STATUS\G` → `Slave_IO_Running: Yes`, `Slave_SQL_Running: Yes`. + +3. **MaxScale / DB proxy** (not on Delta): + ``` + maxctrl list servers + ``` + `maxctrl list servers` should show the primary as `Master, Running` and the + replica as `Slave, Running`. Then configure Delta's `DB_HOST`/`DB_PORT` to + this endpoint and deploy a CMS slot. + +## Verifying the split + +``` +maxctrl list services # connections/routing +maxctrl list servers # roles + replication lag +``` +Reads should land on the replica, writes (and reads-after-writes within a +session) on the primary. + +## Enabling automated failover (optional) + +`auto_failover` is **off** by default — promoting a replica on a 2-node async pair +risks split-brain/data loss and should be deliberate. To enable: + +1. Grant the extra privileges on the primary (replicate to replica): + `REPLICATION SLAVE ADMIN, SUPER, PROCESS, EVENT, SET USER, RELOAD` to `MAXSCALE_USER`. +2. Set `auto_failover=true` in [maxscale.cnf](../maxscale/maxscale.cnf). +3. Consider `maxctrl` switchover for planned maintenance instead of relying on + auto-failover. + +Manual failover without MaxScale: on the replica `STOP SLAVE; RESET SLAVE ALL;`, +set `read_only=OFF`, repoint MaxScale's `primary` server, rebuild the old primary +as a replica. + +## Notes / gotchas + +- `server_id` must be unique per node (1 primary / 2 replica); `gtid_domain_id` + must match (1 here). +- Schema changes: the CMS runs additive, idempotent migrations at startup + (`ADD COLUMN IF NOT EXISTS`) that replicate cleanly. Keep DDL expand-only so a + rollback never drops a column the old version needs. Large `ALTER`s run on the + primary and replicate — watch replica lag during them. +- Adding more replicas: give each a unique `server_id`, provision with + `setup-replica.sh`, and add it to `servers=` in the monitor + service. diff --git a/deploy/nginx/triangle-cms-active-upstreams.conf.example b/deploy/nginx/triangle-cms-active-upstreams.conf.example new file mode 100644 index 0000000..d3bf968 --- /dev/null +++ b/deploy/nginx/triangle-cms-active-upstreams.conf.example @@ -0,0 +1,5 @@ +# Generated atomically by deploy/scripts/deploy.sh and deploy/scripts/rollback.sh. +# Copy to /etc/nginx/triangle-cms-active-upstreams.conf during bootstrap. +set $triangle_cms_slot blue; +set $triangle_cms_frontend http://127.0.0.1:8091; +set $triangle_cms_backend http://127.0.0.1:8081; diff --git a/deploy/nginx/triangle-cms.conf b/deploy/nginx/triangle-cms.conf new file mode 100644 index 0000000..32de964 --- /dev/null +++ b/deploy/nginx/triangle-cms.conf @@ -0,0 +1,48 @@ +# Install as a host Nginx site, for example: +# /etc/nginx/sites-available/triangle-cms.conf +# and symlink into sites-enabled. The active slot include is generated by the +# deploy/rollback scripts. +# +# Initial deployment intentionally uses HTTP and server_name _. It will work via +# Delta's VPN IP or hostname. When cms.thetriangle.org is ready, replace +# server_name and add HTTPS/TLS termination here; the backend should remain +# internal HTTP behind Nginx. + +server { + listen 80; + server_name _; + + include /etc/nginx/triangle-cms-active-upstreams.conf; + + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + location = /healthz { + access_log off; + add_header Content-Type text/plain; + return 200 "ok\n"; + } + + location = /swagger { + return 301 /swagger/; + } + + location /swagger/ { + proxy_pass $triangle_cms_backend; + } + + location = /v1 { + proxy_pass $triangle_cms_backend; + } + + location /v1/ { + proxy_pass $triangle_cms_backend; + } + + location / { + proxy_pass $triangle_cms_frontend; + } +} diff --git a/deploy/scripts/common.sh b/deploy/scripts/common.sh new file mode 100755 index 0000000..fbbdc74 --- /dev/null +++ b/deploy/scripts/common.sh @@ -0,0 +1,261 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DEPLOY_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +REPO_DIR="$(cd "${DEPLOY_DIR}/.." && pwd)" + +COMPOSE_FILE="${COMPOSE_FILE:-${DEPLOY_DIR}/compose.cms.yml}" +ENV_FILE="${ENV_FILE:-${DEPLOY_DIR}/cms.env}" +NGINX_ACTIVE_INCLUDE="${NGINX_ACTIVE_INCLUDE:-/etc/nginx/triangle-cms-active-upstreams.conf}" +PUBLIC_BASE_URL="${PUBLIC_BASE_URL:-http://127.0.0.1}" +DEPLOY_LOCK_FILE="${DEPLOY_LOCK_FILE:-/tmp/triangle-cms-deploy.lock}" +BACKEND_HEALTH_TIMEOUT="${BACKEND_HEALTH_TIMEOUT:-180}" +FRONTEND_HEALTH_TIMEOUT="${FRONTEND_HEALTH_TIMEOUT:-120}" +PUBLIC_HEALTH_TIMEOUT="${PUBLIC_HEALTH_TIMEOUT:-30}" + +compose() { + docker compose -f "${COMPOSE_FILE}" --env-file "${ENV_FILE}" "$@" +} + +require_file() { + local path="$1" + if [[ ! -f "${path}" ]]; then + echo "required file not found: ${path}" >&2 + exit 1 + fi +} + +acquire_deploy_lock() { + exec 9>"${DEPLOY_LOCK_FILE}" + if ! flock -n 9; then + echo "another deployment is already running (${DEPLOY_LOCK_FILE})" >&2 + exit 1 + fi +} + +validate_slot() { + local slot="$1" + case "${slot}" in + blue|green) ;; + *) echo "invalid slot: ${slot}" >&2; exit 1 ;; + esac +} + +opposite_slot() { + local slot="$1" + validate_slot "${slot}" + if [[ "${slot}" == "blue" ]]; then + echo "green" + else + echo "blue" + fi +} + +slot_backend_port() { + local slot="$1" + validate_slot "${slot}" + if [[ "${slot}" == "blue" ]]; then + echo "8081" + else + echo "8082" + fi +} + +slot_frontend_port() { + local slot="$1" + validate_slot "${slot}" + if [[ "${slot}" == "blue" ]]; then + echo "8091" + else + echo "8092" + fi +} + +active_slot() { + if [[ ! -f "${NGINX_ACTIVE_INCLUDE}" ]]; then + echo "blue" + return + fi + local slot + slot="$(sed -n 's/^set[[:space:]]\+$triangle_cms_slot[[:space:]]\+\([^;]*\);/\1/p' "${NGINX_ACTIVE_INCLUDE}" | tail -n 1)" + slot="${slot//\'/}" + slot="${slot//\"/}" + if [[ "${slot}" != "blue" && "${slot}" != "green" ]]; then + echo "invalid active slot in ${NGINX_ACTIVE_INCLUDE}: ${slot:-}" >&2 + return 1 + fi + echo "${slot}" +} + +write_include_file() { + local slot="$1" + local path="$2" + validate_slot "${slot}" + + local frontend_port backend_port + frontend_port="$(slot_frontend_port "${slot}")" + backend_port="$(slot_backend_port "${slot}")" + + { + echo "# Generated by triangle-cms deployment scripts. Do not edit in place." + echo "set \$triangle_cms_slot ${slot};" + echo "set \$triangle_cms_frontend http://127.0.0.1:${frontend_port};" + echo "set \$triangle_cms_backend http://127.0.0.1:${backend_port};" + } > "${path}" +} + +atomic_install_file() { + local src="$1" + local dest="$2" + local dest_dir tmp + dest_dir="$(dirname "${dest}")" + tmp="$(mktemp "${dest_dir}/triangle-cms-active-upstreams.install.XXXXXX")" + cp -p "${src}" "${tmp}" + mv "${tmp}" "${dest}" +} + +nginx_test() { + if [[ "${DEPLOY_TEST_MODE:-0}" == "1" && -n "${NGINX_TEST_CMD:-}" ]]; then + bash -lc "${NGINX_TEST_CMD}" + elif [[ -n "${NGINX_TEST_CMD:-}" || -n "${NGINX_RELOAD_CMD:-}" ]]; then + echo "NGINX_TEST_CMD/NGINX_RELOAD_CMD are test-only; set DEPLOY_TEST_MODE=1 outside production" >&2 + return 2 + elif command -v sudo >/dev/null 2>&1; then + sudo nginx -t + else + nginx -t + fi +} + +nginx_reload() { + if [[ "${DEPLOY_TEST_MODE:-0}" == "1" && -n "${NGINX_RELOAD_CMD:-}" ]]; then + bash -lc "${NGINX_RELOAD_CMD}" + elif [[ -n "${NGINX_TEST_CMD:-}" || -n "${NGINX_RELOAD_CMD:-}" ]]; then + echo "NGINX_TEST_CMD/NGINX_RELOAD_CMD are test-only; set DEPLOY_TEST_MODE=1 outside production" >&2 + return 2 + elif command -v sudo >/dev/null 2>&1; then + sudo nginx -s reload + else + nginx -s reload + fi +} + +http_get() { + local url="$1" + if command -v curl >/dev/null 2>&1; then + curl -fsS --max-time 5 "${url}" >/dev/null + else + wget -q -O /dev/null --timeout=5 "${url}" + fi +} + +wait_for_url() { + local label="$1" + local url="$2" + local timeout_seconds="${3:-120}" + local deadline=$((SECONDS + timeout_seconds)) + + until http_get "${url}"; do + if (( SECONDS >= deadline )); then + echo "timed out waiting for ${label}: ${url}" >&2 + return 1 + fi + sleep 3 + done +} + +wait_for_slot() { + local slot="$1" + validate_slot "${slot}" + wait_for_url "backend ${slot}" "http://127.0.0.1:$(slot_backend_port "${slot}")/v1/health/db" "${BACKEND_HEALTH_TIMEOUT}" + wait_for_url "frontend ${slot}" "http://127.0.0.1:$(slot_frontend_port "${slot}")/healthz" "${FRONTEND_HEALTH_TIMEOUT}" +} + +public_smoke_test() { + local base="${PUBLIC_BASE_URL%/}" + wait_for_url "public nginx health" "${base}/healthz" "${PUBLIC_HEALTH_TIMEOUT}" + wait_for_url "public backend readiness" "${base}/v1/health/db" "${PUBLIC_HEALTH_TIMEOUT}" + wait_for_url "public frontend" "${base}/" "${PUBLIC_HEALTH_TIMEOUT}" +} + +transactional_switch_slot() { + local next_slot="$1" + validate_slot "${next_slot}" + + local current_slot include_dir tmpdir candidate prior prior_exists original_failure + current_slot="$(active_slot)" + validate_slot "${current_slot}" + include_dir="$(dirname "${NGINX_ACTIVE_INCLUDE}")" + tmpdir="$(mktemp -d "${include_dir}/triangle-cms-switch.XXXXXX")" + candidate="${tmpdir}/candidate.conf" + prior="${tmpdir}/prior.conf" + prior_exists=false + + cleanup_switch_tmp() { + trap - RETURN HUP INT TERM + if [[ -n "${tmpdir:-}" ]]; then + rm -rf "${tmpdir}" + fi + } + trap cleanup_switch_tmp RETURN HUP INT TERM + + if [[ -f "${NGINX_ACTIVE_INCLUDE}" ]]; then + cp -p "${NGINX_ACTIVE_INCLUDE}" "${prior}" + prior_exists=true + fi + + write_include_file "${next_slot}" "${candidate}" + if [[ "${prior_exists}" == "true" ]]; then + chmod --reference="${prior}" "${candidate}" + else + chmod 0644 "${candidate}" + fi + + restore_prior_include() { + local cause="$1" + echo "restoring previous Nginx include after failed switch to ${next_slot}: ${cause}" >&2 + if [[ "${prior_exists}" == "true" ]]; then + if ! atomic_install_file "${prior}" "${NGINX_ACTIVE_INCLUDE}"; then + echo "CRITICAL: failed to restore previous Nginx include after: ${cause}" >&2 + return 1 + fi + else + if ! rm -f "${NGINX_ACTIVE_INCLUDE}"; then + echo "CRITICAL: failed to remove failed candidate include after: ${cause}" >&2 + return 1 + fi + fi + } + + atomic_install_file "${candidate}" "${NGINX_ACTIVE_INCLUDE}" + if ! nginx_test; then + original_failure="nginx validation failed after installing candidate include for ${next_slot}" + if ! restore_prior_include "${original_failure}"; then + echo "CRITICAL: ${original_failure}; restoration also failed and operator intervention is required" >&2 + return 10 + fi + echo "${original_failure}; previous include restored" >&2 + return 1 + fi + + if ! nginx_reload; then + original_failure="nginx reload failed after validation for ${next_slot}" + if ! restore_prior_include "${original_failure}"; then + echo "CRITICAL: ${original_failure}; restoration also failed and operator intervention is required" >&2 + return 10 + fi + if ! nginx_test; then + echo "CRITICAL: ${original_failure}; restored include failed nginx validation and operator intervention is required" >&2 + return 11 + fi + if ! nginx_reload; then + echo "CRITICAL: ${original_failure}; restored include validation passed but recovery reload failed and operator intervention is required" >&2 + return 12 + fi + echo "${original_failure}; previous include restored and reloaded" >&2 + return 1 + fi + + return 0 +} diff --git a/deploy/scripts/deploy.sh b/deploy/scripts/deploy.sh new file mode 100755 index 0000000..00dda00 --- /dev/null +++ b/deploy/scripts/deploy.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/common.sh" + +usage() { + echo "usage: $0 " >&2 +} + +if [[ $# -ne 1 ]]; then + usage + exit 2 +fi + +CMS_IMAGE_TAG="$1" +if [[ ! "${CMS_IMAGE_TAG}" =~ ^[0-9a-fA-F]{40}$ ]]; then + echo "CMS_IMAGE_TAG must be a full 40-character commit SHA" >&2 + exit 2 +fi +export CMS_IMAGE_TAG + +require_file "${COMPOSE_FILE}" +require_file "${ENV_FILE}" +acquire_deploy_lock + +current_slot="$(active_slot)" +validate_slot "${current_slot}" +next_slot="$(opposite_slot "${current_slot}")" + +echo "active slot: ${current_slot}" +echo "deploying ${CMS_IMAGE_TAG} to inactive slot: ${next_slot}" + +compose pull "backend-${next_slot}" "frontend-${next_slot}" +compose up -d --no-deps "backend-${next_slot}" "frontend-${next_slot}" + +wait_for_slot "${next_slot}" + +echo "switching nginx to ${next_slot}" +if ! transactional_switch_slot "${next_slot}"; then + echo "switch failed; active include should still describe ${current_slot}" >&2 + exit 1 +fi + +if ! public_smoke_test; then + echo "post-switch smoke tests failed; switching back to ${current_slot}" >&2 + if ! transactional_switch_slot "${current_slot}"; then + echo "CRITICAL: post-switch smoke tests failed and automatic restoration to ${current_slot} failed; operator intervention is required" >&2 + exit 20 + fi + exit 1 +fi + +echo "deployment complete: ${next_slot} is active" +echo "previous slot kept running for rollback: ${current_slot}" diff --git a/deploy/scripts/deploy_scripts_test.sh b/deploy/scripts/deploy_scripts_test.sh new file mode 100755 index 0000000..4c72e9a --- /dev/null +++ b/deploy/scripts/deploy_scripts_test.sh @@ -0,0 +1,216 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/common.sh" + +TEST_ROOT="$(mktemp -d)" +cleanup_all() { + rm -rf "${TEST_ROOT}" +} +trap cleanup_all EXIT + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +assert_file_contains() { + local path="$1" + local pattern="$2" + [[ -f "${path}" ]] || fail "expected file to exist: ${path}" + grep -q "${pattern}" "${path}" || fail "expected ${path} to contain ${pattern}" +} + +assert_no_switch_temps() { + local dir="$1" + if find "${dir}" -maxdepth 1 -type d -name 'triangle-cms-switch.*' | grep -q .; then + fail "switch temp directory was not cleaned up in ${dir}" + fi +} + +make_case() { + local name="$1" + local dir="${TEST_ROOT}/${name}" + mkdir -p "${dir}/bin" + NGINX_ACTIVE_INCLUDE="${dir}/active.conf" + ENV_FILE="${dir}/cms.env" + COMPOSE_FILE="${dir}/compose.yml" + DEPLOY_LOCK_FILE="${dir}/deploy.lock" + PUBLIC_BASE_URL="http://public.test" + BACKEND_HEALTH_TIMEOUT=0 + FRONTEND_HEALTH_TIMEOUT=0 + PUBLIC_HEALTH_TIMEOUT=0 + DEPLOY_TEST_MODE=1 + NGINX_TEST_CMD='exit "${FAKE_NGINX_TEST_STATUS:-0}"' + NGINX_RELOAD_CMD='exit "${FAKE_NGINX_RELOAD_STATUS:-0}"' + FAKE_NGINX_TEST_STATUS=0 + FAKE_NGINX_RELOAD_STATUS=0 + FAIL_READINESS=0 + FAIL_PUBLIC=0 + export NGINX_ACTIVE_INCLUDE ENV_FILE COMPOSE_FILE DEPLOY_LOCK_FILE PUBLIC_BASE_URL + export BACKEND_HEALTH_TIMEOUT FRONTEND_HEALTH_TIMEOUT PUBLIC_HEALTH_TIMEOUT + export DEPLOY_TEST_MODE NGINX_TEST_CMD NGINX_RELOAD_CMD FAKE_NGINX_TEST_STATUS FAKE_NGINX_RELOAD_STATUS + export FAIL_READINESS FAIL_PUBLIC + : > "${ENV_FILE}" + : > "${COMPOSE_FILE}" + CASE_DIR="${dir}" +} + +write_fake_bin() { + local dir="$1" + cat > "${dir}/bin/docker" <<'EOF' +#!/usr/bin/env bash +echo "docker $*" >> "${FAKE_DOCKER_LOG}" +exit 0 +EOF + cat > "${dir}/bin/curl" <<'EOF' +#!/usr/bin/env bash +url="${@: -1}" +echo "curl ${url}" >> "${FAKE_CURL_LOG}" +if [[ "${FAIL_READINESS:-0}" == "1" && "${url}" == *":8082/v1/health/db" ]]; then + exit 1 +fi +if [[ "${FAIL_PUBLIC:-0}" == "1" && "${url}" == "${PUBLIC_BASE_URL}"* ]]; then + exit 1 +fi +exit 0 +EOF + chmod +x "${dir}/bin/docker" "${dir}/bin/curl" + FAKE_DOCKER_LOG="${dir}/docker.log" + FAKE_CURL_LOG="${dir}/curl.log" + export FAKE_DOCKER_LOG FAKE_CURL_LOG + PATH="${dir}/bin:${PATH}" + export PATH +} + +test_first_deployment_switch() { + local dir + make_case first + dir="${CASE_DIR}" + transactional_switch_slot green + assert_file_contains "${NGINX_ACTIVE_INCLUDE}" 'triangle_cms_slot green' + assert_no_switch_temps "${dir}" +} + +test_blue_to_green_switch() { + local dir + make_case blue_green + dir="${CASE_DIR}" + write_include_file blue "${NGINX_ACTIVE_INCLUDE}" + transactional_switch_slot green + assert_file_contains "${NGINX_ACTIVE_INCLUDE}" 'triangle_cms_slot green' + assert_no_switch_temps "${dir}" +} + +test_malformed_active_include_fails() { + local dir + make_case malformed + dir="${CASE_DIR}" + echo 'set $triangle_cms_slot purple;' > "${NGINX_ACTIVE_INCLUDE}" + if active_slot >/dev/null; then + fail "expected malformed active include to fail" + fi + assert_no_switch_temps "${dir}" +} + +test_nginx_test_failure_restores_include() { + local dir + make_case test_fail + dir="${CASE_DIR}" + write_include_file blue "${NGINX_ACTIVE_INCLUDE}" + FAKE_NGINX_TEST_STATUS=1 + export FAKE_NGINX_TEST_STATUS + if transactional_switch_slot green; then + fail "expected nginx test failure" + fi + assert_file_contains "${NGINX_ACTIVE_INCLUDE}" 'triangle_cms_slot blue' + assert_no_switch_temps "${dir}" +} + +test_reload_failure_restores_include() { + local dir reload_count + make_case reload_fail + dir="${CASE_DIR}" + reload_count="${dir}/reload-count" + write_include_file blue "${NGINX_ACTIVE_INCLUDE}" + NGINX_RELOAD_CMD='c=$(cat "${FAKE_RELOAD_COUNT_FILE}" 2>/dev/null || echo 0); c=$((c+1)); echo "$c" > "${FAKE_RELOAD_COUNT_FILE}"; if [[ "$c" -eq 1 ]]; then exit 1; fi; exit 0' + FAKE_RELOAD_COUNT_FILE="${reload_count}" + export NGINX_RELOAD_CMD FAKE_RELOAD_COUNT_FILE + if transactional_switch_slot green; then + fail "expected reload failure" + fi + assert_file_contains "${NGINX_ACTIVE_INCLUDE}" 'triangle_cms_slot blue' + [[ "$(cat "${reload_count}")" == "2" ]] || fail "expected recovery reload" + assert_no_switch_temps "${dir}" +} + +test_failed_readiness_leaves_active_slot() { + local dir sha + make_case readiness + dir="${CASE_DIR}" + write_fake_bin "${dir}" + write_include_file blue "${NGINX_ACTIVE_INCLUDE}" + FAIL_READINESS=1 + export FAIL_READINESS + sha="0123456789abcdef0123456789abcdef01234567" + if "${SCRIPT_DIR}/deploy.sh" "${sha}"; then + fail "expected readiness failure" + fi + assert_file_contains "${NGINX_ACTIVE_INCLUDE}" 'triangle_cms_slot blue' +} + +test_failed_public_smoke_rolls_back() { + local dir sha + make_case smoke + dir="${CASE_DIR}" + write_fake_bin "${dir}" + write_include_file blue "${NGINX_ACTIVE_INCLUDE}" + FAIL_PUBLIC=1 + export FAIL_PUBLIC + sha="0123456789abcdef0123456789abcdef01234567" + if "${SCRIPT_DIR}/deploy.sh" "${sha}"; then + fail "expected public smoke failure" + fi + assert_file_contains "${NGINX_ACTIVE_INCLUDE}" 'triangle_cms_slot blue' +} + +test_invalid_and_malicious_sha() { + local dir + make_case bad_sha + dir="${CASE_DIR}" + write_fake_bin "${dir}" + write_include_file blue "${NGINX_ACTIVE_INCLUDE}" + if "${SCRIPT_DIR}/deploy.sh" '0123456789abcdef0123456789abcdef0123456;touch-x'; then + fail "expected malicious sha rejection" + fi + if "${SCRIPT_DIR}/deploy.sh" 'not-a-sha'; then + fail "expected invalid sha rejection" + fi +} + +test_lock_contention() { + local dir + make_case lock + dir="${CASE_DIR}" + write_fake_bin "${dir}" + write_include_file blue "${NGINX_ACTIVE_INCLUDE}" + exec 8>"${DEPLOY_LOCK_FILE}" + flock -n 8 || fail "failed to acquire test lock" + if "${SCRIPT_DIR}/rollback.sh" green; then + fail "expected rollback lock contention failure" + fi + exec 8>&- +} + +test_first_deployment_switch +test_blue_to_green_switch +test_malformed_active_include_fails +test_nginx_test_failure_restores_include +test_reload_failure_restores_include +test_failed_readiness_leaves_active_slot +test_failed_public_smoke_rolls_back +test_invalid_and_malicious_sha +test_lock_contention + +echo "deploy script tests passed" diff --git a/deploy/scripts/rollback.sh b/deploy/scripts/rollback.sh new file mode 100755 index 0000000..f7a52cb --- /dev/null +++ b/deploy/scripts/rollback.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/common.sh" + +require_file "${COMPOSE_FILE}" +require_file "${ENV_FILE}" +acquire_deploy_lock + +current_slot="$(active_slot)" +validate_slot "${current_slot}" +target_slot="${1:-$(opposite_slot "${current_slot}")}" +validate_slot "${target_slot}" + +if [[ "${target_slot}" == "${current_slot}" ]]; then + echo "slot ${target_slot} is already active" + exit 0 +fi + +echo "rolling back from ${current_slot} to ${target_slot}" +wait_for_slot "${target_slot}" +if ! transactional_switch_slot "${target_slot}"; then + echo "rollback switch failed; active include should still describe ${current_slot}" >&2 + exit 1 +fi + +if ! public_smoke_test; then + echo "rollback smoke tests failed; switching back to ${current_slot}" >&2 + if ! transactional_switch_slot "${current_slot}"; then + echo "CRITICAL: rollback smoke tests failed and automatic restoration to ${current_slot} failed; operator intervention is required" >&2 + exit 20 + fi + exit 1 +fi + +echo "rollback complete: ${target_slot} is active" From 08b1676863d6e82b2f6ed9d8e3a0eb97f1e2bcf6 Mon Sep 17 00:00:00 2001 From: ssavutu Date: Sat, 25 Jul 2026 00:02:42 -0400 Subject: [PATCH 6/6] ci: add validated publish and protected deployment workflows --- .github/workflows/README.md | 16 +++++ .github/workflows/ci.yml | 98 ++++++++++++++++++++++++++++ .github/workflows/deploy.yml | 80 +++++++++++++++++++++++ .github/workflows/docker-publish.yml | 76 --------------------- .github/workflows/publish.yml | 82 +++++++++++++++++++++++ 5 files changed, 276 insertions(+), 76 deletions(-) create mode 100644 .github/workflows/README.md create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/deploy.yml delete mode 100644 .github/workflows/docker-publish.yml create mode 100644 .github/workflows/publish.yml diff --git a/.github/workflows/README.md b/.github/workflows/README.md new file mode 100644 index 0000000..adfe4e7 --- /dev/null +++ b/.github/workflows/README.md @@ -0,0 +1,16 @@ +# CI/CD Workflows + +`ci.yml` runs on pull requests and pushes without production secrets. It covers +backend tests, race tests, `go vet`, frontend lint/build, Docker image builds, +and Compose validation. + +`publish.yml` runs only from a successful `CI` workflow run on `main` that was +triggered by a trusted push. It validates +`github.event.workflow_run.head_sha` as a 40-character hexadecimal SHA and +publishes backend and frontend GHCR images tagged only with that full SHA. + +`deploy.yml` runs on the narrowly labelled self-hosted runner inside the Drexel +VPN. Automatic deployments use the trusted publish run `head_sha`; manual +deployments accept an already-published image SHA as data only. Deployment code +is always checked out from the protected default branch, never from the supplied +image SHA. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..869ff3e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,98 @@ +name: CI + +on: + pull_request: + branches: + - main + push: + branches: + - main + workflow_dispatch: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + backend: + runs-on: ubuntu-latest + defaults: + run: + working-directory: server + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: server/go.mod + cache-dependency-path: server/go.sum + - name: Test + run: go test ./... + - name: Race test + run: go test -race ./... + - name: Vet + run: go vet ./... + + frontend: + runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: frontend/package-lock.json + - name: Clean install + run: npm ci + - name: Lint + run: npm run lint + - name: Build + run: npm run build + + docker-builds: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-buildx-action@v3 + - name: Build backend image + uses: docker/build-push-action@v6 + with: + context: ./server + file: ./server/Dockerfile + push: false + tags: triangle-cms-backend:${{ github.sha }} + cache-from: type=gha,scope=backend + cache-to: type=gha,mode=max,scope=backend + - name: Build frontend image + uses: docker/build-push-action@v6 + with: + context: ./frontend + file: ./frontend/Dockerfile + push: false + tags: triangle-cms-frontend:${{ github.sha }} + cache-from: type=gha,scope=frontend + cache-to: type=gha,mode=max,scope=frontend + + compose-validation: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Validate Delta Compose config + env: + CMS_IMAGE_TAG: ${{ github.sha }} + DB_NAME: triangle + DB_USER: triangle_user + DB_PASSWORD: ci-placeholder + DB_HOST: db-host-placeholder + DB_PORT: "3306" + OIDC_ISSUER_URL: http://oidc-placeholder.invalid + OIDC_CLIENT_ID: ci-placeholder + OIDC_CLIENT_SECRET: ci-placeholder + FRONTEND_ORIGIN: http://delta-placeholder + OIDC_REDIRECT_URI: http://delta-placeholder/auth/callback + run: docker compose -f deploy/compose.cms.yml config >/dev/null diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..391b5ad --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,80 @@ +name: Deploy Delta + +on: + workflow_run: + workflows: + - Publish Images + branches: + - main + types: + - completed + workflow_dispatch: + inputs: + image_sha: + description: Full commit SHA image tag to deploy + required: true + +concurrency: + group: delta-production-deploy + cancel-in-progress: false + +permissions: + contents: read + packages: read + +jobs: + deploy: + if: > + github.event_name == 'workflow_dispatch' || + ( + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.name == 'Publish Images' && + github.event.workflow_run.head_branch == 'main' && + github.event.workflow_run.event == 'workflow_run' + ) + runs-on: + - self-hosted + - drexel-vpn + - delta + - triangle-cms + environment: production + steps: + - name: Resolve SHA + id: sha + run: | + if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then + # Manual deployments treat image_sha strictly as data: an immutable + # GHCR tag that must already exist. It is never used as a checkout + # ref, script source, Compose source, or env-file source. + sha="${{ inputs.image_sha }}" + else + # Automatic deployments come only from the trusted automatic + # Publish Images workflow, which itself only publishes CI-validated + # pushes to main. The publish workflow run head_sha is the image tag. + sha="${{ github.event.workflow_run.head_sha }}" + fi + if [[ ! "$sha" =~ ^[0-9a-fA-F]{40}$ ]]; then + echo "invalid full commit SHA: $sha" >&2 + exit 1 + fi + echo "value=$sha" >> "$GITHUB_OUTPUT" + + - name: Checkout trusted deployment code + uses: actions/checkout@v4 + with: + # Trust boundary: deployment code comes from the protected default + # branch/workflow revision, not from the data-only image SHA. + ref: ${{ github.event.repository.default_branch }} + + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Deploy inactive slot and switch Nginx + env: + ENV_FILE: ${{ vars.DELTA_CMS_ENV_FILE }} + NGINX_ACTIVE_INCLUDE: ${{ vars.DELTA_NGINX_ACTIVE_INCLUDE }} + PUBLIC_BASE_URL: ${{ vars.DELTA_PUBLIC_BASE_URL }} + run: deploy/scripts/deploy.sh "${{ steps.sha.outputs.value }}" diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml deleted file mode 100644 index b3139ac..0000000 --- a/.github/workflows/docker-publish.yml +++ /dev/null @@ -1,76 +0,0 @@ -name: Build and Publish CMS Image - -# Builds the CMS backend Docker image and pushes it to GitHub Container Registry -# (GHCR). Pushes are tagged with the full commit SHA (immutable, used for -# production deploys) and, on the default branch, a moving `latest` tag. -# Pull requests build the image to catch breakage but never push. - -on: - push: - branches: - - main - tags: - - "v*" - pull_request: - branches: - - main - workflow_dispatch: - -# Only the latest run per ref needs to finish; cancel superseded ones. -concurrency: - group: docker-publish-${{ github.ref }} - cancel-in-progress: true - -permissions: - contents: read - packages: write - -jobs: - build-and-push: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - # GHCR image names must be lowercase; the org (DrexelTriangle) is not. - - name: Compute lowercase image name - id: image - run: echo "name=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT" - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - # Skip login on pull_request builds: fork PRs have no push rights and - # must not receive registry credentials. - - name: Log in to GHCR - if: github.event_name != 'pull_request' - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Derive image tags and labels - id: meta - uses: docker/metadata-action@v5 - with: - images: ${{ steps.image.outputs.name }} - tags: | - type=sha,format=long,prefix= - type=raw,value=latest,enable={{is_default_branch}} - type=ref,event=tag - labels: | - org.opencontainers.image.title=triangle-cms - org.opencontainers.image.source=https://github.com/${{ github.repository }} - - - name: Build and push - uses: docker/build-push-action@v6 - with: - context: ./server - file: ./server/Dockerfile - # Push on branch/tag builds and manual runs; PRs build only. - push: ${{ github.event_name != 'pull_request' }} - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..d6ae0c2 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,82 @@ +name: Publish Images + +on: + workflow_run: + workflows: + - CI + branches: + - main + types: + - completed + +concurrency: + group: publish-${{ github.event.workflow_run.head_sha }} + cancel-in-progress: false + +permissions: + contents: read + packages: write + +jobs: + publish: + if: > + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.name == 'CI' && + github.event.workflow_run.head_branch == 'main' && + github.event.workflow_run.event == 'push' + runs-on: ubuntu-latest + steps: + - name: Resolve SHA + id: sha + run: | + sha="${{ github.event.workflow_run.head_sha }}" + if [[ ! "$sha" =~ ^[0-9a-fA-F]{40}$ ]]; then + echo "invalid full commit SHA: $sha" >&2 + exit 1 + fi + echo "value=$sha" >> "$GITHUB_OUTPUT" + + - uses: actions/checkout@v4 + with: + ref: ${{ steps.sha.outputs.value }} + + - name: Compute image names + id: image + run: | + repo="${GITHUB_REPOSITORY,,}" + echo "backend=ghcr.io/${repo}-backend" >> "$GITHUB_OUTPUT" + echo "frontend=ghcr.io/${repo}-frontend" >> "$GITHUB_OUTPUT" + + - uses: docker/setup-buildx-action@v3 + + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and publish backend + uses: docker/build-push-action@v6 + with: + context: ./server + file: ./server/Dockerfile + push: true + tags: ${{ steps.image.outputs.backend }}:${{ steps.sha.outputs.value }} + labels: | + org.opencontainers.image.source=https://github.com/${{ github.repository }} + org.opencontainers.image.revision=${{ steps.sha.outputs.value }} + cache-from: type=gha,scope=backend + cache-to: type=gha,mode=max,scope=backend + + - name: Build and publish frontend + uses: docker/build-push-action@v6 + with: + context: ./frontend + file: ./frontend/Dockerfile + push: true + tags: ${{ steps.image.outputs.frontend }}:${{ steps.sha.outputs.value }} + labels: | + org.opencontainers.image.source=https://github.com/${{ github.repository }} + org.opencontainers.image.revision=${{ steps.sha.outputs.value }} + cache-from: type=gha,scope=frontend + cache-to: type=gha,mode=max,scope=frontend