deploy configuration
This commit is contained in:
parent
9ba3c47d82
commit
dac02c61c3
45 changed files with 1405 additions and 582 deletions
13
.env.example
13
.env.example
|
|
@ -20,4 +20,15 @@ CMS_SSO_HEADER=X-SSO-Token
|
||||||
# Optional shared secret. Leave empty to accept any non-empty forwarded value.
|
# Optional shared secret. Leave empty to accept any non-empty forwarded value.
|
||||||
CMS_SSO_TOKEN=
|
CMS_SSO_TOKEN=
|
||||||
# Optional cookie fallback when the proxy sets a cookie instead of a header.
|
# Optional cookie fallback when the proxy sets a cookie instead of a header.
|
||||||
CMS_SSO_COOKIE=
|
CMS_SSO_COOKIE=
|
||||||
|
|
||||||
|
# Go Loose browser login. Leave the credentials empty to keep the proxy identity.
|
||||||
|
# Register https://tell.dev/api/auth/callback as the redirect URI of the Go Loose
|
||||||
|
# application that owns these credentials.
|
||||||
|
GO_LOOSE_TENANT=tell
|
||||||
|
GO_LOOSE_AUTH_DOMAIN=auth.dev
|
||||||
|
GO_LOOSE_APP_DOMAIN=tell.dev
|
||||||
|
GO_LOOSE_CLIENT_ID=
|
||||||
|
GO_LOOSE_CLIENT_SECRET=
|
||||||
|
# Extra certificate authority for an internally signed identity domain.
|
||||||
|
GO_LOOSE_CA_FILE=
|
||||||
2
.github/workflows/build-test-push.yml
vendored
2
.github/workflows/build-test-push.yml
vendored
|
|
@ -7,7 +7,7 @@ on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
IMAGE_NAME: batty1039.startdedicated.net:5000/cms
|
IMAGE_NAME: batty1039.startdedicated.net:5000/go-tell
|
||||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|
|
||||||
43
.github/workflows/deploy-rancher.yml
vendored
43
.github/workflows/deploy-rancher.yml
vendored
|
|
@ -26,7 +26,7 @@ permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: cms-${{ github.event_name == 'workflow_dispatch' && inputs.environment || 'production' }}
|
group: go-tell-${{ github.event_name == 'workflow_dispatch' && inputs.environment || 'production' }}
|
||||||
cancel-in-progress: false
|
cancel-in-progress: false
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|
@ -47,6 +47,9 @@ jobs:
|
||||||
CMS_ALLOWED_ORIGINS: ${{ vars.CMS_ALLOWED_ORIGINS }}
|
CMS_ALLOWED_ORIGINS: ${{ vars.CMS_ALLOWED_ORIGINS }}
|
||||||
CMS_SSO_HEADER: ${{ vars.CMS_SSO_HEADER }}
|
CMS_SSO_HEADER: ${{ vars.CMS_SSO_HEADER }}
|
||||||
CMS_SSO_COOKIE: ${{ vars.CMS_SSO_COOKIE }}
|
CMS_SSO_COOKIE: ${{ vars.CMS_SSO_COOKIE }}
|
||||||
|
GO_LOOSE_CLIENT_ID: ${{ secrets.GO_LOOSE_CLIENT_ID }}
|
||||||
|
GO_LOOSE_CLIENT_SECRET: ${{ secrets.GO_LOOSE_CLIENT_SECRET }}
|
||||||
|
LOCAL_CA_CERT: ${{ secrets.LOCAL_CA_CERT }}
|
||||||
RANCHER_NAMESPACE: ${{ vars.RANCHER_NAMESPACE }}
|
RANCHER_NAMESPACE: ${{ vars.RANCHER_NAMESPACE }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
|
@ -79,7 +82,11 @@ jobs:
|
||||||
echo "REGISTRY_USERNAME and REGISTRY_PASSWORD are required" >&2
|
echo "REGISTRY_USERNAME and REGISTRY_PASSWORD are required" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
namespace="${RANCHER_NAMESPACE:-cms-$TARGET_ENVIRONMENT}"
|
if [ -z "$GO_LOOSE_CLIENT_ID" ] || [ -z "$GO_LOOSE_CLIENT_SECRET" ]; then
|
||||||
|
echo "GO_LOOSE_CLIENT_ID and GO_LOOSE_CLIENT_SECRET are required: they come from the Go Loose console" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
namespace="${RANCHER_NAMESPACE:-go-tell-$TARGET_ENVIRONMENT}"
|
||||||
if [ -n "$PUBLISHED_SHA" ]; then
|
if [ -n "$PUBLISHED_SHA" ]; then
|
||||||
image_tag="$PUBLISHED_SHA"
|
image_tag="$PUBLISHED_SHA"
|
||||||
else
|
else
|
||||||
|
|
@ -88,9 +95,9 @@ jobs:
|
||||||
allowed_origins="$CMS_ALLOWED_ORIGINS"
|
allowed_origins="$CMS_ALLOWED_ORIGINS"
|
||||||
if [ -z "$allowed_origins" ]; then
|
if [ -z "$allowed_origins" ]; then
|
||||||
if [ "$TARGET_ENVIRONMENT" = "production" ]; then
|
if [ "$TARGET_ENVIRONMENT" = "production" ]; then
|
||||||
allowed_origins="https://cms.urpi.be"
|
allowed_origins="https://tell.dev"
|
||||||
else
|
else
|
||||||
allowed_origins="http://localhost:30081"
|
allowed_origins="http://localhost:30010"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
{
|
{
|
||||||
|
|
@ -112,28 +119,42 @@ jobs:
|
||||||
set -eu
|
set -eu
|
||||||
kubectl create namespace "$DEPLOY_NAMESPACE" \
|
kubectl create namespace "$DEPLOY_NAMESPACE" \
|
||||||
--dry-run=client -o yaml | kubectl apply -f -
|
--dry-run=client -o yaml | kubectl apply -f -
|
||||||
kubectl -n "$DEPLOY_NAMESPACE" create secret docker-registry cms-registry \
|
kubectl -n "$DEPLOY_NAMESPACE" create secret docker-registry go-tell-registry \
|
||||||
--docker-server=batty1039.startdedicated.net:5000 \
|
--docker-server=batty1039.startdedicated.net:5000 \
|
||||||
--docker-username="$REGISTRY_USERNAME" \
|
--docker-username="$REGISTRY_USERNAME" \
|
||||||
--docker-password="$REGISTRY_PASSWORD" \
|
--docker-password="$REGISTRY_PASSWORD" \
|
||||||
--dry-run=client -o yaml | kubectl apply -f -
|
--dry-run=client -o yaml | kubectl apply -f -
|
||||||
|
|
||||||
|
- name: Publish the internal certificate authority
|
||||||
|
if: ${{ env.LOCAL_CA_CERT != '' }}
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
printf '%s' "$LOCAL_CA_CERT" | base64 --decode > "$RUNNER_TEMP/local-ca.crt"
|
||||||
|
kubectl -n "$DEPLOY_NAMESPACE" create secret generic local-ca \
|
||||||
|
--from-file=ca.crt="$RUNNER_TEMP/local-ca.crt" \
|
||||||
|
--dry-run=client -o yaml | kubectl apply -f -
|
||||||
|
|
||||||
- name: Deploy Helm release
|
- name: Deploy Helm release
|
||||||
run: |
|
run: |
|
||||||
set -eu
|
set -eu
|
||||||
values_file="deploy/helm/cms/values-$TARGET_ENVIRONMENT.yaml"
|
values_file="deploy/helm/go-tell/values-$TARGET_ENVIRONMENT.yaml"
|
||||||
set -- \
|
set -- \
|
||||||
--set-string "image.tag=$IMAGE_TAG" \
|
--set-string "image.tag=$IMAGE_TAG" \
|
||||||
--set "imagePullSecrets[0].name=cms-registry" \
|
--set "imagePullSecrets[0].name=go-tell-registry" \
|
||||||
--set-string "app.allowedOrigins=$RESOLVED_ALLOWED_ORIGINS" \
|
--set-string "app.allowedOrigins=$RESOLVED_ALLOWED_ORIGINS" \
|
||||||
--set-string "secrets.ssoToken=$CMS_SSO_TOKEN"
|
--set-string "secrets.ssoToken=$CMS_SSO_TOKEN" \
|
||||||
|
--set-string "goLoose.clientID=$GO_LOOSE_CLIENT_ID" \
|
||||||
|
--set-string "goLoose.clientSecret=$GO_LOOSE_CLIENT_SECRET"
|
||||||
|
if [ -n "$LOCAL_CA_CERT" ]; then
|
||||||
|
set -- "$@" --set-string goLoose.ca.existingSecret=local-ca
|
||||||
|
fi
|
||||||
if [ -n "$CMS_SSO_HEADER" ]; then
|
if [ -n "$CMS_SSO_HEADER" ]; then
|
||||||
set -- "$@" --set-string "app.ssoHeader=$CMS_SSO_HEADER"
|
set -- "$@" --set-string "app.ssoHeader=$CMS_SSO_HEADER"
|
||||||
fi
|
fi
|
||||||
if [ -n "$CMS_SSO_COOKIE" ]; then
|
if [ -n "$CMS_SSO_COOKIE" ]; then
|
||||||
set -- "$@" --set-string "app.ssoCookie=$CMS_SSO_COOKIE"
|
set -- "$@" --set-string "app.ssoCookie=$CMS_SSO_COOKIE"
|
||||||
fi
|
fi
|
||||||
helm upgrade --install cms deploy/helm/cms \
|
helm upgrade --install go-tell deploy/helm/go-tell \
|
||||||
--namespace "$DEPLOY_NAMESPACE" \
|
--namespace "$DEPLOY_NAMESPACE" \
|
||||||
--values "$values_file" \
|
--values "$values_file" \
|
||||||
--atomic \
|
--atomic \
|
||||||
|
|
@ -143,6 +164,6 @@ jobs:
|
||||||
|
|
||||||
- name: Verify deployment
|
- name: Verify deployment
|
||||||
run: |
|
run: |
|
||||||
kubectl -n "$DEPLOY_NAMESPACE" rollout status deployment/cms \
|
kubectl -n "$DEPLOY_NAMESPACE" rollout status deployment/go-tell \
|
||||||
--timeout=5m
|
--timeout=5m
|
||||||
helm test cms --namespace "$DEPLOY_NAMESPACE" --logs
|
helm test go-tell --namespace "$DEPLOY_NAMESPACE" --logs
|
||||||
|
|
|
||||||
13
Dockerfile
13
Dockerfile
|
|
@ -9,18 +9,19 @@ RUN npm run build -- --outDir /src/ui/dist --emptyOutDir
|
||||||
# Compile the Go binary with the built frontend embedded.
|
# Compile the Go binary with the built frontend embedded.
|
||||||
FROM golang:1.27-alpine AS backend
|
FROM golang:1.27-alpine AS backend
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY go.mod ./
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
COPY cmd/ ./cmd/
|
COPY cmd/ ./cmd/
|
||||||
COPY internal/ ./internal/
|
COPY internal/ ./internal/
|
||||||
COPY --from=ui /src/ui/dist/ ./cmd/server/web/dist/
|
COPY --from=ui /src/ui/dist/ ./cmd/server/web/dist/
|
||||||
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/cms ./cmd/server
|
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/go-tell ./cmd/server
|
||||||
|
|
||||||
# Runtime: one static binary, no database, no shell tooling.
|
# Runtime: one static binary, no database, no shell tooling.
|
||||||
FROM alpine:3.20
|
FROM alpine:3.20
|
||||||
RUN apk add --no-cache ca-certificates tzdata \
|
RUN apk add --no-cache ca-certificates tzdata \
|
||||||
&& adduser -D -u 10001 cms
|
&& adduser -D -u 10001 go-tell
|
||||||
COPY --from=backend /out/cms /usr/local/bin/cms
|
COPY --from=backend /out/go-tell /usr/local/bin/go-tell
|
||||||
USER cms
|
USER go-tell
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
ENV CMS_HTTP_ADDR=:8080
|
ENV CMS_HTTP_ADDR=:8080
|
||||||
# Mount a volume here and set CMS_CONTENT_FILE=/data/content.json to keep edits
|
# Mount a volume here and set CMS_CONTENT_FILE=/data/content.json to keep edits
|
||||||
|
|
@ -28,4 +29,4 @@ ENV CMS_HTTP_ADDR=:8080
|
||||||
VOLUME ["/data"]
|
VOLUME ["/data"]
|
||||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \
|
||||||
CMD wget -qO- http://127.0.0.1:8080/health || exit 1
|
CMD wget -qO- http://127.0.0.1:8080/health || exit 1
|
||||||
ENTRYPOINT ["/usr/local/bin/cms"]
|
ENTRYPOINT ["/usr/local/bin/go-tell"]
|
||||||
67
Makefile
67
Makefile
|
|
@ -1,16 +1,27 @@
|
||||||
SHELL := /bin/sh
|
SHELL := /bin/sh
|
||||||
|
|
||||||
IMAGE ?= cms
|
IMAGE ?= go-tell
|
||||||
TAG ?= latest
|
TAG ?= latest
|
||||||
REGISTRY_IMAGE ?= batty1039.startdedicated.net:5000/cms
|
REGISTRY_IMAGE ?= batty1039.startdedicated.net:5000/go-tell
|
||||||
HELM ?= helm
|
HELM ?= helm
|
||||||
HELM_CHART := deploy/helm/cms
|
HELM_CHART := deploy/helm/go-tell
|
||||||
HELM_RELEASE ?= cms
|
HELM_RELEASE ?= go-tell
|
||||||
HELM_NAMESPACE ?= cms
|
HELM_NAMESPACE ?= go-tell
|
||||||
KUBECONFIG_DEV ?= $(HOME)/.config/kubectl/rancher.urpi.local.yaml
|
# The development and production releases share one cluster, so both point at the
|
||||||
KUBECONFIG_PRD ?= $(HOME)/.config/kubectl/rancher.urpi.be.yaml
|
# same kubeconfig and only the namespace and the values profile differ.
|
||||||
|
KUBECONFIG ?= $(HOME)/.kube/config
|
||||||
|
# Secret holding the platform CA bundle the pod needs to reach auth.dev. Create it
|
||||||
|
# per namespace with kubectl create secret generic local-ca --from-file=ca.crt=...
|
||||||
|
GO_LOOSE_CA_SECRET ?= local-ca
|
||||||
|
|
||||||
.PHONY: help ui frontend backend demo production run test check docker docker-push docker-run clean rancher-storage helm-lint helm-deploy-development helm-deploy-production helm-down-development helm-down-production helm-proxy-development helm-proxy-production
|
# Helm receives the Go Loose credentials from the environment instead of a values
|
||||||
|
# file. make sources the gitignored .env first, so a configured checkout only has
|
||||||
|
# to run the target.
|
||||||
|
GO_LOOSE_SET = $(if $(GO_LOOSE_CLIENT_ID),--set-string goLoose.clientID=$(GO_LOOSE_CLIENT_ID)) \
|
||||||
|
$(if $(GO_LOOSE_CLIENT_SECRET),--set-string goLoose.clientSecret=$(GO_LOOSE_CLIENT_SECRET)) \
|
||||||
|
$(if $(GO_LOOSE_CA_SECRET),--set-string goLoose.ca.existingSecret=$(GO_LOOSE_CA_SECRET))
|
||||||
|
|
||||||
|
.PHONY: help ui frontend backend demo production run test check docker docker-push docker-run clean ingress-nginx-install cert-manager-install cluster-prepare helm-lint helm-deploy-development helm-deploy-production helm-down-development helm-down-production helm-proxy-development helm-proxy-production
|
||||||
|
|
||||||
help:
|
help:
|
||||||
@echo "make ui build the React app into cmd/server/web/dist"
|
@echo "make ui build the React app into cmd/server/web/dist"
|
||||||
|
|
@ -25,7 +36,9 @@ help:
|
||||||
@echo "make docker-push build and push the image to the private registry"
|
@echo "make docker-push build and push the image to the private registry"
|
||||||
@echo "make docker-run run the image on :8080"
|
@echo "make docker-run run the image on :8080"
|
||||||
@echo "make clean remove build output"
|
@echo "make clean remove build output"
|
||||||
@echo "make rancher-storage make rancher storage"
|
@echo "make ingress-nginx-install install the ingress controller in the cluster"
|
||||||
|
@echo "make cert-manager-install install cert-manager in the cluster"
|
||||||
|
@echo "make cluster-prepare prepare the cluster for both releases"
|
||||||
@echo "make helm-lint linting the helm charts"
|
@echo "make helm-lint linting the helm charts"
|
||||||
@echo "make helm-deploy-development deploy to development"
|
@echo "make helm-deploy-development deploy to development"
|
||||||
@echo "make helm-deploy-production deploy to production"
|
@echo "make helm-deploy-production deploy to production"
|
||||||
|
|
@ -85,8 +98,17 @@ clean:
|
||||||
rm -rf cmd/server/web/dist ui/dist
|
rm -rf cmd/server/web/dist ui/dist
|
||||||
mkdir -p cmd/server/web/dist
|
mkdir -p cmd/server/web/dist
|
||||||
printf 'Placeholder so the Go embed directive resolves before the frontend is built.\nRun `make ui` to replace this directory with the real React build.\n' > cmd/server/web/dist/placeholder.txt
|
printf 'Placeholder so the Go embed directive resolves before the frontend is built.\nRun `make ui` to replace this directory with the real React build.\n' > cmd/server/web/dist/placeholder.txt
|
||||||
rancher-storage:
|
ingress-nginx-install:
|
||||||
KUBECONFIG="$(KUBECONFIG_DEV)" kubectl apply -f deploy/rancher/local-path.yaml
|
kubectl --kubeconfig "$(KUBECONFIG)" apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v1.14.1/deploy/static/provider/kind/deploy.yaml
|
||||||
|
kubectl --kubeconfig "$(KUBECONFIG)" -n ingress-nginx rollout status deployment/ingress-nginx-controller --timeout=5m
|
||||||
|
|
||||||
|
cert-manager-install:
|
||||||
|
kubectl --kubeconfig "$(KUBECONFIG)" apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.19.1/cert-manager.yaml
|
||||||
|
kubectl --kubeconfig "$(KUBECONFIG)" -n cert-manager rollout status deployment/cert-manager-webhook --timeout=5m
|
||||||
|
kubectl --kubeconfig "$(KUBECONFIG)" -n cert-manager rollout status deployment/cert-manager-cainjector --timeout=5m
|
||||||
|
kubectl --kubeconfig "$(KUBECONFIG)" -n cert-manager rollout status deployment/cert-manager --timeout=5m
|
||||||
|
|
||||||
|
cluster-prepare: ingress-nginx-install cert-manager-install
|
||||||
|
|
||||||
helm-lint:
|
helm-lint:
|
||||||
$(HELM) lint $(HELM_CHART) -f $(HELM_CHART)/values-development.yaml
|
$(HELM) lint $(HELM_CHART) -f $(HELM_CHART)/values-development.yaml
|
||||||
|
|
@ -94,27 +116,36 @@ helm-lint:
|
||||||
$(HELM) template $(HELM_RELEASE) $(HELM_CHART) \
|
$(HELM) template $(HELM_RELEASE) $(HELM_CHART) \
|
||||||
-f $(HELM_CHART)/values-development.yaml >/dev/null
|
-f $(HELM_CHART)/values-development.yaml >/dev/null
|
||||||
$(HELM) template $(HELM_RELEASE) $(HELM_CHART) \
|
$(HELM) template $(HELM_RELEASE) $(HELM_CHART) \
|
||||||
-f $(HELM_CHART)/values-production.yaml >/dev/null
|
-f $(HELM_CHART)/values-production.yaml \
|
||||||
|
--set-string goLoose.clientID=lint --set-string goLoose.clientSecret=lint >/dev/null
|
||||||
|
|
||||||
|
# Go Loose credentials are never stored in the values files. Pass them on the
|
||||||
|
# command line, from a secret store, or with --set-file:
|
||||||
|
# make helm-deploy-production \
|
||||||
|
# --set goLoose.clientID=$(GO_LOOSE_CLIENT_ID) \
|
||||||
|
# --set goLoose.clientSecret=$(GO_LOOSE_CLIENT_SECRET) \
|
||||||
|
# --set goLoose.ca.existingSecret=local-ca
|
||||||
helm-deploy-development:
|
helm-deploy-development:
|
||||||
KUBECONFIG="$(KUBECONFIG_DEV)" $(HELM) upgrade --install $(HELM_RELEASE) $(HELM_CHART) \
|
KUBECONFIG="$(KUBECONFIG)" $(HELM) upgrade --install $(HELM_RELEASE) $(HELM_CHART) \
|
||||||
--namespace $(HELM_NAMESPACE)-development --create-namespace \
|
--namespace $(HELM_NAMESPACE)-development --create-namespace \
|
||||||
-f $(HELM_CHART)/values-development.yaml \
|
-f $(HELM_CHART)/values-development.yaml \
|
||||||
--rollback-on-failure --wait --timeout 10m
|
--rollback-on-failure --wait --timeout 10m
|
||||||
|
|
||||||
helm-deploy-production:
|
helm-deploy-production:
|
||||||
KUBECONFIG="$(KUBECONFIG_PRD)" $(HELM) upgrade --install $(HELM_RELEASE) $(HELM_CHART) \
|
set -a; [ ! -f .env ] || . ./.env; set +a; \
|
||||||
|
KUBECONFIG="$(KUBECONFIG)" $(HELM) upgrade --install $(HELM_RELEASE) $(HELM_CHART) \
|
||||||
--namespace $(HELM_NAMESPACE)-production --create-namespace \
|
--namespace $(HELM_NAMESPACE)-production --create-namespace \
|
||||||
-f $(HELM_CHART)/values-production.yaml \
|
-f $(HELM_CHART)/values-production.yaml \
|
||||||
|
$(GO_LOOSE_SET) \
|
||||||
--rollback-on-failure --wait --timeout 10m
|
--rollback-on-failure --wait --timeout 10m
|
||||||
|
|
||||||
helm-down-production:
|
helm-down-production:
|
||||||
KUBECONFIG="$(KUBECONFIG_PRD)" $(HELM) uninstall $(HELM_RELEASE) -n $(HELM_NAMESPACE)-production
|
KUBECONFIG="$(KUBECONFIG)" $(HELM) uninstall $(HELM_RELEASE) -n $(HELM_NAMESPACE)-production
|
||||||
|
|
||||||
helm-down-development:
|
helm-down-development:
|
||||||
KUBECONFIG="$(KUBECONFIG_DEV)" $(HELM) uninstall $(HELM_RELEASE) -n $(HELM_NAMESPACE)-development
|
KUBECONFIG="$(KUBECONFIG)" $(HELM) uninstall $(HELM_RELEASE) -n $(HELM_NAMESPACE)-development
|
||||||
|
|
||||||
helm-proxy-production:
|
helm-proxy-production:
|
||||||
KUBECONFIG="$(KUBECONFIG_DEV)" kubectl port-forward -n $(HELM_NAMESPACE)-production deployment/$(HELM_RELEASE) 31373:8080 --address 0.0.0.0
|
kubectl --kubeconfig "$(KUBECONFIG)" port-forward -n $(HELM_NAMESPACE)-production service/$(HELM_RELEASE) 31373:80 --address 0.0.0.0
|
||||||
helm-proxy-development:
|
helm-proxy-development:
|
||||||
KUBECONFIG="$(KUBECONFIG_PRD)" kubectl port-forward -n $(HELM_NAMESPACE)-development deployment/$(HELM_RELEASE) 30010:8080 --address 0.0.0.0
|
kubectl --kubeconfig "$(KUBECONFIG)" port-forward -n $(HELM_NAMESPACE)-development service/$(HELM_RELEASE) 30010:80 --address 0.0.0.0
|
||||||
160
README.md
160
README.md
|
|
@ -1,66 +1,158 @@
|
||||||
# CMS project
|
# Go Tell
|
||||||
|
|
||||||
This repository contains a Go backend and React frontend for the CMS.
|
Go Tell is a small content CMS: one Go binary that serves the API and embeds
|
||||||
|
the React frontend, backed by a JSON file. It is served from a single host,
|
||||||
|
`https://tell.dev`, and it uses [Go Loose](https://github.com/danyel/go-loose)
|
||||||
|
to decide who is allowed to edit.
|
||||||
|
|
||||||
## Backend
|
## Backend
|
||||||
|
|
||||||
The backend targets Go 1.27.1 and uses a layered design (`internal/web`,
|
The backend targets Go 1.27.1 and uses a layered design (`internal/web`,
|
||||||
`service`, and `security`). Content is seeded in memory and can be mirrored to
|
`internal/service`, `internal/security`, `internal/golooseauth`). Content is
|
||||||
a JSON file configured through `CMS_CONTENT_FILE`.
|
seeded in memory and can be mirrored to a JSON file configured through
|
||||||
|
`CMS_CONTENT_FILE`.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go run ./cmd/server
|
go run ./cmd/server
|
||||||
```
|
```
|
||||||
|
|
||||||
Configuration is read from environment variables (see `.env`). The API includes `GET /health`, `GET /api/auth/session`, `GET /api/protected`, `GET /api/content`, `GET /api/content/categories`, `GET /api/content/badges`, and `GET|PUT /api/content/:slug`.
|
Configuration is read from environment variables (see `.env.example`). The API
|
||||||
|
exposes `GET /health`, `GET /api/auth/session`, `GET /api/protected`,
|
||||||
|
`GET /api/content`, `GET /api/content/categories`, `GET /api/content/badges`,
|
||||||
|
and `GET|PUT /api/content/:slug`.
|
||||||
|
|
||||||
## Single sign-on
|
| Variable | Default | Purpose |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `CMS_HTTP_ADDR` | `:8080` | Listen address |
|
||||||
|
| `CMS_PROFILE` | `demo` | `demo` enables the in-memory seed and the sudo terminal, anything else requires an authenticated editor |
|
||||||
|
| `CMS_CONTENT_FILE` | empty | Path of the JSON file that stores edits; empty keeps everything in memory |
|
||||||
|
| `CMS_POSTGRES_URL` | empty | Reserved for the PostgreSQL persistence adapter |
|
||||||
|
| `CMS_ALLOWED_ORIGINS` | `http://localhost:5173` | `Access-Control-Allow-Origin` value |
|
||||||
|
| `CMS_SSO_HEADER` | `X-SSO-Token` | Identity header set by the proxy; empty disables proxy identity |
|
||||||
|
| `CMS_SSO_TOKEN` | empty | Shared secret the proxy value must match |
|
||||||
|
| `CMS_SSO_COOKIE` | empty | Cookie carrying the same value when the proxy sets no header |
|
||||||
|
| `GO_LOOSE_TENANT` | `tell` | Go Loose tenant that owns the login |
|
||||||
|
| `GO_LOOSE_AUTH_DOMAIN` | `auth.dev` | Identity domain shared by all Go Loose tenants |
|
||||||
|
| `GO_LOOSE_APP_DOMAIN` | `tell.dev` | Public host of the CMS, which also determines the redirect URI |
|
||||||
|
| `GO_LOOSE_CLIENT_ID` | empty | Client id of the Go Loose application |
|
||||||
|
| `GO_LOOSE_CLIENT_SECRET` | empty | Client secret of the Go Loose application |
|
||||||
|
| `GO_LOOSE_CA_FILE` | empty | Extra CA bundle for an internally signed identity domain |
|
||||||
|
|
||||||
The CMS never signs anyone in and stores no users, admins, or sessions. An identity-aware proxy (oauth2-proxy, Authelia, Cloudflare Access, ...) authenticates the owner and forwards a header on every accepted request:
|
## Authentication
|
||||||
|
|
||||||
- **Header present** → the request is the owner and can edit content (`canEdit: true`).
|
Go Tell accepts an editor from three sources, in this order: the demo sudo
|
||||||
- **Header absent** → the request is anonymous and content is read-only (`canEdit: false`).
|
token, the identity-aware proxy header, and the Go Loose browser session.
|
||||||
|
Reading content is always anonymous; `PUT /api/content/:slug` answers `401`
|
||||||
|
without one of them.
|
||||||
|
|
||||||
Configure the header with `CMS_SSO_HEADER` (default `X-SSO-Token`). Set `CMS_SSO_TOKEN` to a shared secret to require an exact value; leave it empty to trust any non-empty forwarded value. `CMS_SSO_COOKIE` optionally names a cookie fallback.
|
### Go Loose
|
||||||
|
|
||||||
Because the backend trusts the forwarded header, it must only be reachable through the proxy. Bind it to the loopback interface or a private network, and make sure the proxy strips any client-supplied copy of that header.
|
Set `GO_LOOSE_CLIENT_ID` and `GO_LOOSE_CLIENT_SECRET` and the CMS grows a
|
||||||
|
browser login:
|
||||||
|
|
||||||
Anonymous visitors get a **Sign in with Google** button in the header. It redirects the browser to `VITE_SSO_LOGIN_URL` (frontend setting), where the provider authenticates the owner and returns to the page; the CMS itself never performs an OAuth exchange and holds no client secret. Leave the setting empty to hide the button when the proxy gates the whole app automatically.
|
1. `GET /api/auth/login` redirects to
|
||||||
|
`https://<GO_LOOSE_TENANT>.<GO_LOOSE_AUTH_DOMAIN>/connect/authorize`.
|
||||||
|
2. Go Loose authenticates the user and redirects to
|
||||||
|
`https://<GO_LOOSE_APP_DOMAIN>/api/auth/callback`, which exchanges the
|
||||||
|
authorization code and stores the session in an HTTP-only cookie.
|
||||||
|
3. `GET /api/auth/session` then reports `authenticated: true`, the editor name,
|
||||||
|
and the login and logout endpoints the frontend follows.
|
||||||
|
4. `GET|POST /api/auth/logout` revokes the session at the identity provider and
|
||||||
|
clears the cookie.
|
||||||
|
|
||||||
|
That redirect URI has to be registered on the Go Loose application, next to the
|
||||||
|
client id and secret. Without credentials the login routes answer `404` with
|
||||||
|
the variables to set, and the CMS falls back to the proxy identity.
|
||||||
|
|
||||||
|
The local platform application lives in the Go Loose tenant `tell`, so the login
|
||||||
|
origin is `https://tell.auth.dev` and the interview accounts are
|
||||||
|
`interview@tell.auth.dev` and `reviewer@tell.auth.dev`. The one-time client
|
||||||
|
secret is recorded in the platform's gitignored `secrets/credentials.md` and in
|
||||||
|
the gitignored `.env` of this repository.
|
||||||
|
|
||||||
|
### Proxy identity
|
||||||
|
|
||||||
|
Without Go Loose, an identity-aware proxy (oauth2-proxy, Authelia, Cloudflare
|
||||||
|
Access, ...) can authenticate the owner and forward a header on every accepted
|
||||||
|
request: the header present means the request can edit, the header absent means
|
||||||
|
read-only. Because the backend trusts that header, it must only be reachable
|
||||||
|
through the proxy, and the proxy must strip any client-supplied copy of it.
|
||||||
|
|
||||||
## Content
|
## Content
|
||||||
|
|
||||||
Content supports one reusable category per document plus any number of badges. The anonymous `GET /api/content` endpoint accepts `category=engineering` and repeated `badge=go&badge=docker` filters; selected badges are combined with AND semantics. `GET /api/content/categories` returns the category dropdown values. The frontend applies these filters immediately when the category changes or when a badge is entered.
|
Content supports one reusable category per document plus any number of badges.
|
||||||
|
The anonymous `GET /api/content` endpoint accepts `category=engineering` and
|
||||||
Editing requires the SSO identity: `PUT /api/content/:slug` returns `401` without it. `GET /api/content/:slug` and the taxonomy endpoints are public.
|
repeated `badge=go&badge=docker` filters; selected badges are combined with AND
|
||||||
|
semantics. `GET /api/content/categories` returns the category dropdown values.
|
||||||
|
The frontend applies these filters immediately when the category changes or when
|
||||||
|
a badge is entered.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
make backend # run the Go server
|
make backend # run the Go server
|
||||||
make frontend # run the React app
|
make frontend # run the React app
|
||||||
make run # build the frontend, then run the server
|
make run # build the frontend, then run the server
|
||||||
|
make check # gofmt, vet, and tests
|
||||||
|
make docker # build the all-in-one image
|
||||||
```
|
```
|
||||||
|
|
||||||
## Rancher deployment
|
## Kubernetes deployment
|
||||||
|
|
||||||
The Helm chart at `deploy/helm/cms` provides two profiles:
|
The Helm chart at `deploy/helm/go-tell` deploys the single image to the k3s
|
||||||
|
cluster behind `ingress-nginx` and `cert-manager`, both of which have to be
|
||||||
- `values-development.yaml` runs the demo profile on NodePort `30081` with
|
present first:
|
||||||
ephemeral content.
|
|
||||||
- `values-production.yaml` runs the seeded demo application on NodePort
|
```sh
|
||||||
`31373` with a persistent content volume. Host Nginx proxies `cms.urpi.be`
|
make cluster-prepare # install ingress-nginx and cert-manager
|
||||||
to this port.
|
make helm-lint # lint and render both profiles
|
||||||
|
make helm-deploy-production # namespace go-tell-production
|
||||||
Validate or deploy either profile with:
|
make helm-deploy-development # namespace go-tell-development
|
||||||
|
```
|
||||||
|
|
||||||
|
Go Loose credentials are never written into a values file. The production target
|
||||||
|
reads `GO_LOOSE_CLIENT_ID`, `GO_LOOSE_CLIENT_SECRET`, and `GO_LOOSE_CA_SECRET`
|
||||||
|
from the environment, after sourcing the gitignored `.env`:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
make helm-lint
|
|
||||||
make helm-deploy-development
|
|
||||||
make helm-deploy-production
|
make helm-deploy-production
|
||||||
```
|
```
|
||||||
|
|
||||||
The deploy workflow automatically deploys a successful `main` image build to
|
They end up in the release secret and in the `--set-string` flags the target
|
||||||
production and also supports manual development or production deployments.
|
builds. Override any of them on the command line when deploying from elsewhere:
|
||||||
Create GitHub environments named `development` and `production`. Each needs
|
|
||||||
the secrets `RANCHER_KUBE_CONFIG_BASE64`, `REGISTRY_USERNAME`, and
|
```sh
|
||||||
`REGISTRY_PASSWORD`. `CMS_SSO_TOKEN` is optional. Environment variables
|
make helm-deploy-production \
|
||||||
`RANCHER_NAMESPACE`, `CMS_ALLOWED_ORIGINS`, `CMS_SSO_HEADER`, and
|
--set-string goLoose.clientID="$GO_LOOSE_CLIENT_ID" \
|
||||||
`CMS_SSO_COOKIE` can override the chart defaults.
|
--set-string goLoose.clientSecret="$GO_LOOSE_CLIENT_SECRET" \
|
||||||
|
--set-string goLoose.ca.existingSecret=local-ca
|
||||||
|
```
|
||||||
|
|
||||||
|
`goLoose.ca.existingSecret` names the Secret holding the platform CA bundle, so
|
||||||
|
the pod trusts `tell.auth.dev`. It has to exist in the namespace before the
|
||||||
|
release starts:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
kubectl -n go-tell-production create secret generic local-ca \
|
||||||
|
--from-file=ca.crt=~/sources/go/infrastructure/certs/local-ca.crt
|
||||||
|
```
|
||||||
|
|
||||||
|
`go-tell-production` is the profile that matters: one host, TLS from
|
||||||
|
cert-manager, and a persistent volume for the content file. `go-tell-development`
|
||||||
|
runs the demo profile on NodePort `30010` with ephemeral content and no Go
|
||||||
|
Loose login, which is what `make frontend` talks to.
|
||||||
|
|
||||||
|
The earlier dedicated-host deployment (`cms.urpi.be` through host Nginx on a
|
||||||
|
Rancher cluster) is retired: `deploy/nginx` and `deploy/rancher` are gone, and
|
||||||
|
every Makefile target talks to `~/.kube/config`.
|
||||||
|
|
||||||
|
## Continuous delivery
|
||||||
|
|
||||||
|
`build-test-push.yml` publishes `batty1039.startdedicated.net:5000/go-tell` on
|
||||||
|
every push to `main`. `deploy-rancher.yml` deploys a successful build to
|
||||||
|
production and can also be dispatched by hand for either profile. Create the
|
||||||
|
GitHub environments `development` and `production` with the secrets
|
||||||
|
`RANCHER_KUBE_CONFIG_BASE64`, `REGISTRY_USERNAME`, `REGISTRY_PASSWORD`,
|
||||||
|
`GO_LOOSE_CLIENT_ID`, `GO_LOOSE_CLIENT_SECRET`, and optionally
|
||||||
|
`CMS_SSO_TOKEN` and `LOCAL_CA_CERT` (the internal CA bundle mounted through
|
||||||
|
`goLoose.ca.existingSecret`). The variables `RANCHER_NAMESPACE`,
|
||||||
|
`CMS_ALLOWED_ORIGINS`, `CMS_SSO_HEADER`, and `CMS_SSO_COOKIE` override the
|
||||||
|
chart defaults.
|
||||||
|
|
@ -7,10 +7,11 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/danyel/go-cms/internal/config"
|
"github.com/danyel/go-tell/internal/config"
|
||||||
"github.com/danyel/go-cms/internal/security"
|
"github.com/danyel/go-tell/internal/golooseauth"
|
||||||
"github.com/danyel/go-cms/internal/service"
|
"github.com/danyel/go-tell/internal/security"
|
||||||
"github.com/danyel/go-cms/internal/web"
|
"github.com/danyel/go-tell/internal/service"
|
||||||
|
"github.com/danyel/go-tell/internal/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
// web/dist holds the compiled React app. A placeholder file is committed so the
|
// web/dist holds the compiled React app. A placeholder file is committed so the
|
||||||
|
|
@ -33,6 +34,10 @@ func main() {
|
||||||
}
|
}
|
||||||
|
|
||||||
sso := security.NewSSOTokenVerifier(cfg.SSOHeader, cfg.SSOToken, cfg.SSOCookie)
|
sso := security.NewSSOTokenVerifier(cfg.SSOHeader, cfg.SSOToken, cfg.SSOCookie)
|
||||||
|
browser, err := golooseauth.New(cfg.GoLoose)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
var demo *service.DemoAuth
|
var demo *service.DemoAuth
|
||||||
if cfg.Profile == "demo" {
|
if cfg.Profile == "demo" {
|
||||||
demo = service.NewDemoAuth()
|
demo = service.NewDemoAuth()
|
||||||
|
|
@ -40,9 +45,14 @@ func main() {
|
||||||
} else {
|
} else {
|
||||||
log.Printf("CMS profile: %s (SSO identity required; PostgreSQL URL configured: %t)", cfg.Profile, cfg.PostgresURL != "")
|
log.Printf("CMS profile: %s (SSO identity required; PostgreSQL URL configured: %t)", cfg.Profile, cfg.PostgresURL != "")
|
||||||
}
|
}
|
||||||
|
if browser != nil {
|
||||||
|
log.Printf("Go Loose login enabled for tenant %q", cfg.GoLoose.Tenant)
|
||||||
|
} else {
|
||||||
|
log.Print("Go Loose login disabled: set GO_LOOSE_CLIENT_ID and GO_LOOSE_CLIENT_SECRET to enable it")
|
||||||
|
}
|
||||||
server := &http.Server{
|
server := &http.Server{
|
||||||
Addr: cfg.HTTPAddr,
|
Addr: cfg.HTTPAddr,
|
||||||
Handler: web.NewHandler(content, assets, sso, demo).Routes(cfg.AllowedOrigins),
|
Handler: web.NewHandler(content, assets, sso, browser, demo).Routes(cfg.AllowedOrigins),
|
||||||
ReadHeaderTimeout: 10 * time.Second,
|
ReadHeaderTimeout: 10 * time.Second,
|
||||||
}
|
}
|
||||||
log.Printf("CMS listening on %s", cfg.HTTPAddr)
|
log.Printf("CMS listening on %s", cfg.HTTPAddr)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
apiVersion: v2
|
|
||||||
name: cms
|
|
||||||
description: CMS application
|
|
||||||
type: application
|
|
||||||
version: 0.1.0
|
|
||||||
appVersion: "0.1.0"
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
CMS has been installed in namespace {{ .Release.Namespace }}.
|
|
||||||
|
|
||||||
{{- if .Values.ingress.enabled }}
|
|
||||||
Open the host configured in ingress.hosts.
|
|
||||||
{{- else if eq .Values.service.type "NodePort" }}
|
|
||||||
The application is exposed on cluster node port {{ .Values.service.nodePort }}.
|
|
||||||
Open it through the configured external reverse proxy.
|
|
||||||
{{- else }}
|
|
||||||
Run:
|
|
||||||
kubectl -n {{ .Release.Namespace }} port-forward service/{{ include "cms.fullname" . }} 8080:{{ .Values.service.port }}
|
|
||||||
Then open http://127.0.0.1:8080.
|
|
||||||
{{- end }}
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
{{- define "cms.name" -}}
|
|
||||||
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
|
|
||||||
{{- end }}
|
|
||||||
|
|
||||||
{{- define "cms.fullname" -}}
|
|
||||||
{{- if .Values.fullnameOverride }}
|
|
||||||
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
|
|
||||||
{{- else }}
|
|
||||||
{{- $name := default .Chart.Name .Values.nameOverride }}
|
|
||||||
{{- if contains $name .Release.Name }}
|
|
||||||
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
|
|
||||||
{{- else }}
|
|
||||||
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
|
|
||||||
{{- end }}
|
|
||||||
{{- end }}
|
|
||||||
{{- end }}
|
|
||||||
|
|
||||||
{{- define "cms.labels" -}}
|
|
||||||
helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" }}
|
|
||||||
app.kubernetes.io/name: {{ include "cms.name" . }}
|
|
||||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
|
||||||
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
|
|
||||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
|
||||||
{{- end }}
|
|
||||||
|
|
||||||
{{- define "cms.selectorLabels" -}}
|
|
||||||
app.kubernetes.io/name: {{ include "cms.name" . }}
|
|
||||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
|
||||||
{{- end }}
|
|
||||||
|
|
||||||
{{- define "cms.secretName" -}}
|
|
||||||
{{- printf "%s-secrets" (include "cms.fullname" .) }}
|
|
||||||
{{- end }}
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
{{- if .Values.ingress.enabled }}
|
|
||||||
apiVersion: networking.k8s.io/v1
|
|
||||||
kind: Ingress
|
|
||||||
metadata:
|
|
||||||
name: {{ include "cms.fullname" . }}
|
|
||||||
labels:
|
|
||||||
{{- include "cms.labels" . | nindent 4 }}
|
|
||||||
{{- with .Values.ingress.annotations }}
|
|
||||||
annotations:
|
|
||||||
{{- toYaml . | nindent 4 }}
|
|
||||||
{{- end }}
|
|
||||||
spec:
|
|
||||||
{{- with .Values.ingress.className }}
|
|
||||||
ingressClassName: {{ . }}
|
|
||||||
{{- end }}
|
|
||||||
{{- with .Values.ingress.tls }}
|
|
||||||
tls:
|
|
||||||
{{- toYaml . | nindent 4 }}
|
|
||||||
{{- end }}
|
|
||||||
rules:
|
|
||||||
{{- range .Values.ingress.hosts }}
|
|
||||||
- host: {{ .host | quote }}
|
|
||||||
http:
|
|
||||||
paths:
|
|
||||||
{{- range .paths }}
|
|
||||||
- path: {{ .path }}
|
|
||||||
pathType: {{ .pathType }}
|
|
||||||
backend:
|
|
||||||
service:
|
|
||||||
name: {{ include "cms.fullname" $ }}
|
|
||||||
port:
|
|
||||||
number: {{ $.Values.service.port }}
|
|
||||||
{{- end }}
|
|
||||||
{{- end }}
|
|
||||||
{{- end }}
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
apiVersion: v1
|
|
||||||
kind: Secret
|
|
||||||
metadata:
|
|
||||||
name: {{ include "cms.secretName" . }}
|
|
||||||
labels:
|
|
||||||
{{- include "cms.labels" . | nindent 4 }}
|
|
||||||
type: Opaque
|
|
||||||
stringData:
|
|
||||||
sso-token: {{ .Values.secrets.ssoToken | quote }}
|
|
||||||
|
|
@ -1,76 +0,0 @@
|
||||||
nameOverride: ""
|
|
||||||
fullnameOverride: ""
|
|
||||||
|
|
||||||
replicaCount: 1
|
|
||||||
|
|
||||||
image:
|
|
||||||
repository: batty1039.startdedicated.net:5000/cms
|
|
||||||
tag: latest
|
|
||||||
pullPolicy: IfNotPresent
|
|
||||||
|
|
||||||
imagePullSecrets: []
|
|
||||||
|
|
||||||
app:
|
|
||||||
environment: production
|
|
||||||
allowedOrigins: http://localhost:8080
|
|
||||||
contentFile: /data/content.json
|
|
||||||
postgresURL: ""
|
|
||||||
ssoHeader: X-SSO-Token
|
|
||||||
ssoCookie: ""
|
|
||||||
|
|
||||||
secrets:
|
|
||||||
ssoToken: ""
|
|
||||||
|
|
||||||
service:
|
|
||||||
type: ClusterIP
|
|
||||||
port: 80
|
|
||||||
nodePort: null
|
|
||||||
|
|
||||||
ingress:
|
|
||||||
enabled: false
|
|
||||||
className: nginx
|
|
||||||
annotations: {}
|
|
||||||
hosts:
|
|
||||||
- host: cms.local
|
|
||||||
paths:
|
|
||||||
- path: /
|
|
||||||
pathType: Prefix
|
|
||||||
tls: []
|
|
||||||
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
cpu: 100m
|
|
||||||
memory: 128Mi
|
|
||||||
limits:
|
|
||||||
cpu: 500m
|
|
||||||
memory: 512Mi
|
|
||||||
|
|
||||||
podAnnotations: {}
|
|
||||||
podLabels: {}
|
|
||||||
|
|
||||||
podSecurityContext:
|
|
||||||
fsGroup: 10001
|
|
||||||
fsGroupChangePolicy: OnRootMismatch
|
|
||||||
|
|
||||||
securityContext:
|
|
||||||
allowPrivilegeEscalation: false
|
|
||||||
capabilities:
|
|
||||||
drop:
|
|
||||||
- ALL
|
|
||||||
readOnlyRootFilesystem: true
|
|
||||||
runAsNonRoot: true
|
|
||||||
runAsUser: 10001
|
|
||||||
|
|
||||||
strategy:
|
|
||||||
type: Recreate
|
|
||||||
|
|
||||||
persistence:
|
|
||||||
enabled: true
|
|
||||||
size: 2Gi
|
|
||||||
accessModes:
|
|
||||||
- ReadWriteOnce
|
|
||||||
storageClass: ""
|
|
||||||
|
|
||||||
nodeSelector: {}
|
|
||||||
tolerations: []
|
|
||||||
affinity: {}
|
|
||||||
6
deploy/helm/go-tell/Chart.yaml
Normal file
6
deploy/helm/go-tell/Chart.yaml
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
apiVersion: v2
|
||||||
|
name: go-tell
|
||||||
|
description: Go Tell, a single-host content CMS behind Go Loose
|
||||||
|
type: application
|
||||||
|
version: 0.2.0
|
||||||
|
appVersion: "0.1.0"
|
||||||
221
deploy/helm/go-tell/README.md
Normal file
221
deploy/helm/go-tell/README.md
Normal file
|
|
@ -0,0 +1,221 @@
|
||||||
|
# Go Tell Helm chart
|
||||||
|
|
||||||
|
This chart deploys Go Tell as one image: a Go binary that serves the API and
|
||||||
|
embeds the React frontend, with a persistent volume for the content file. It
|
||||||
|
publishes a single HTTPS host, `https://tell.dev`, behind ingress-nginx with a
|
||||||
|
certificate from cert-manager.
|
||||||
|
|
||||||
|
## 1. What the chart creates
|
||||||
|
|
||||||
|
| Resource | Name | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| Deployment | `go-tell` | CMS binary, API and web UI in one container |
|
||||||
|
| Service | `go-tell` | Cluster-internal service, ingress backend |
|
||||||
|
| Ingress | `go-tell` | One rule and one TLS SAN for `tell.dev` |
|
||||||
|
| Issuer | `go-tell-selfsigned` | cert-manager self-signed issuer |
|
||||||
|
| Certificate | `go-tell-ca` | Chart CA that signs the leaf certificate |
|
||||||
|
| Certificate | `go-tell` | Leaf certificate for `tell.dev` |
|
||||||
|
| PersistentVolumeClaim | `go-tell` | Content file storage, `emptyDir` when disabled |
|
||||||
|
| Secret | `go-tell-secrets` | Go Loose client credentials and the proxy token |
|
||||||
|
|
||||||
|
There are no tenants and no per-tenant hosts: one release, one host, one
|
||||||
|
content file.
|
||||||
|
|
||||||
|
## 2. Prerequisites
|
||||||
|
|
||||||
|
- A Kubernetes cluster reachable through `KUBECONFIG`, by default
|
||||||
|
`~/.kube/config`.
|
||||||
|
- A `StorageClass` when `persistence.enabled` is `true`.
|
||||||
|
- ingress-nginx with the `nginx` ingress class: `make ingress-nginx-install`.
|
||||||
|
- cert-manager when `ingress.tls.enabled` is `true`: `make cert-manager-install`.
|
||||||
|
- Cluster nodes trusting `batty1039.startdedicated.net:5000` as an insecure
|
||||||
|
registry, or an HTTPS registry.
|
||||||
|
|
||||||
|
Run everything the chart depends on in one step:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make cluster-prepare
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Deploy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make helm-lint
|
||||||
|
make helm-deploy-development
|
||||||
|
```
|
||||||
|
|
||||||
|
The development profile runs the demo profile on NodePort `30010` with
|
||||||
|
ephemeral content and Go Loose disabled, which is what `make frontend` talks to.
|
||||||
|
Production runs the persisted content file with browser sign-in:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export GO_LOOSE_CLIENT_ID=...
|
||||||
|
export GO_LOOSE_CLIENT_SECRET=...
|
||||||
|
make helm-deploy-production
|
||||||
|
```
|
||||||
|
|
||||||
|
Credentials are never written into a values file. They reach the release
|
||||||
|
secret through `--set`, or through the `GO_LOOSE_CLIENT_ID` and
|
||||||
|
`GO_LOOSE_CLIENT_SECRET` secrets of the deploy workflow. The chart refuses to
|
||||||
|
render when `goLoose.enabled` is true and either of them is missing, because a
|
||||||
|
release without credentials would have no way to sign anybody in.
|
||||||
|
|
||||||
|
After a successful install the release notes list the URL and the command that
|
||||||
|
exports the CA certificate. With the local platform, prefer
|
||||||
|
`docs/KUBERNETES-DEPLOYMENTS.md` in the infrastructure repository, which covers
|
||||||
|
`/etc/hosts` entries and CA trust for the whole platform.
|
||||||
|
|
||||||
|
## 4. The single host
|
||||||
|
|
||||||
|
`host` is the only host the release serves:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
host: tell.dev
|
||||||
|
```
|
||||||
|
|
||||||
|
The chart derives, without any extra configuration:
|
||||||
|
|
||||||
|
- One ingress rule for that host.
|
||||||
|
- One certificate SAN for that host, so a single secret covers the release.
|
||||||
|
- `GO_LOOSE_APP_DOMAIN`, which the API turns into the redirect URI
|
||||||
|
`https://tell.dev/api/auth/callback`.
|
||||||
|
|
||||||
|
That redirect URI has to be registered on the Go Loose application together with
|
||||||
|
the client id and secret. `goLoose.tenant` names the Go Loose tenant that owns
|
||||||
|
the login, which decides where the browser is sent first:
|
||||||
|
|
||||||
|
```
|
||||||
|
https://tell.dev/api/auth/login
|
||||||
|
-> https://tell.auth.dev/connect/authorize
|
||||||
|
-> https://tell.dev/api/auth/callback
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Values
|
||||||
|
|
||||||
|
### Top level
|
||||||
|
|
||||||
|
| Key | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `nameOverride` | `""` | Overrides the chart name in resource names and labels |
|
||||||
|
| `fullnameOverride` | `""` | Replaces the generated resource name prefix |
|
||||||
|
| `host` | `tell.dev` | Public host of the CMS |
|
||||||
|
| `replicaCount` | `1` | CMS replicas, the content file is shared through the volume |
|
||||||
|
| `imagePullSecrets` | `[]` | Pull secrets added to the pod |
|
||||||
|
| `nodeSelector`, `tolerations`, `affinity` | empty | Scheduling of the deployment |
|
||||||
|
|
||||||
|
### image and app
|
||||||
|
|
||||||
|
| Key | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `image.repository` | `batty1039.startdedicated.net:5000/go-tell` | CMS image |
|
||||||
|
| `image.tag` | `latest` | CMS image tag |
|
||||||
|
| `image.pullPolicy` | `IfNotPresent` | CMS pull policy |
|
||||||
|
| `app.profile` | `production` | `CMS_PROFILE`, `demo` enables the in-memory seed and the sudo terminal |
|
||||||
|
| `app.allowedOrigins` | `https://tell.dev` | `CMS_ALLOWED_ORIGINS` |
|
||||||
|
| `app.contentFile` | `/data/content.json` | `CMS_CONTENT_FILE`, ignored when persistence is disabled |
|
||||||
|
| `app.postgresURL` | `""` | `CMS_POSTGRES_URL`, reserved for the PostgreSQL adapter |
|
||||||
|
| `app.ssoHeader` | `X-SSO-Token` | `CMS_SSO_HEADER`, empty disables the proxy identity |
|
||||||
|
| `app.ssoCookie` | `""` | `CMS_SSO_COOKIE`, cookie carrying the same value |
|
||||||
|
| `app.resources` | `100m`/`128Mi` to `500m`/`512Mi` | Requests and limits |
|
||||||
|
| `app.podAnnotations`, `app.podLabels` | `{}` | Extra pod metadata |
|
||||||
|
|
||||||
|
### goLoose
|
||||||
|
|
||||||
|
| Key | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `goLoose.enabled` | `true` | Injects the Go Loose environment into the pod |
|
||||||
|
| `goLoose.tenant` | `tell` | Go Loose tenant that owns the login, one DNS label |
|
||||||
|
| `goLoose.authDomain` | `auth.dev` | Identity domain shared by every Go Loose tenant |
|
||||||
|
| `goLoose.appDomain` | `tell.dev` | Public host, which determines the redirect URI |
|
||||||
|
| `goLoose.clientID` | `""` | Client id of the Go Loose application, required when enabled |
|
||||||
|
| `goLoose.clientSecret` | `""` | Client secret of the Go Loose application, required when enabled |
|
||||||
|
| `goLoose.ca.existingSecret` | `""` | Secret holding the CA bundle of an internally signed identity domain |
|
||||||
|
| `goLoose.ca.key` | `ca.crt` | Key projected from that secret |
|
||||||
|
| `goLoose.ca.mountPath` | `/certs/local-ca.crt` | `GO_LOOSE_CA_FILE` target |
|
||||||
|
| `secrets.ssoToken` | `""` | Shared secret the proxy header value must match |
|
||||||
|
|
||||||
|
The CA bundle is only mounted when `goLoose.ca.existingSecret` is set. A
|
||||||
|
publicly signed identity domain therefore needs no extra configuration, while an
|
||||||
|
internally signed one, such as the local platform, has to publish its
|
||||||
|
certificate first:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n go-tell-production create secret generic local-ca --from-file=ca.crt=certs/local-ca.crt
|
||||||
|
```
|
||||||
|
|
||||||
|
### ingress and certManager
|
||||||
|
|
||||||
|
| Key | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `ingress.enabled` | `true` | Creates the ingress |
|
||||||
|
| `ingress.className` | `nginx` | Ingress class of the controller |
|
||||||
|
| `ingress.annotations` | TLS redirect | Controller annotations |
|
||||||
|
| `ingress.path` | `/` | Path routed to the service |
|
||||||
|
| `ingress.pathType` | `Prefix` | Path type of that route |
|
||||||
|
| `ingress.tls.enabled` | `true` | Terminates TLS on the controller |
|
||||||
|
| `ingress.tls.secretName` | `""` | Defaults to `<fullname>-tls` |
|
||||||
|
| `ingress.tls.existingSecret` | `""` | Uses a certificate managed outside the chart |
|
||||||
|
| `certManager.enabled` | `true` | Chart creates the certificates |
|
||||||
|
| `certManager.issuerRef` | `{}` | Uses an existing issuer instead of the chart CA |
|
||||||
|
| `certManager.duration` | `8760h` | Certificate lifetime |
|
||||||
|
| `certManager.renewBefore` | `720h` | Renewal window |
|
||||||
|
| `certManager.caSecretName` | `""` | Defaults to `<fullname>-ca` |
|
||||||
|
|
||||||
|
Four certificate objects are created: a self-signed `Issuer`, a CA
|
||||||
|
`Certificate` stored in `caSecretName`, an `Issuer` that serves that CA, and the
|
||||||
|
leaf `Certificate` stored in the ingress TLS secret for `host`. Set
|
||||||
|
`certManager.issuerRef` to sign with a cluster issuer instead:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
certManager:
|
||||||
|
issuerRef:
|
||||||
|
name: internal-acme
|
||||||
|
kind: ClusterIssuer
|
||||||
|
group: cert-manager.io
|
||||||
|
```
|
||||||
|
|
||||||
|
Set `ingress.tls.existingSecret` to use a certificate the chart does not own,
|
||||||
|
and `certManager.enabled: false` to stop emitting cert-manager objects.
|
||||||
|
|
||||||
|
### Storage and pod security
|
||||||
|
|
||||||
|
| Key | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `persistence.enabled` | `true` | Persistent volume for the content file |
|
||||||
|
| `persistence.size` | `2Gi` | Volume size |
|
||||||
|
| `persistence.accessModes` | `[ReadWriteOnce]` | Volume access modes |
|
||||||
|
| `persistence.storageClass` | `""` | Storage class, the cluster default when empty |
|
||||||
|
| `podSecurityContext` | `fsGroup: 10001` | Pod security context, gives the volume to the image user |
|
||||||
|
| `securityContext` | non-root `10001`, read-only root filesystem | Container security context |
|
||||||
|
| `strategy.type` | `Recreate` | The old pod releases the volume before the new one starts |
|
||||||
|
|
||||||
|
The image runs as its own `go-tell` user (uid `10001`) and declares no
|
||||||
|
`HOME`, so `readOnlyRootFilesystem` needs the writable `/tmp` mount the chart
|
||||||
|
adds.
|
||||||
|
|
||||||
|
## 6. Upgrade and remove
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make helm-deploy-production
|
||||||
|
helm -n go-tell-production history go-tell
|
||||||
|
helm -n go-tell-production rollback go-tell 1
|
||||||
|
make helm-down-production
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep the Go Loose credentials outside the cluster: uninstalling removes the
|
||||||
|
release secret, and Go Loose shows a new secret only once.
|
||||||
|
|
||||||
|
## 7. Verify
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n go-tell-production get ingress go-tell
|
||||||
|
kubectl -n go-tell-production get certificate
|
||||||
|
kubectl -n go-tell-production rollout status deployment/go-tell
|
||||||
|
curl --resolve tell.dev:443:127.0.0.1 --cacert go-tell-ca.crt https://tell.dev/health
|
||||||
|
curl -I --cacert go-tell-ca.crt https://tell.dev/api/auth/login
|
||||||
|
```
|
||||||
|
|
||||||
|
The login request must answer `302` towards
|
||||||
|
`https://tell.auth.dev/connect/authorize`; a `404` means the release has no Go
|
||||||
|
Loose credentials. The test pod installed by `helm test` checks the health
|
||||||
|
endpoint from inside the cluster.
|
||||||
35
deploy/helm/go-tell/templates/NOTES.txt
Normal file
35
deploy/helm/go-tell/templates/NOTES.txt
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
Go Tell is installed in namespace {{ .Release.Namespace }}.
|
||||||
|
|
||||||
|
{{- if .Values.ingress.enabled }}
|
||||||
|
|
||||||
|
Open https://{{ .Values.host }}/.
|
||||||
|
|
||||||
|
{{- if .Values.goLoose.enabled }}
|
||||||
|
|
||||||
|
Signing in goes through Go Loose:
|
||||||
|
1. https://{{ .Values.goLoose.tenant }}.{{ .Values.goLoose.authDomain }}/connect/authorize redirects to
|
||||||
|
https://{{ .Values.goLoose.appDomain }}/api/auth/callback.
|
||||||
|
2. That redirect URI has to be registered on the Go Loose application, next
|
||||||
|
to client id {{ .Values.goLoose.clientID }}.
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.ingress.tls.enabled }}
|
||||||
|
|
||||||
|
{{- if .Values.certManager.enabled }}
|
||||||
|
{{- if not .Values.certManager.issuerRef }}
|
||||||
|
The certificate is issued by the self-signed chain in this release. Export the
|
||||||
|
root once so the browser trusts it:
|
||||||
|
kubectl -n {{ .Release.Namespace }} get secret {{ include "go-tell.certManagerCASecretName" . }} \
|
||||||
|
-o jsonpath='{.data.ca\.crt}' | base64 -d > ca.crt
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
{{- else if eq .Values.service.type "NodePort" }}
|
||||||
|
|
||||||
|
The application is exposed on cluster node port {{ .Values.service.nodePort }}.
|
||||||
|
Open it through the configured external reverse proxy.
|
||||||
|
{{- else }}
|
||||||
|
|
||||||
|
Run:
|
||||||
|
kubectl -n {{ .Release.Namespace }} port-forward service/{{ include "go-tell.fullname" . }} 8080:{{ .Values.service.port }}
|
||||||
|
Then open http://127.0.0.1:8080.
|
||||||
|
{{- end }}
|
||||||
77
deploy/helm/go-tell/templates/_helpers.tpl
Normal file
77
deploy/helm/go-tell/templates/_helpers.tpl
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
{{- define "go-tell.name" -}}
|
||||||
|
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{- define "go-tell.fullname" -}}
|
||||||
|
{{- if .Values.fullnameOverride }}
|
||||||
|
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
|
||||||
|
{{- else }}
|
||||||
|
{{- $name := default .Chart.Name .Values.nameOverride }}
|
||||||
|
{{- if contains $name .Release.Name }}
|
||||||
|
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
|
||||||
|
{{- else }}
|
||||||
|
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{- define "go-tell.labels" -}}
|
||||||
|
helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" }}
|
||||||
|
app.kubernetes.io/name: {{ include "go-tell.name" . }}
|
||||||
|
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||||
|
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
|
||||||
|
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{- define "go-tell.selectorLabels" -}}
|
||||||
|
app.kubernetes.io/name: {{ include "go-tell.name" . }}
|
||||||
|
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{- define "go-tell.secretName" -}}
|
||||||
|
{{- printf "%s-secrets" (include "go-tell.fullname" .) }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{- define "go-tell.certManagerCASecretName" -}}
|
||||||
|
{{- default (printf "%s-ca" (include "go-tell.fullname" .)) .Values.certManager.caSecretName }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{- define "go-tell.tlsSecretName" -}}
|
||||||
|
{{- default (printf "%s-tls" (include "go-tell.fullname" .)) .Values.ingress.tls.secretName }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{- define "go-tell.certManagerIssuerRef" -}}
|
||||||
|
{{- if kindIs "map" .Values.certManager.issuerRef }}
|
||||||
|
name: {{ required "certManager.issuerRef.name is required when certManager.issuerRef is set" .Values.certManager.issuerRef.name }}
|
||||||
|
kind: {{ default "Issuer" .Values.certManager.issuerRef.kind }}
|
||||||
|
group: {{ default "cert-manager.io" .Values.certManager.issuerRef.group }}
|
||||||
|
{{- else }}
|
||||||
|
name: {{ .Values.certManager.issuerRef }}
|
||||||
|
kind: Issuer
|
||||||
|
group: cert-manager.io
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
CA bundle used to verify the identity domain. Mounted only when the operator
|
||||||
|
points goLoose.ca.existingSecret at a certificate, so a publicly signed identity
|
||||||
|
domain needs no extra configuration.
|
||||||
|
*/}}
|
||||||
|
{{- define "go-tell.caVolume" -}}
|
||||||
|
{{- if and .Values.goLoose.enabled .Values.goLoose.ca.existingSecret }}
|
||||||
|
- name: go-loose-ca
|
||||||
|
secret:
|
||||||
|
secretName: {{ .Values.goLoose.ca.existingSecret }}
|
||||||
|
items:
|
||||||
|
- key: {{ .Values.goLoose.ca.key }}
|
||||||
|
path: {{ base .Values.goLoose.ca.mountPath }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{- define "go-tell.caVolumeMount" -}}
|
||||||
|
{{- if and .Values.goLoose.enabled .Values.goLoose.ca.existingSecret }}
|
||||||
|
- name: go-loose-ca
|
||||||
|
mountPath: {{ dir .Values.goLoose.ca.mountPath }}
|
||||||
|
readOnly: true
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
|
@ -1,16 +1,16 @@
|
||||||
apiVersion: apps/v1
|
apiVersion: apps/v1
|
||||||
kind: Deployment
|
kind: Deployment
|
||||||
metadata:
|
metadata:
|
||||||
name: {{ include "cms.fullname" . }}
|
name: {{ include "go-tell.fullname" . }}
|
||||||
labels:
|
labels:
|
||||||
{{- include "cms.labels" . | nindent 4 }}
|
{{- include "go-tell.labels" . | nindent 4 }}
|
||||||
spec:
|
spec:
|
||||||
replicas: {{ .Values.replicaCount }}
|
replicas: {{ .Values.replicaCount }}
|
||||||
strategy:
|
strategy:
|
||||||
type: {{ .Values.strategy.type }}
|
type: {{ .Values.strategy.type }}
|
||||||
selector:
|
selector:
|
||||||
matchLabels:
|
matchLabels:
|
||||||
{{- include "cms.selectorLabels" . | nindent 6 }}
|
{{- include "go-tell.selectorLabels" . | nindent 6 }}
|
||||||
app.kubernetes.io/component: application
|
app.kubernetes.io/component: application
|
||||||
template:
|
template:
|
||||||
metadata:
|
metadata:
|
||||||
|
|
@ -20,7 +20,7 @@ spec:
|
||||||
{{- toYaml . | nindent 8 }}
|
{{- toYaml . | nindent 8 }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
labels:
|
labels:
|
||||||
{{- include "cms.selectorLabels" . | nindent 8 }}
|
{{- include "go-tell.selectorLabels" . | nindent 8 }}
|
||||||
app.kubernetes.io/component: application
|
app.kubernetes.io/component: application
|
||||||
{{- with .Values.podLabels }}
|
{{- with .Values.podLabels }}
|
||||||
{{- toYaml . | nindent 8 }}
|
{{- toYaml . | nindent 8 }}
|
||||||
|
|
@ -34,14 +34,14 @@ spec:
|
||||||
securityContext:
|
securityContext:
|
||||||
{{- toYaml .Values.podSecurityContext | nindent 8 }}
|
{{- toYaml .Values.podSecurityContext | nindent 8 }}
|
||||||
containers:
|
containers:
|
||||||
- name: cms
|
- name: go-tell
|
||||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
|
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
|
||||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||||
securityContext:
|
securityContext:
|
||||||
{{- toYaml .Values.securityContext | nindent 12 }}
|
{{- toYaml .Values.securityContext | nindent 12 }}
|
||||||
env:
|
env:
|
||||||
- name: CMS_PROFILE
|
- name: CMS_PROFILE
|
||||||
value: {{ .Values.app.environment | quote }}
|
value: {{ .Values.app.profile | quote }}
|
||||||
- name: CMS_ALLOWED_ORIGINS
|
- name: CMS_ALLOWED_ORIGINS
|
||||||
value: {{ .Values.app.allowedOrigins | quote }}
|
value: {{ .Values.app.allowedOrigins | quote }}
|
||||||
- name: CMS_CONTENT_FILE
|
- name: CMS_CONTENT_FILE
|
||||||
|
|
@ -55,8 +55,30 @@ spec:
|
||||||
- name: CMS_SSO_TOKEN
|
- name: CMS_SSO_TOKEN
|
||||||
valueFrom:
|
valueFrom:
|
||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: {{ include "cms.secretName" . }}
|
name: {{ include "go-tell.secretName" . }}
|
||||||
key: sso-token
|
key: sso-token
|
||||||
|
{{- if .Values.goLoose.enabled }}
|
||||||
|
- name: GO_LOOSE_TENANT
|
||||||
|
value: {{ .Values.goLoose.tenant | quote }}
|
||||||
|
- name: GO_LOOSE_AUTH_DOMAIN
|
||||||
|
value: {{ .Values.goLoose.authDomain | quote }}
|
||||||
|
- name: GO_LOOSE_APP_DOMAIN
|
||||||
|
value: {{ .Values.goLoose.appDomain | quote }}
|
||||||
|
- name: GO_LOOSE_CLIENT_ID
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ include "go-tell.secretName" . }}
|
||||||
|
key: go-loose-client-id
|
||||||
|
- name: GO_LOOSE_CLIENT_SECRET
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ include "go-tell.secretName" . }}
|
||||||
|
key: go-loose-client-secret
|
||||||
|
{{- if .Values.goLoose.ca.existingSecret }}
|
||||||
|
- name: GO_LOOSE_CA_FILE
|
||||||
|
value: {{ .Values.goLoose.ca.mountPath | quote }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
ports:
|
ports:
|
||||||
- name: http
|
- name: http
|
||||||
containerPort: 8080
|
containerPort: 8080
|
||||||
|
|
@ -86,14 +108,16 @@ spec:
|
||||||
- name: content
|
- name: content
|
||||||
mountPath: /data
|
mountPath: /data
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
{{- include "go-tell.caVolumeMount" . | nindent 12 }}
|
||||||
volumes:
|
volumes:
|
||||||
- name: temporary-files
|
- name: temporary-files
|
||||||
emptyDir: {}
|
emptyDir: {}
|
||||||
{{- if .Values.persistence.enabled }}
|
{{- if .Values.persistence.enabled }}
|
||||||
- name: content
|
- name: content
|
||||||
persistentVolumeClaim:
|
persistentVolumeClaim:
|
||||||
claimName: {{ include "cms.fullname" . }}
|
claimName: {{ include "go-tell.fullname" . }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
{{- include "go-tell.caVolume" . | nindent 8 }}
|
||||||
{{- with .Values.nodeSelector }}
|
{{- with .Values.nodeSelector }}
|
||||||
nodeSelector:
|
nodeSelector:
|
||||||
{{- toYaml . | nindent 8 }}
|
{{- toYaml . | nindent 8 }}
|
||||||
|
|
@ -105,4 +129,4 @@ spec:
|
||||||
{{- with .Values.tolerations }}
|
{{- with .Values.tolerations }}
|
||||||
tolerations:
|
tolerations:
|
||||||
{{- toYaml . | nindent 8 }}
|
{{- toYaml . | nindent 8 }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: Service
|
kind: Service
|
||||||
metadata:
|
metadata:
|
||||||
name: {{ include "cms.fullname" . }}
|
name: {{ include "go-tell.fullname" . }}
|
||||||
labels:
|
labels:
|
||||||
{{- include "cms.labels" . | nindent 4 }}
|
{{- include "go-tell.labels" . | nindent 4 }}
|
||||||
spec:
|
spec:
|
||||||
type: {{ .Values.service.type }}
|
type: {{ .Values.service.type }}
|
||||||
selector:
|
selector:
|
||||||
{{- include "cms.selectorLabels" . | nindent 4 }}
|
{{- include "go-tell.selectorLabels" . | nindent 4 }}
|
||||||
app.kubernetes.io/component: application
|
app.kubernetes.io/component: application
|
||||||
ports:
|
ports:
|
||||||
- name: http
|
- name: http
|
||||||
74
deploy/helm/go-tell/templates/certificate.yaml
Normal file
74
deploy/helm/go-tell/templates/certificate.yaml
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
{{- if and .Values.ingress.enabled .Values.ingress.tls.enabled .Values.certManager.enabled (not .Values.ingress.tls.existingSecret) }}
|
||||||
|
{{- $fullname := include "go-tell.fullname" . }}
|
||||||
|
{{- $caSecret := include "go-tell.certManagerCASecretName" . }}
|
||||||
|
{{- $tlsSecret := include "go-tell.tlsSecretName" . }}
|
||||||
|
{{- if not .Values.certManager.issuerRef }}
|
||||||
|
apiVersion: cert-manager.io/v1
|
||||||
|
kind: Issuer
|
||||||
|
metadata:
|
||||||
|
name: {{ $fullname }}-selfsigned
|
||||||
|
labels:
|
||||||
|
{{- include "go-tell.labels" . | nindent 4 }}
|
||||||
|
app.kubernetes.io/component: certificate
|
||||||
|
spec:
|
||||||
|
selfSigned: {}
|
||||||
|
---
|
||||||
|
apiVersion: cert-manager.io/v1
|
||||||
|
kind: Certificate
|
||||||
|
metadata:
|
||||||
|
name: {{ $fullname }}-ca
|
||||||
|
labels:
|
||||||
|
{{- include "go-tell.labels" . | nindent 4 }}
|
||||||
|
app.kubernetes.io/component: certificate
|
||||||
|
spec:
|
||||||
|
secretName: {{ $caSecret }}
|
||||||
|
commonName: {{ printf "%s-ca" $fullname }}
|
||||||
|
duration: {{ .Values.certManager.duration }}
|
||||||
|
renewBefore: {{ .Values.certManager.renewBefore }}
|
||||||
|
privateKey:
|
||||||
|
algorithm: ECDSA
|
||||||
|
size: 256
|
||||||
|
issuerRef:
|
||||||
|
name: {{ $fullname }}-selfsigned
|
||||||
|
kind: Issuer
|
||||||
|
group: cert-manager.io
|
||||||
|
---
|
||||||
|
apiVersion: cert-manager.io/v1
|
||||||
|
kind: Issuer
|
||||||
|
metadata:
|
||||||
|
name: {{ $fullname }}-ca
|
||||||
|
labels:
|
||||||
|
{{- include "go-tell.labels" . | nindent 4 }}
|
||||||
|
app.kubernetes.io/component: certificate
|
||||||
|
spec:
|
||||||
|
ca:
|
||||||
|
secretName: {{ $caSecret }}
|
||||||
|
---
|
||||||
|
{{- end }}
|
||||||
|
apiVersion: cert-manager.io/v1
|
||||||
|
kind: Certificate
|
||||||
|
metadata:
|
||||||
|
name: {{ $fullname }}
|
||||||
|
labels:
|
||||||
|
{{- include "go-tell.labels" . | nindent 4 }}
|
||||||
|
app.kubernetes.io/component: certificate
|
||||||
|
spec:
|
||||||
|
secretName: {{ $tlsSecret }}
|
||||||
|
duration: {{ .Values.certManager.duration }}
|
||||||
|
renewBefore: {{ .Values.certManager.renewBefore }}
|
||||||
|
privateKey:
|
||||||
|
algorithm: ECDSA
|
||||||
|
size: 256
|
||||||
|
usages:
|
||||||
|
- server auth
|
||||||
|
dnsNames:
|
||||||
|
- {{ .Values.host | quote }}
|
||||||
|
issuerRef:
|
||||||
|
{{- if .Values.certManager.issuerRef }}
|
||||||
|
{{- include "go-tell.certManagerIssuerRef" . | nindent 4 }}
|
||||||
|
{{- else }}
|
||||||
|
name: {{ $fullname }}-ca
|
||||||
|
kind: Issuer
|
||||||
|
group: cert-manager.io
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
33
deploy/helm/go-tell/templates/ingress.yaml
Normal file
33
deploy/helm/go-tell/templates/ingress.yaml
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
{{- if .Values.ingress.enabled }}
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: {{ include "go-tell.fullname" . }}
|
||||||
|
labels:
|
||||||
|
{{- include "go-tell.labels" . | nindent 4 }}
|
||||||
|
{{- with .Values.ingress.annotations }}
|
||||||
|
annotations:
|
||||||
|
{{- toYaml . | nindent 4 }}
|
||||||
|
{{- end }}
|
||||||
|
spec:
|
||||||
|
{{- with .Values.ingress.className }}
|
||||||
|
ingressClassName: {{ . }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.ingress.tls.enabled }}
|
||||||
|
tls:
|
||||||
|
- hosts:
|
||||||
|
- {{ .Values.host | quote }}
|
||||||
|
secretName: {{ .Values.ingress.tls.existingSecret | default (include "go-tell.tlsSecretName" .) }}
|
||||||
|
{{- end }}
|
||||||
|
rules:
|
||||||
|
- host: {{ .Values.host | quote }}
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- path: {{ .Values.ingress.path }}
|
||||||
|
pathType: {{ .Values.ingress.pathType }}
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: {{ include "go-tell.fullname" . }}
|
||||||
|
port:
|
||||||
|
number: {{ .Values.service.port }}
|
||||||
|
{{- end }}
|
||||||
|
|
@ -2,9 +2,9 @@
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: PersistentVolumeClaim
|
kind: PersistentVolumeClaim
|
||||||
metadata:
|
metadata:
|
||||||
name: {{ include "cms.fullname" . }}
|
name: {{ include "go-tell.fullname" . }}
|
||||||
labels:
|
labels:
|
||||||
{{- include "cms.labels" . | nindent 4 }}
|
{{- include "go-tell.labels" . | nindent 4 }}
|
||||||
spec:
|
spec:
|
||||||
accessModes:
|
accessModes:
|
||||||
{{- toYaml .Values.persistence.accessModes | nindent 4 }}
|
{{- toYaml .Values.persistence.accessModes | nindent 4 }}
|
||||||
20
deploy/helm/go-tell/templates/secret.yaml
Normal file
20
deploy/helm/go-tell/templates/secret.yaml
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
{{- $name := include "go-tell.secretName" . -}}
|
||||||
|
{{- if and .Values.goLoose.enabled (not .Values.goLoose.clientID) }}
|
||||||
|
{{- fail "goLoose.clientID is required when goLoose.enabled is true" }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if and .Values.goLoose.enabled (not .Values.goLoose.clientSecret) }}
|
||||||
|
{{- fail "goLoose.clientSecret is required when goLoose.enabled is true" }}
|
||||||
|
{{- end }}
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: {{ $name }}
|
||||||
|
labels:
|
||||||
|
{{- include "go-tell.labels" . | nindent 4 }}
|
||||||
|
type: Opaque
|
||||||
|
data:
|
||||||
|
sso-token: {{ .Values.secrets.ssoToken | b64enc | quote }}
|
||||||
|
{{- if .Values.goLoose.enabled }}
|
||||||
|
go-loose-client-id: {{ .Values.goLoose.clientID | b64enc | quote }}
|
||||||
|
go-loose-client-secret: {{ .Values.goLoose.clientSecret | b64enc | quote }}
|
||||||
|
{{- end }}
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: Pod
|
kind: Pod
|
||||||
metadata:
|
metadata:
|
||||||
name: "{{ include "cms.fullname" . }}-test-connection"
|
name: "{{ include "go-tell.fullname" . }}-test-connection"
|
||||||
labels:
|
labels:
|
||||||
{{- include "cms.labels" . | nindent 4 }}
|
{{- include "go-tell.labels" . | nindent 4 }}
|
||||||
annotations:
|
annotations:
|
||||||
helm.sh/hook: test
|
helm.sh/hook: test
|
||||||
helm.sh/hook-delete-policy: before-hook-creation
|
helm.sh/hook-delete-policy: before-hook-creation
|
||||||
|
|
@ -17,4 +17,4 @@ spec:
|
||||||
- wget
|
- wget
|
||||||
args:
|
args:
|
||||||
- -qO-
|
- -qO-
|
||||||
- "http://{{ include "cms.fullname" . }}:{{ .Values.service.port }}/health"
|
- "http://{{ include "go-tell.fullname" . }}:{{ .Values.service.port }}/health"
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
image:
|
image:
|
||||||
tag: latest
|
|
||||||
pullPolicy: Always
|
pullPolicy: Always
|
||||||
|
|
||||||
app:
|
app:
|
||||||
environment: demo
|
profile: demo
|
||||||
allowedOrigins: http://localhost:30010
|
allowedOrigins: http://localhost:30010
|
||||||
contentFile: ""
|
contentFile: ""
|
||||||
|
|
||||||
|
goLoose:
|
||||||
|
enabled: false
|
||||||
|
|
||||||
service:
|
service:
|
||||||
type: NodePort
|
type: NodePort
|
||||||
nodePort: 30010
|
nodePort: 30010
|
||||||
|
|
@ -23,4 +25,4 @@ resources:
|
||||||
memory: 384Mi
|
memory: 384Mi
|
||||||
|
|
||||||
persistence:
|
persistence:
|
||||||
enabled: false
|
enabled: false
|
||||||
|
|
@ -1,18 +1,13 @@
|
||||||
image:
|
image:
|
||||||
tag: latest
|
|
||||||
pullPolicy: IfNotPresent
|
pullPolicy: IfNotPresent
|
||||||
|
|
||||||
app:
|
app:
|
||||||
environment: demo
|
profile: production
|
||||||
allowedOrigins: https://cms.urpi.be
|
allowedOrigins: https://tell.dev
|
||||||
contentFile: /data/content.json
|
contentFile: /data/content.json
|
||||||
|
|
||||||
service:
|
service:
|
||||||
type: NodePort
|
type: ClusterIP
|
||||||
nodePort: 31373
|
|
||||||
|
|
||||||
ingress:
|
|
||||||
enabled: false
|
|
||||||
|
|
||||||
resources:
|
resources:
|
||||||
requests:
|
requests:
|
||||||
|
|
@ -24,4 +19,4 @@ resources:
|
||||||
|
|
||||||
persistence:
|
persistence:
|
||||||
enabled: true
|
enabled: true
|
||||||
size: 5Gi
|
size: 5Gi
|
||||||
116
deploy/helm/go-tell/values.yaml
Normal file
116
deploy/helm/go-tell/values.yaml
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
nameOverride: ""
|
||||||
|
fullnameOverride: ""
|
||||||
|
|
||||||
|
# Public host of the CMS. There is one release, one host, and no tenant
|
||||||
|
# routing: every visitor reaches the same content and the same editor.
|
||||||
|
host: tell.dev
|
||||||
|
|
||||||
|
replicaCount: 1
|
||||||
|
|
||||||
|
image:
|
||||||
|
repository: batty1039.startdedicated.net:5000/go-tell
|
||||||
|
tag: latest
|
||||||
|
pullPolicy: IfNotPresent
|
||||||
|
|
||||||
|
imagePullSecrets: []
|
||||||
|
|
||||||
|
app:
|
||||||
|
# Demo keeps content in memory and offers the sudo terminal. Production serves
|
||||||
|
# the persisted file and requires an authenticated editor.
|
||||||
|
profile: production
|
||||||
|
allowedOrigins: https://tell.dev
|
||||||
|
contentFile: /data/content.json
|
||||||
|
postgresURL: ""
|
||||||
|
# Single-sign-on proxy identity, kept as a fallback when Go Loose is not
|
||||||
|
# configured. An empty header disables it.
|
||||||
|
ssoHeader: X-SSO-Token
|
||||||
|
ssoCookie: ""
|
||||||
|
|
||||||
|
goLoose:
|
||||||
|
enabled: true
|
||||||
|
# Go Loose tenant that owns the login, the shared identity domain, and the
|
||||||
|
# public host of the CMS. Together they produce the base URL
|
||||||
|
# https://<tenant>.<authDomain> and the redirect URI
|
||||||
|
# https://<appDomain>/api/auth/callback that has to be registered in Go Loose.
|
||||||
|
tenant: tell
|
||||||
|
authDomain: auth.dev
|
||||||
|
appDomain: tell.dev
|
||||||
|
ca:
|
||||||
|
# Mount an internal CA bundle and point GO_LOOSE_CA_FILE at it when the
|
||||||
|
# identity domain is signed by a private authority.
|
||||||
|
existingSecret: ""
|
||||||
|
key: ca.crt
|
||||||
|
mountPath: /certs/local-ca.crt
|
||||||
|
clientID: ""
|
||||||
|
clientSecret: ""
|
||||||
|
|
||||||
|
secrets:
|
||||||
|
# Shared secret the identity-aware proxy must present in the SSO header. Empty
|
||||||
|
# accepts any non-empty forwarded value.
|
||||||
|
ssoToken: ""
|
||||||
|
|
||||||
|
service:
|
||||||
|
type: ClusterIP
|
||||||
|
port: 80
|
||||||
|
nodePort: null
|
||||||
|
|
||||||
|
ingress:
|
||||||
|
enabled: true
|
||||||
|
className: nginx
|
||||||
|
annotations:
|
||||||
|
nginx.ingress.kubernetes.io/ssl-redirect: "true"
|
||||||
|
path: /
|
||||||
|
pathType: Prefix
|
||||||
|
tls:
|
||||||
|
enabled: true
|
||||||
|
# Use a certificate you already manage instead of the chart-issued one.
|
||||||
|
existingSecret: ""
|
||||||
|
# Defaults to <fullname>-tls when empty and certManager.enabled is true.
|
||||||
|
secretName: ""
|
||||||
|
|
||||||
|
certManager:
|
||||||
|
# Requires cert-manager in the cluster: make cert-manager-install.
|
||||||
|
enabled: true
|
||||||
|
# Reuse an existing cert-manager issuer instead of creating a self-signed one.
|
||||||
|
issuerRef: {}
|
||||||
|
duration: 8760h
|
||||||
|
renewBefore: 720h
|
||||||
|
caSecretName: ""
|
||||||
|
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 100m
|
||||||
|
memory: 128Mi
|
||||||
|
limits:
|
||||||
|
cpu: 500m
|
||||||
|
memory: 512Mi
|
||||||
|
|
||||||
|
podAnnotations: {}
|
||||||
|
podLabels: {}
|
||||||
|
|
||||||
|
podSecurityContext:
|
||||||
|
fsGroup: 10001
|
||||||
|
fsGroupChangePolicy: OnRootMismatch
|
||||||
|
|
||||||
|
securityContext:
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
capabilities:
|
||||||
|
drop:
|
||||||
|
- ALL
|
||||||
|
readOnlyRootFilesystem: true
|
||||||
|
runAsNonRoot: true
|
||||||
|
runAsUser: 10001
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
type: Recreate
|
||||||
|
|
||||||
|
persistence:
|
||||||
|
enabled: true
|
||||||
|
size: 2Gi
|
||||||
|
accessModes:
|
||||||
|
- ReadWriteOnce
|
||||||
|
storageClass: ""
|
||||||
|
|
||||||
|
nodeSelector: {}
|
||||||
|
tolerations: []
|
||||||
|
affinity: {}
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
# Dedicated-server reverse proxy
|
|
||||||
|
|
||||||
Production exposes the CMS application through Kubernetes NodePort `31373`.
|
|
||||||
|
|
||||||
## DNS
|
|
||||||
|
|
||||||
In one.com, create an `A` record for `cms.urpi.be` pointing to the dedicated
|
|
||||||
server's public IPv4 address. Add an `AAAA` record only when the server and its
|
|
||||||
firewall are configured for public IPv6. Wait for:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
dig +short cms.urpi.be
|
|
||||||
```
|
|
||||||
|
|
||||||
to return the dedicated server address before requesting a certificate.
|
|
||||||
|
|
||||||
## Nginx and Certbot
|
|
||||||
|
|
||||||
The included virtual host assumes Nginx runs on the Kubernetes node and can
|
|
||||||
reach the NodePort at `127.0.0.1:31373`.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo install -m 0644 deploy/nginx/cms.urpi.be.conf \
|
|
||||||
/etc/nginx/sites-available/cms.urpi.be.conf
|
|
||||||
sudo ln -s /etc/nginx/sites-available/cms.urpi.be.conf \
|
|
||||||
/etc/nginx/sites-enabled/cms.urpi.be.conf
|
|
||||||
sudo nginx -t
|
|
||||||
sudo systemctl reload nginx
|
|
||||||
sudo certbot --nginx -d cms.urpi.be
|
|
||||||
sudo certbot renew --dry-run
|
|
||||||
```
|
|
||||||
|
|
||||||
Certbot adds the HTTPS listener, certificate paths, and HTTP-to-HTTPS redirect
|
|
||||||
to this virtual host. Allow inbound TCP ports `80` and `443`. Do not expose
|
|
||||||
NodePort `31373` to the public internet. If Nginx is not on a Kubernetes node, replace
|
|
||||||
`127.0.0.1` in `proxy_pass` with a private cluster-node address and allow
|
|
||||||
`31373` only from the Nginx server.
|
|
||||||
|
|
||||||
## Deploy and verify
|
|
||||||
|
|
||||||
Deploy the production profile:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make helm-deploy-production
|
|
||||||
kubectl -n cms-production get pods
|
|
||||||
kubectl -n cms-production get service cms
|
|
||||||
curl --fail http://127.0.0.1:31373/health
|
|
||||||
curl --fail https://cms.urpi.be/health
|
|
||||||
```
|
|
||||||
|
|
||||||
The `cms` service should show `80:31373/TCP`.
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
server {
|
|
||||||
listen 80;
|
|
||||||
listen [::]:80;
|
|
||||||
server_name cms.urpi.be;
|
|
||||||
|
|
||||||
client_max_body_size 10m;
|
|
||||||
|
|
||||||
location / {
|
|
||||||
proxy_pass http://127.0.0.1:31373;
|
|
||||||
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;
|
|
||||||
proxy_set_header Connection "";
|
|
||||||
proxy_buffering off;
|
|
||||||
proxy_request_buffering off;
|
|
||||||
proxy_read_timeout 3600s;
|
|
||||||
proxy_send_timeout 3600s;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,188 +0,0 @@
|
||||||
apiVersion: v1
|
|
||||||
kind: Namespace
|
|
||||||
metadata:
|
|
||||||
name: local-path-storage
|
|
||||||
|
|
||||||
---
|
|
||||||
apiVersion: v1
|
|
||||||
kind: ServiceAccount
|
|
||||||
metadata:
|
|
||||||
name: local-path-provisioner-service-account
|
|
||||||
namespace: local-path-storage
|
|
||||||
|
|
||||||
---
|
|
||||||
apiVersion: rbac.authorization.k8s.io/v1
|
|
||||||
kind: Role
|
|
||||||
metadata:
|
|
||||||
name: local-path-provisioner-role
|
|
||||||
namespace: local-path-storage
|
|
||||||
rules:
|
|
||||||
- apiGroups: [""]
|
|
||||||
resources: ["pods"]
|
|
||||||
verbs: ["get", "list", "watch", "create", "patch", "update", "delete"]
|
|
||||||
|
|
||||||
---
|
|
||||||
apiVersion: rbac.authorization.k8s.io/v1
|
|
||||||
kind: ClusterRole
|
|
||||||
metadata:
|
|
||||||
name: local-path-provisioner-role
|
|
||||||
rules:
|
|
||||||
- apiGroups: [""]
|
|
||||||
resources: ["nodes", "persistentvolumeclaims", "configmaps", "pods", "pods/log"]
|
|
||||||
verbs: ["get", "list", "watch"]
|
|
||||||
- apiGroups: [""]
|
|
||||||
resources: ["persistentvolumes"]
|
|
||||||
verbs: ["get", "list", "watch", "create", "patch", "update", "delete"]
|
|
||||||
- apiGroups: [""]
|
|
||||||
resources: ["events"]
|
|
||||||
verbs: ["create", "patch"]
|
|
||||||
- apiGroups: ["storage.k8s.io"]
|
|
||||||
resources: ["storageclasses"]
|
|
||||||
verbs: ["get", "list", "watch"]
|
|
||||||
|
|
||||||
---
|
|
||||||
apiVersion: rbac.authorization.k8s.io/v1
|
|
||||||
kind: RoleBinding
|
|
||||||
metadata:
|
|
||||||
name: local-path-provisioner-bind
|
|
||||||
namespace: local-path-storage
|
|
||||||
roleRef:
|
|
||||||
apiGroup: rbac.authorization.k8s.io
|
|
||||||
kind: Role
|
|
||||||
name: local-path-provisioner-role
|
|
||||||
subjects:
|
|
||||||
- kind: ServiceAccount
|
|
||||||
name: local-path-provisioner-service-account
|
|
||||||
namespace: local-path-storage
|
|
||||||
|
|
||||||
---
|
|
||||||
apiVersion: rbac.authorization.k8s.io/v1
|
|
||||||
kind: ClusterRoleBinding
|
|
||||||
metadata:
|
|
||||||
name: local-path-provisioner-bind
|
|
||||||
roleRef:
|
|
||||||
apiGroup: rbac.authorization.k8s.io
|
|
||||||
kind: ClusterRole
|
|
||||||
name: local-path-provisioner-role
|
|
||||||
subjects:
|
|
||||||
- kind: ServiceAccount
|
|
||||||
name: local-path-provisioner-service-account
|
|
||||||
namespace: local-path-storage
|
|
||||||
|
|
||||||
---
|
|
||||||
apiVersion: apps/v1
|
|
||||||
kind: Deployment
|
|
||||||
metadata:
|
|
||||||
name: local-path-provisioner
|
|
||||||
namespace: local-path-storage
|
|
||||||
spec:
|
|
||||||
replicas: 1
|
|
||||||
selector:
|
|
||||||
matchLabels:
|
|
||||||
app: local-path-provisioner
|
|
||||||
template:
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
app: local-path-provisioner
|
|
||||||
spec:
|
|
||||||
serviceAccountName: local-path-provisioner-service-account
|
|
||||||
containers:
|
|
||||||
- name: local-path-provisioner
|
|
||||||
image: docker.io/rancher/local-path-provisioner:v0.0.37
|
|
||||||
imagePullPolicy: IfNotPresent
|
|
||||||
command:
|
|
||||||
- local-path-provisioner
|
|
||||||
- --debug
|
|
||||||
- start
|
|
||||||
- --config
|
|
||||||
- /etc/config/config.json
|
|
||||||
ports:
|
|
||||||
- name: health
|
|
||||||
containerPort: 8080
|
|
||||||
protocol: TCP
|
|
||||||
startupProbe:
|
|
||||||
httpGet:
|
|
||||||
path: /health
|
|
||||||
port: health
|
|
||||||
initialDelaySeconds: 5
|
|
||||||
periodSeconds: 5
|
|
||||||
failureThreshold: 12
|
|
||||||
livenessProbe:
|
|
||||||
httpGet:
|
|
||||||
path: /health
|
|
||||||
port: health
|
|
||||||
initialDelaySeconds: 10
|
|
||||||
periodSeconds: 10
|
|
||||||
readinessProbe:
|
|
||||||
httpGet:
|
|
||||||
path: /ready
|
|
||||||
port: health
|
|
||||||
initialDelaySeconds: 5
|
|
||||||
periodSeconds: 5
|
|
||||||
volumeMounts:
|
|
||||||
- name: config-volume
|
|
||||||
mountPath: /etc/config/
|
|
||||||
env:
|
|
||||||
- name: POD_NAMESPACE
|
|
||||||
valueFrom:
|
|
||||||
fieldRef:
|
|
||||||
fieldPath: metadata.namespace
|
|
||||||
- name: CONFIG_MOUNT_PATH
|
|
||||||
value: /etc/config/
|
|
||||||
- name: HEALTH_PORT
|
|
||||||
value: "8080"
|
|
||||||
volumes:
|
|
||||||
- name: config-volume
|
|
||||||
configMap:
|
|
||||||
name: local-path-config
|
|
||||||
|
|
||||||
---
|
|
||||||
apiVersion: storage.k8s.io/v1
|
|
||||||
kind: StorageClass
|
|
||||||
metadata:
|
|
||||||
name: local-path
|
|
||||||
annotations:
|
|
||||||
storageclass.kubernetes.io/is-default-class: "true"
|
|
||||||
provisioner: rancher.io/local-path
|
|
||||||
volumeBindingMode: WaitForFirstConsumer
|
|
||||||
reclaimPolicy: Retain
|
|
||||||
|
|
||||||
---
|
|
||||||
kind: ConfigMap
|
|
||||||
apiVersion: v1
|
|
||||||
metadata:
|
|
||||||
name: local-path-config
|
|
||||||
namespace: local-path-storage
|
|
||||||
data:
|
|
||||||
config.json: |-
|
|
||||||
{
|
|
||||||
"nodePathMap":[
|
|
||||||
{
|
|
||||||
"node":"DEFAULT_PATH_FOR_NON_LISTED_NODES",
|
|
||||||
"paths":["/var/lib/rancher/k3s/storage"]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
setup: |-
|
|
||||||
#!/bin/sh
|
|
||||||
set -eu
|
|
||||||
mkdir -m 0777 -p "$VOL_DIR"
|
|
||||||
teardown: |-
|
|
||||||
#!/bin/sh
|
|
||||||
set -eu
|
|
||||||
rm -rf "$VOL_DIR"
|
|
||||||
helperPod.yaml: |-
|
|
||||||
apiVersion: v1
|
|
||||||
kind: Pod
|
|
||||||
metadata:
|
|
||||||
name: helper-pod
|
|
||||||
spec:
|
|
||||||
priorityClassName: system-node-critical
|
|
||||||
tolerations:
|
|
||||||
- key: node.kubernetes.io/disk-pressure
|
|
||||||
operator: Exists
|
|
||||||
effect: NoSchedule
|
|
||||||
containers:
|
|
||||||
- name: helper-pod
|
|
||||||
image: docker.io/library/busybox
|
|
||||||
imagePullPolicy: IfNotPresent
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
mirrors:
|
|
||||||
"batty1039.startdedicated.net:5000":
|
|
||||||
endpoint:
|
|
||||||
- "http://batty1039.startdedicated.net:5000"
|
|
||||||
|
|
||||||
configs:
|
|
||||||
"batty1039.startdedicated.net:5000":
|
|
||||||
tls:
|
|
||||||
insecure_skip_verify: true
|
|
||||||
9
go.mod
9
go.mod
|
|
@ -1,12 +1,13 @@
|
||||||
module github.com/danyel/go-cms
|
module github.com/danyel/go-tell
|
||||||
|
|
||||||
go 1.27.1
|
go 1.27.1
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/danyel/go-loose v0.0.0-20261002224534-b011701522b1
|
||||||
github.com/go-chi/chi/v5 v5.2.3
|
github.com/go-chi/chi/v5 v5.2.3
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/jackc/pgx/v5 v5.9.2
|
github.com/jackc/pgx/v5 v5.11.0
|
||||||
github.com/ledongthuc/pdf v0.0.0-20260907135840-6c8c28e0e8a0
|
github.com/ledongthuc/pdf v0.0.0-20260907135840-6c8c28e0e8a0
|
||||||
github.com/pressly/goose/v3 v3.26.0
|
github.com/pressly/goose/v3 v3.26.0
|
||||||
github.com/rabbitmq/amqp091-go v1.15.0
|
github.com/rabbitmq/amqp091-go v1.15.0
|
||||||
|
|
@ -72,8 +73,8 @@ require (
|
||||||
go.opentelemetry.io/otel/metric v1.44.0 // indirect
|
go.opentelemetry.io/otel/metric v1.44.0 // indirect
|
||||||
go.opentelemetry.io/otel/trace v1.44.0 // indirect
|
go.opentelemetry.io/otel/trace v1.44.0 // indirect
|
||||||
go.uber.org/multierr v1.11.0 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
golang.org/x/sync v0.22.0 // indirect
|
golang.org/x/sync v0.23.0 // indirect
|
||||||
golang.org/x/sys v0.47.0 // indirect
|
golang.org/x/sys v0.47.0 // indirect
|
||||||
golang.org/x/text v0.40.0 // indirect
|
golang.org/x/text v0.42.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|
|
||||||
5
go.sum
5
go.sum
|
|
@ -8,6 +8,8 @@ github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmC
|
||||||
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
|
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
|
||||||
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
|
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
|
||||||
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
|
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
|
||||||
|
github.com/danyel/go-loose v0.0.0-20261002224534-b011701522b1 h1:hGywTGxVZoxwpSovWdzPHU6oRcQvbCwOJXaBpnynXeM=
|
||||||
|
github.com/danyel/go-loose v0.0.0-20261002224534-b011701522b1/go.mod h1:qOO4ay0c2MWiiLwz7WHCXCqDwxxA3sKTKKUnqbFgsOk=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||||
|
|
@ -26,6 +28,7 @@ github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||||
|
github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||||
|
|
@ -69,12 +72,14 @@ go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUS
|
||||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
|
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||||
|
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
// Package config carries the runtime settings for the demo CMS. There is no
|
// Package config carries the runtime settings for the CMS. There is no
|
||||||
// database to configure: content is seeded in memory and optionally mirrored to
|
// database to configure: content is seeded in memory and optionally mirrored to
|
||||||
// a single JSON file.
|
// a single JSON file. Editing rights come from Go Loose, from the single-sign-on
|
||||||
|
// proxy, or from the demo profile, depending on what is configured.
|
||||||
package config
|
package config
|
||||||
|
|
||||||
import "os"
|
import "os"
|
||||||
|
|
@ -8,8 +9,26 @@ import "os"
|
||||||
type Config struct {
|
type Config struct {
|
||||||
HTTPAddr, Profile, ContentFile, PostgresURL string
|
HTTPAddr, Profile, ContentFile, PostgresURL string
|
||||||
AllowedOrigins, SSOHeader, SSOToken, SSOCookie string
|
AllowedOrigins, SSOHeader, SSOToken, SSOCookie string
|
||||||
|
GoLoose GoLooseConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GoLooseConfig describes the Go Loose hosted-login integration. Go Tell is
|
||||||
|
// served from a single host, so one client application covers every request:
|
||||||
|
// Tenant picks the Go Loose tenant that owns the login, AuthDomain is the
|
||||||
|
// identity domain shared by all tenants, and AppDomain is the public host of
|
||||||
|
// the CMS, which also determines the registered redirect URI.
|
||||||
|
type GoLooseConfig struct {
|
||||||
|
Tenant string
|
||||||
|
AuthDomain string
|
||||||
|
AppDomain string
|
||||||
|
CAFile string
|
||||||
|
ClientID string
|
||||||
|
ClientSecret string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enabled reports whether Go Loose client credentials are configured.
|
||||||
|
func (g GoLooseConfig) Enabled() bool { return g.ClientID != "" && g.ClientSecret != "" }
|
||||||
|
|
||||||
func Load() Config {
|
func Load() Config {
|
||||||
return Config{
|
return Config{
|
||||||
HTTPAddr: get("CMS_HTTP_ADDR", ":8080"),
|
HTTPAddr: get("CMS_HTTP_ADDR", ":8080"),
|
||||||
|
|
@ -20,6 +39,14 @@ func Load() Config {
|
||||||
SSOHeader: get("CMS_SSO_HEADER", "X-SSO-Token"),
|
SSOHeader: get("CMS_SSO_HEADER", "X-SSO-Token"),
|
||||||
SSOToken: get("CMS_SSO_TOKEN", ""),
|
SSOToken: get("CMS_SSO_TOKEN", ""),
|
||||||
SSOCookie: get("CMS_SSO_COOKIE", ""),
|
SSOCookie: get("CMS_SSO_COOKIE", ""),
|
||||||
|
GoLoose: GoLooseConfig{
|
||||||
|
Tenant: get("GO_LOOSE_TENANT", "tell"),
|
||||||
|
AuthDomain: get("GO_LOOSE_AUTH_DOMAIN", "auth.dev"),
|
||||||
|
AppDomain: get("GO_LOOSE_APP_DOMAIN", "tell.dev"),
|
||||||
|
CAFile: os.Getenv("GO_LOOSE_CA_FILE"),
|
||||||
|
ClientID: os.Getenv("GO_LOOSE_CLIENT_ID"),
|
||||||
|
ClientSecret: os.Getenv("GO_LOOSE_CLIENT_SECRET"),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
142
internal/golooseauth/browser.go
Normal file
142
internal/golooseauth/browser.go
Normal file
|
|
@ -0,0 +1,142 @@
|
||||||
|
// Package golooseauth builds the Go Loose hosted-login client used by the CMS.
|
||||||
|
package golooseauth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
goloose "github.com/danyel/go-loose/client"
|
||||||
|
|
||||||
|
"github.com/danyel/go-tell/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// SessionCookie holds the Go Loose session token after a successful login.
|
||||||
|
SessionCookie = "go_loose_user_session"
|
||||||
|
// LoginPath starts the browser login flow.
|
||||||
|
LoginPath = "/api/auth/login"
|
||||||
|
// CallbackPath is the redirect URI registered with Go Loose.
|
||||||
|
CallbackPath = "/api/auth/callback"
|
||||||
|
// LogoutPath ends the browser session, locally and at the identity provider.
|
||||||
|
LogoutPath = "/api/auth/logout"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Auth is the Go Loose browser client plus the session lookup the API needs.
|
||||||
|
// The login, callback, and logout handlers come from the Go Loose client; this
|
||||||
|
// type only adds a non-redirecting way to turn a request into a user, because
|
||||||
|
// the client middleware answers anonymous requests with a redirect and the CMS
|
||||||
|
// has to answer its own API with a status code.
|
||||||
|
type Auth struct {
|
||||||
|
*goloose.BrowserAuth
|
||||||
|
baseURL string
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// New returns the browser client for the configured Go Loose application, or a
|
||||||
|
// nil client when no credentials are set, which leaves the CMS with its other
|
||||||
|
// identity sources. ClientID and ClientSecret are the values Go Loose shows once
|
||||||
|
// when a client application is configured.
|
||||||
|
func New(cfg config.GoLooseConfig) (*Auth, error) {
|
||||||
|
if !cfg.Enabled() {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
tenant := domain(cfg.Tenant)
|
||||||
|
if tenant == "" || strings.Contains(tenant, ".") {
|
||||||
|
return nil, fmt.Errorf("GO_LOOSE_TENANT must be a single lowercase tenant slug")
|
||||||
|
}
|
||||||
|
authDomain := domain(cfg.AuthDomain)
|
||||||
|
if authDomain == "" {
|
||||||
|
return nil, fmt.Errorf("GO_LOOSE_AUTH_DOMAIN is required when client credentials are configured")
|
||||||
|
}
|
||||||
|
appDomain := domain(cfg.AppDomain)
|
||||||
|
if appDomain == "" {
|
||||||
|
return nil, fmt.Errorf("GO_LOOSE_APP_DOMAIN is required when client credentials are configured")
|
||||||
|
}
|
||||||
|
httpClient, err := client(cfg.CAFile)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
baseURL := "https://" + tenant + "." + authDomain
|
||||||
|
browser, err := goloose.NewBrowserAuth(goloose.BrowserAuthConfig{
|
||||||
|
BaseURL: baseURL,
|
||||||
|
ClientID: cfg.ClientID,
|
||||||
|
ClientSecret: cfg.ClientSecret,
|
||||||
|
RedirectURL: "https://" + appDomain + CallbackPath,
|
||||||
|
AfterLoginURL: "/",
|
||||||
|
AfterLogoutURL: "/",
|
||||||
|
LoginPath: LoginPath,
|
||||||
|
CookieName: SessionCookie,
|
||||||
|
CookieSecure: true,
|
||||||
|
HTTPClient: httpClient,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &Auth{BrowserAuth: browser, baseURL: baseURL, httpClient: httpClient}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tenant reports the Go Loose tenant that owns the login.
|
||||||
|
func (a *Auth) Tenant() string { return a.baseURL }
|
||||||
|
|
||||||
|
// SessionUser resolves the Go Loose session cookie of a request into a user.
|
||||||
|
// Requests without a cookie, with a rejected session, or without a reachable
|
||||||
|
// identity provider are simply not signed in.
|
||||||
|
func (a *Auth) SessionUser(r *http.Request) (goloose.User, bool) {
|
||||||
|
if a == nil {
|
||||||
|
return goloose.User{}, false
|
||||||
|
}
|
||||||
|
cookie, err := r.Cookie(SessionCookie)
|
||||||
|
if err != nil || cookie.Value == "" {
|
||||||
|
return goloose.User{}, false
|
||||||
|
}
|
||||||
|
request, err := http.NewRequestWithContext(r.Context(), http.MethodGet, a.baseURL+"/connect/userinfo", nil)
|
||||||
|
if err != nil {
|
||||||
|
return goloose.User{}, false
|
||||||
|
}
|
||||||
|
request.Header.Set("Authorization", "Bearer "+cookie.Value)
|
||||||
|
response, err := a.httpClient.Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return goloose.User{}, false
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
var user goloose.User
|
||||||
|
if response.StatusCode != http.StatusOK ||
|
||||||
|
json.NewDecoder(io.LimitReader(response.Body, 1<<20)).Decode(&user) != nil {
|
||||||
|
return goloose.User{}, false
|
||||||
|
}
|
||||||
|
return user, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// domain normalizes a configured host or tenant slug.
|
||||||
|
func domain(value string) string {
|
||||||
|
return strings.Trim(strings.ToLower(strings.TrimSpace(value)), ".")
|
||||||
|
}
|
||||||
|
|
||||||
|
// client returns the HTTP client used to talk to Go Loose, trusting the extra
|
||||||
|
// certificate authorities in caFile when one is configured. It is needed while
|
||||||
|
// the identity domain is served by an internal certificate authority.
|
||||||
|
func client(caFile string) (*http.Client, error) {
|
||||||
|
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||||
|
if caFile != "" {
|
||||||
|
pem, err := os.ReadFile(caFile)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read Go Loose CA: %w", err)
|
||||||
|
}
|
||||||
|
pool, err := x509.SystemCertPool()
|
||||||
|
if err != nil || pool == nil {
|
||||||
|
pool = x509.NewCertPool()
|
||||||
|
}
|
||||||
|
if !pool.AppendCertsFromPEM(pem) {
|
||||||
|
return nil, fmt.Errorf("parse Go Loose CA %s", caFile)
|
||||||
|
}
|
||||||
|
transport.TLSClientConfig = &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12}
|
||||||
|
}
|
||||||
|
return &http.Client{Timeout: 5 * time.Second, Transport: transport}, nil
|
||||||
|
}
|
||||||
150
internal/golooseauth/browser_test.go
Normal file
150
internal/golooseauth/browser_test.go
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
package golooseauth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
goloose "github.com/danyel/go-loose/client"
|
||||||
|
|
||||||
|
"github.com/danyel/go-tell/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewWithoutCredentialsIsDisabled(t *testing.T) {
|
||||||
|
auth, err := New(config.GoLooseConfig{Tenant: "tell", AuthDomain: "auth.dev", AppDomain: "tell.dev"})
|
||||||
|
if err != nil || auth != nil {
|
||||||
|
t.Fatalf("New() = %#v, %v; want a disabled client", auth, err)
|
||||||
|
}
|
||||||
|
auth, err = New(config.GoLooseConfig{ClientID: "client"})
|
||||||
|
if err != nil || auth != nil {
|
||||||
|
t.Fatalf("New() with a half-configured client = %#v, %v; want a disabled client", auth, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewRejectsInvalidDomains(t *testing.T) {
|
||||||
|
cases := map[string]config.GoLooseConfig{
|
||||||
|
"tenant with a dot": {Tenant: "tell.dev", AuthDomain: "auth.dev", AppDomain: "tell.dev", ClientID: "c", ClientSecret: "s"},
|
||||||
|
"empty tenant": {Tenant: " ", AuthDomain: "auth.dev", AppDomain: "tell.dev", ClientID: "c", ClientSecret: "s"},
|
||||||
|
"empty auth domain": {Tenant: "tell", ClientID: "c", ClientSecret: "s"},
|
||||||
|
"empty app domain": {Tenant: "tell", AuthDomain: "auth.dev", ClientID: "c", ClientSecret: "s"},
|
||||||
|
}
|
||||||
|
for name, cfg := range cases {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
if _, err := New(cfg); err == nil {
|
||||||
|
t.Fatal("New() error = nil, want a configuration error")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewBuildsSingleHostBrowserClient(t *testing.T) {
|
||||||
|
auth, err := New(config.GoLooseConfig{
|
||||||
|
Tenant: " Tell ", AuthDomain: ".auth.dev.", AppDomain: "Tell.Dev",
|
||||||
|
ClientID: "client", ClientSecret: "secret",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if auth.Tenant() != "https://tell.auth.dev" {
|
||||||
|
t.Fatalf("Tenant() = %q", auth.Tenant())
|
||||||
|
}
|
||||||
|
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
auth.LoginHandler(recorder, httptest.NewRequest(http.MethodGet, LoginPath, nil))
|
||||||
|
if recorder.Code != http.StatusFound {
|
||||||
|
t.Fatalf("login status = %d, body = %s", recorder.Code, recorder.Body.String())
|
||||||
|
}
|
||||||
|
target, err := url.Parse(recorder.Header().Get("Location"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if target.Scheme+"://"+target.Host != "https://tell.auth.dev" || target.Path != "/connect/authorize" {
|
||||||
|
t.Fatalf("unexpected login target: %s", target)
|
||||||
|
}
|
||||||
|
if target.Query().Get("client_id") != "client" ||
|
||||||
|
target.Query().Get("redirect_uri") != "https://tell.dev"+CallbackPath ||
|
||||||
|
target.Query().Get("state") == "" {
|
||||||
|
t.Fatalf("unexpected login query: %s", target.RawQuery)
|
||||||
|
}
|
||||||
|
cookies := recorder.Result().Cookies()
|
||||||
|
if len(cookies) != 1 || cookies[0].Name == SessionCookie || !cookies[0].HttpOnly {
|
||||||
|
t.Fatalf("expected an HTTP-only state cookie scoped to the callback, got %#v", cookies)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionUserResolvesCookie(t *testing.T) {
|
||||||
|
identity := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Header.Get("Authorization") != "Bearer session-token" {
|
||||||
|
http.Error(w, "missing bearer token", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write([]byte(`{"id":"user-1","email":"owner@tell.dev","display_name":"Owner","tenant_slug":"tell"}`))
|
||||||
|
}))
|
||||||
|
defer identity.Close()
|
||||||
|
|
||||||
|
auth := testAuth(t, identity)
|
||||||
|
request := httptest.NewRequest(http.MethodPut, "https://tell.dev/api/content/post", nil)
|
||||||
|
if _, ok := auth.SessionUser(request); ok {
|
||||||
|
t.Fatal("SessionUser() without a cookie = true, want false")
|
||||||
|
}
|
||||||
|
if _, ok := auth.SessionUser(httptest.NewRequest(http.MethodPut, "https://tell.dev/api/content/post", nil)); ok {
|
||||||
|
t.Fatal("SessionUser() without a cookie = true, want false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionUserRejectsRejectedSession(t *testing.T) {
|
||||||
|
identity := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
http.Error(w, "no such session", http.StatusUnauthorized)
|
||||||
|
}))
|
||||||
|
defer identity.Close()
|
||||||
|
|
||||||
|
auth := testAuth(t, identity)
|
||||||
|
request := httptest.NewRequest(http.MethodPut, "https://tell.dev/api/content/post", nil)
|
||||||
|
request.AddCookie(&http.Cookie{Name: SessionCookie, Value: "expired"})
|
||||||
|
if user, ok := auth.SessionUser(request); ok {
|
||||||
|
t.Fatalf("SessionUser() with a rejected session = %#v, want no user", user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionUserReturnsIdentity(t *testing.T) {
|
||||||
|
identity := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write([]byte(`{"id":"user-1","email":"owner@tell.dev","display_name":"Owner","tenant_slug":"tell"}`))
|
||||||
|
}))
|
||||||
|
defer identity.Close()
|
||||||
|
|
||||||
|
auth := testAuth(t, identity)
|
||||||
|
request := httptest.NewRequest(http.MethodPut, "https://tell.dev/api/content/post", nil)
|
||||||
|
request.AddCookie(&http.Cookie{Name: SessionCookie, Value: "session-token"})
|
||||||
|
user, ok := auth.SessionUser(request)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("SessionUser() = false, want the session user")
|
||||||
|
}
|
||||||
|
if user.Email != "owner@tell.dev" || user.DisplayName != "Owner" || user.TenantSlug != "tell" {
|
||||||
|
t.Fatalf("unexpected user: %#v", user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNilAuthHasNoSession(t *testing.T) {
|
||||||
|
var auth *Auth
|
||||||
|
if user, ok := auth.SessionUser(httptest.NewRequest(http.MethodGet, "/", nil)); ok {
|
||||||
|
t.Fatalf("SessionUser() on a disabled client = %#v, want no user", user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// testAuth wires an Auth to a local identity server, which is what the client
|
||||||
|
// would talk to once the base URL points somewhere reachable in a test.
|
||||||
|
func testAuth(t *testing.T, identity *httptest.Server) *Auth {
|
||||||
|
t.Helper()
|
||||||
|
browser, err := goloose.NewBrowserAuth(goloose.BrowserAuthConfig{
|
||||||
|
BaseURL: identity.URL, ClientID: "client", ClientSecret: "secret",
|
||||||
|
RedirectURL: "https://tell.dev" + CallbackPath, LoginPath: LoginPath,
|
||||||
|
CookieName: SessionCookie, HTTPClient: identity.Client(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return &Auth{BrowserAuth: browser, baseURL: identity.URL, httpClient: identity.Client()}
|
||||||
|
}
|
||||||
|
|
@ -33,9 +33,9 @@ func TestSSOTokenVerifierChecksExpectedToken(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSSOTokenVerifierCookieFallback(t *testing.T) {
|
func TestSSOTokenVerifierCookieFallback(t *testing.T) {
|
||||||
v := NewSSOTokenVerifier("X-SSO-Token", "", "cms_session")
|
v := NewSSOTokenVerifier("X-SSO-Token", "", "go_tell_session")
|
||||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
r.AddCookie(&http.Cookie{Name: "cms_session", Value: "cookie-token"})
|
r.AddCookie(&http.Cookie{Name: "go_tell_session", Value: "cookie-token"})
|
||||||
if !v.Authenticated(r) {
|
if !v.Authenticated(r) {
|
||||||
t.Fatal("cookie token must be authenticated")
|
t.Fatal("cookie token must be authenticated")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,22 +8,27 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/danyel/go-cms/internal/security"
|
goloose "github.com/danyel/go-loose/client"
|
||||||
"github.com/danyel/go-cms/internal/service"
|
|
||||||
|
"github.com/danyel/go-tell/internal/golooseauth"
|
||||||
|
"github.com/danyel/go-tell/internal/security"
|
||||||
|
"github.com/danyel/go-tell/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Handler serves the CMS API and the embedded React app from one binary.
|
// Handler serves the CMS API and the embedded React app from one binary.
|
||||||
// Reading content is anonymous; every mutation requires the identity asserted by
|
// Reading content is anonymous; every mutation requires an authenticated
|
||||||
// the upstream SSO proxy.
|
// editor: a Go Loose browser session, the identity asserted by the upstream
|
||||||
|
// SSO proxy, or a demo user.
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
content service.IContentService
|
content service.IContentService
|
||||||
assets fs.FS
|
assets fs.FS
|
||||||
sso security.ITokenVerifier
|
sso security.ITokenVerifier
|
||||||
|
browser *golooseauth.Auth
|
||||||
demo *service.DemoAuth
|
demo *service.DemoAuth
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHandler(content service.IContentService, assets fs.FS, sso security.ITokenVerifier, demo ...*service.DemoAuth) *Handler {
|
func NewHandler(content service.IContentService, assets fs.FS, sso security.ITokenVerifier, browser *golooseauth.Auth, demo ...*service.DemoAuth) *Handler {
|
||||||
h := &Handler{content: content, assets: assets, sso: sso}
|
h := &Handler{content: content, assets: assets, sso: sso, browser: browser}
|
||||||
if len(demo) > 0 {
|
if len(demo) > 0 {
|
||||||
h.demo = demo[0]
|
h.demo = demo[0]
|
||||||
}
|
}
|
||||||
|
|
@ -35,6 +40,9 @@ func (h *Handler) Routes(origins string) http.Handler {
|
||||||
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||||
jsonWrite(w, http.StatusOK, map[string]string{"status": "ok"})
|
jsonWrite(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||||
})
|
})
|
||||||
|
mux.HandleFunc(golooseauth.LoginPath, h.login)
|
||||||
|
mux.HandleFunc(golooseauth.CallbackPath, h.callback)
|
||||||
|
mux.HandleFunc(golooseauth.LogoutPath, h.logout)
|
||||||
mux.HandleFunc("/api/auth/session", h.session)
|
mux.HandleFunc("/api/auth/session", h.session)
|
||||||
mux.HandleFunc("/api/auth/sudo", h.sudo)
|
mux.HandleFunc("/api/auth/sudo", h.sudo)
|
||||||
mux.HandleFunc("/api/auth/users", h.users)
|
mux.HandleFunc("/api/auth/users", h.users)
|
||||||
|
|
@ -48,9 +56,51 @@ func (h *Handler) Routes(origins string) http.Handler {
|
||||||
return cors(mux, origins, h.sso.HeaderName())
|
return cors(mux, origins, h.sso.HeaderName())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// session reports who the caller is. When Go Loose is configured the response
|
||||||
|
// also carries the login and logout endpoints so the UI can offer the browser
|
||||||
|
// login instead of a proxy redirect.
|
||||||
func (h *Handler) session(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) session(w http.ResponseWriter, r *http.Request) {
|
||||||
username, authenticated := h.identity(r)
|
username, authenticated := h.identity(r)
|
||||||
jsonWrite(w, http.StatusOK, map[string]any{"authenticated": authenticated, "canEdit": authenticated, "username": username})
|
response := map[string]any{"authenticated": authenticated, "canEdit": authenticated, "username": username}
|
||||||
|
if user, ok := h.browserUser(r); ok {
|
||||||
|
response["email"] = user.Email
|
||||||
|
response["displayName"] = user.DisplayName
|
||||||
|
}
|
||||||
|
if h.browser != nil {
|
||||||
|
response["loginUrl"] = golooseauth.LoginPath
|
||||||
|
response["logoutUrl"] = golooseauth.LogoutPath
|
||||||
|
}
|
||||||
|
jsonWrite(w, http.StatusOK, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) login(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if h.browser == nil {
|
||||||
|
writeAuthUnavailable(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.browser.LoginHandler(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) callback(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if h.browser == nil {
|
||||||
|
writeAuthUnavailable(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.browser.CallbackHandler(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) logout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if h.browser == nil {
|
||||||
|
writeAuthUnavailable(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.browser.LogoutHandler(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeAuthUnavailable(w http.ResponseWriter) {
|
||||||
|
jsonWrite(w, http.StatusNotFound, map[string]string{
|
||||||
|
"error": "Go Loose login is not configured: set GO_LOOSE_CLIENT_ID and GO_LOOSE_CLIENT_SECRET, then restart the server",
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) protected(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) protected(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
@ -182,6 +232,9 @@ func (h *Handler) contentDetail(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// identity resolves the editor behind a request. The demo profile wins, then
|
||||||
|
// the SSO proxy, then the Go Loose browser session: the session is checked last
|
||||||
|
// because it costs a call to the identity provider.
|
||||||
func (h *Handler) identity(r *http.Request) (string, bool) {
|
func (h *Handler) identity(r *http.Request) (string, bool) {
|
||||||
if h.demo != nil {
|
if h.demo != nil {
|
||||||
token := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "))
|
token := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "))
|
||||||
|
|
@ -192,9 +245,31 @@ func (h *Handler) identity(r *http.Request) (string, bool) {
|
||||||
if h.sso.Authenticated(r) {
|
if h.sso.Authenticated(r) {
|
||||||
return "sso-owner", true
|
return "sso-owner", true
|
||||||
}
|
}
|
||||||
|
if user, ok := h.browserUser(r); ok {
|
||||||
|
return editorName(user), true
|
||||||
|
}
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// browserUser returns the Go Loose user carried by the request session, if any.
|
||||||
|
func (h *Handler) browserUser(r *http.Request) (goloose.User, bool) {
|
||||||
|
if h.browser == nil {
|
||||||
|
return goloose.User{}, false
|
||||||
|
}
|
||||||
|
user, ok := h.browser.SessionUser(r)
|
||||||
|
if !ok {
|
||||||
|
return goloose.User{}, false
|
||||||
|
}
|
||||||
|
return user, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func editorName(user goloose.User) string {
|
||||||
|
if name := strings.TrimSpace(user.DisplayName); name != "" {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
return user.Email
|
||||||
|
}
|
||||||
|
|
||||||
// singlePageApp serves the embedded build and falls back to index.html so the
|
// singlePageApp serves the embedded build and falls back to index.html so the
|
||||||
// client-side router owns every non-API path.
|
// client-side router owns every non-API path.
|
||||||
func (h *Handler) singlePageApp(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) singlePageApp(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,10 @@ import (
|
||||||
"testing"
|
"testing"
|
||||||
"testing/fstest"
|
"testing/fstest"
|
||||||
|
|
||||||
"github.com/danyel/go-cms/internal/security"
|
"github.com/danyel/go-tell/internal/config"
|
||||||
"github.com/danyel/go-cms/internal/service"
|
"github.com/danyel/go-tell/internal/golooseauth"
|
||||||
|
"github.com/danyel/go-tell/internal/security"
|
||||||
|
"github.com/danyel/go-tell/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
type fakeContent struct{ updated bool }
|
type fakeContent struct{ updated bool }
|
||||||
|
|
@ -31,12 +33,16 @@ func (f *fakeContent) Update(_ context.Context, c service.Content) (service.Cont
|
||||||
return c, nil
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func newTestRoutes(content *fakeContent) http.Handler {
|
func newTestRoutes(content *fakeContent, browser ...*golooseauth.Auth) http.Handler {
|
||||||
assets := fstest.MapFS{
|
assets := fstest.MapFS{
|
||||||
"index.html": &fstest.MapFile{Data: []byte("<!doctype html><div id=\"root\"></div>")},
|
"index.html": &fstest.MapFile{Data: []byte("<!doctype html><div id=\"root\"></div>")},
|
||||||
"assets/index.js": &fstest.MapFile{Data: []byte("console.log('cms')")},
|
"assets/index.js": &fstest.MapFile{Data: []byte("console.log('go-tell')")},
|
||||||
}
|
}
|
||||||
return NewHandler(content, assets, security.NewSSOTokenVerifier("X-SSO-Token", "", "")).Routes("http://localhost:5173")
|
var auth *golooseauth.Auth
|
||||||
|
if len(browser) > 0 {
|
||||||
|
auth = browser[0]
|
||||||
|
}
|
||||||
|
return NewHandler(content, assets, security.NewSSOTokenVerifier("X-SSO-Token", "", ""), auth).Routes("http://localhost:5173")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAnonymousCanReadButNotEdit(t *testing.T) {
|
func TestAnonymousCanReadButNotEdit(t *testing.T) {
|
||||||
|
|
@ -89,6 +95,68 @@ func TestProxyIdentityCanEdit(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGoLooseLoginRedirectsToTheIdentityProvider(t *testing.T) {
|
||||||
|
routes := newTestRoutes(&fakeContent{}, testGoLooseAuth(t))
|
||||||
|
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
routes.ServeHTTP(response, httptest.NewRequest(http.MethodGet, golooseauth.LoginPath, nil))
|
||||||
|
if response.Code != http.StatusFound {
|
||||||
|
t.Fatalf("login status = %d, body = %s", response.Code, response.Body.String())
|
||||||
|
}
|
||||||
|
location := response.Header().Get("Location")
|
||||||
|
if !strings.HasPrefix(location, "https://tell.auth.dev/connect/authorize?") ||
|
||||||
|
!strings.Contains(location, "redirect_uri=https%3A%2F%2Ftell.dev%2Fapi%2Fauth%2Fcallback") {
|
||||||
|
t.Fatalf("unexpected login redirect: %s", location)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionAdvertisesGoLooseEndpoints(t *testing.T) {
|
||||||
|
routes := newTestRoutes(&fakeContent{}, testGoLooseAuth(t))
|
||||||
|
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
routes.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/api/auth/session", nil))
|
||||||
|
if response.Code != http.StatusOK {
|
||||||
|
t.Fatalf("session status = %d", response.Code)
|
||||||
|
}
|
||||||
|
body := response.Body.String()
|
||||||
|
for _, want := range []string{`"authenticated":false`, `"loginUrl":"/api/auth/login"`, `"logoutUrl":"/api/auth/logout"`} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Fatalf("session = %s, want %s", body, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionWithoutGoLooseHasNoLoginURL(t *testing.T) {
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
newTestRoutes(&fakeContent{}).ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/api/auth/session", nil))
|
||||||
|
if strings.Contains(response.Body.String(), "loginUrl") {
|
||||||
|
t.Fatalf("session = %s, want no login endpoint", response.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGoLooseRoutesAreNotFoundWhenUnconfigured(t *testing.T) {
|
||||||
|
routes := newTestRoutes(&fakeContent{})
|
||||||
|
for _, path := range []string{golooseauth.LoginPath, golooseauth.CallbackPath, golooseauth.LogoutPath} {
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
routes.ServeHTTP(response, httptest.NewRequest(http.MethodGet, path, nil))
|
||||||
|
if response.Code != http.StatusNotFound || !strings.Contains(response.Body.String(), "GO_LOOSE_CLIENT_ID") {
|
||||||
|
t.Fatalf("%s without Go Loose = %d %s", path, response.Code, response.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testGoLooseAuth(t *testing.T) *golooseauth.Auth {
|
||||||
|
t.Helper()
|
||||||
|
auth, err := golooseauth.New(config.GoLooseConfig{
|
||||||
|
Tenant: "tell", AuthDomain: "auth.dev", AppDomain: "tell.dev",
|
||||||
|
ClientID: "glc_tell", ClientSecret: "secret",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return auth
|
||||||
|
}
|
||||||
|
|
||||||
func TestServesEmbeddedAppForClientRoutes(t *testing.T) {
|
func TestServesEmbeddedAppForClientRoutes(t *testing.T) {
|
||||||
routes := newTestRoutes(&fakeContent{})
|
routes := newTestRoutes(&fakeContent{})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"name": "cms-ui",
|
"name": "go-tell-ui",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,7 @@ export async function requestSession(): Promise<SessionResponse | null> {
|
||||||
}
|
}
|
||||||
|
|
||||||
function demoAuthHeaders(): Record<string, string> {
|
function demoAuthHeaders(): Record<string, string> {
|
||||||
const token = window.sessionStorage.getItem('cms-token')
|
const token = window.sessionStorage.getItem('go-tell-token')
|
||||||
return token ? {Authorization: `Bearer ${token}`} : {}
|
return token ? {Authorization: `Bearer ${token}`} : {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -99,7 +99,7 @@ async function contentRequest<T>(path = '', init?: RequestInit): Promise<T> {
|
||||||
headers: { Accept: 'application/json', ...demoAuthHeaders(), ...(init?.body ? { 'Content-Type': 'application/json' } : {}), ...init?.headers },
|
headers: { Accept: 'application/json', ...demoAuthHeaders(), ...(init?.body ? { 'Content-Type': 'application/json' } : {}), ...init?.headers },
|
||||||
})
|
})
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
if (response.status === 401) throw new Error('Your SSO session is required to edit content.')
|
if (response.status === 401) throw new Error('You have to sign in to edit content.')
|
||||||
throw new Error((await response.text()) || 'Unable to load content.')
|
throw new Error((await response.text()) || 'Unable to load content.')
|
||||||
}
|
}
|
||||||
return (await response.json()) as T
|
return (await response.json()) as T
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react'
|
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||||
import { apiConfig, requestSession, signInWithGoogle, sudoLogin } from './api'
|
import { apiConfig, apiUrl, requestSession, signInWithGoogle, sudoLogin } from './api'
|
||||||
|
|
||||||
// There is no sign-in or sign-out: the upstream SSO proxy decides whether the
|
// Signing in is either the Go Loose browser login or a redirect to the
|
||||||
// current request carries the owner's identity. Signing in just sends the
|
// identity-aware proxy, depending on what the API advertises: /api/auth/session
|
||||||
// browser to the configured Google/IdP URL.
|
// returns loginUrl and logoutUrl when Go Loose is configured. The demo profile
|
||||||
|
// keeps using its own token in sessionStorage.
|
||||||
type AuthState = {
|
type AuthState = {
|
||||||
loading: boolean
|
loading: boolean
|
||||||
authenticated: boolean
|
authenticated: boolean
|
||||||
|
|
@ -12,6 +13,7 @@ type AuthState = {
|
||||||
error: string | null
|
error: string | null
|
||||||
refresh: () => Promise<void>
|
refresh: () => Promise<void>
|
||||||
canSignIn: boolean
|
canSignIn: boolean
|
||||||
|
canSignOut: boolean
|
||||||
signIn: () => void
|
signIn: () => void
|
||||||
sudo: (username: string, password: string) => Promise<void>
|
sudo: (username: string, password: string) => Promise<void>
|
||||||
signOut: () => void
|
signOut: () => void
|
||||||
|
|
@ -23,6 +25,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
const [authenticated, setAuthenticated] = useState(false)
|
const [authenticated, setAuthenticated] = useState(false)
|
||||||
const [canEdit, setCanEdit] = useState(false)
|
const [canEdit, setCanEdit] = useState(false)
|
||||||
const [username, setUsername] = useState<string | undefined>()
|
const [username, setUsername] = useState<string | undefined>()
|
||||||
|
const [loginUrl, setLoginUrl] = useState('')
|
||||||
|
const [logoutUrl, setLogoutUrl] = useState('')
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
|
@ -33,10 +37,12 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
setAuthenticated(session?.authenticated === true)
|
setAuthenticated(session?.authenticated === true)
|
||||||
setCanEdit(session?.canEdit === true)
|
setCanEdit(session?.canEdit === true)
|
||||||
setUsername(session?.username)
|
setUsername(session?.username)
|
||||||
|
setLoginUrl(session?.loginUrl ?? '')
|
||||||
|
setLogoutUrl(session?.logoutUrl ?? '')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setAuthenticated(false)
|
setAuthenticated(false)
|
||||||
setCanEdit(false)
|
setCanEdit(false)
|
||||||
setError(err instanceof Error ? err.message : 'Unable to check your SSO session.')
|
setError(err instanceof Error ? err.message : 'Unable to check your session.')
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
|
|
@ -44,25 +50,36 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
|
||||||
useEffect(() => { void refresh() }, [refresh])
|
useEffect(() => { void refresh() }, [refresh])
|
||||||
|
|
||||||
const signIn = useCallback(() => { signInWithGoogle() }, [])
|
const signIn = useCallback(() => {
|
||||||
|
if (loginUrl) {
|
||||||
|
window.location.assign(apiUrl(loginUrl))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
signInWithGoogle()
|
||||||
|
}, [loginUrl])
|
||||||
const sudo = useCallback(async (username: string, password: string) => {
|
const sudo = useCallback(async (username: string, password: string) => {
|
||||||
const result = await sudoLogin(username, password)
|
const result = await sudoLogin(username, password)
|
||||||
window.sessionStorage.setItem('cms-token', result.token)
|
window.sessionStorage.setItem('go-tell-token', result.token)
|
||||||
setAuthenticated(true)
|
setAuthenticated(true)
|
||||||
setCanEdit(true)
|
setCanEdit(true)
|
||||||
setUsername(result.user.username)
|
setUsername(result.user.username)
|
||||||
}, [])
|
}, [])
|
||||||
const signOut = useCallback(() => {
|
const signOut = useCallback(() => {
|
||||||
window.sessionStorage.removeItem('cms-token')
|
if (logoutUrl) {
|
||||||
|
window.location.assign(apiUrl(logoutUrl))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
window.sessionStorage.removeItem('go-tell-token')
|
||||||
setAuthenticated(false)
|
setAuthenticated(false)
|
||||||
setCanEdit(false)
|
setCanEdit(false)
|
||||||
setUsername(undefined)
|
setUsername(undefined)
|
||||||
}, [])
|
}, [logoutUrl])
|
||||||
const canSignIn = apiConfig.signIn !== ''
|
const canSignIn = apiConfig.signIn !== '' || loginUrl !== ''
|
||||||
|
const canSignOut = logoutUrl !== ''
|
||||||
|
|
||||||
const value = useMemo(
|
const value = useMemo(
|
||||||
() => ({ loading, authenticated, canEdit, username, error, refresh, canSignIn, signIn, sudo, signOut }),
|
() => ({ loading, authenticated, canEdit, username, error, refresh, canSignIn, canSignOut, signIn, sudo, signOut }),
|
||||||
[loading, authenticated, canEdit, username, error, refresh, canSignIn, signIn, sudo, signOut],
|
[loading, authenticated, canEdit, username, error, refresh, canSignIn, canSignOut, signIn, sudo, signOut],
|
||||||
)
|
)
|
||||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import {type DemoUser} from '../models'
|
||||||
import {SudoTerminal} from './SudoTerminal'
|
import {SudoTerminal} from './SudoTerminal'
|
||||||
|
|
||||||
export function Shell({children}: {children: ReactNode}) {
|
export function Shell({children}: {children: ReactNode}) {
|
||||||
const {authenticated, loading, canSignIn, signIn, username, sudo} = useAuth()
|
const {authenticated, loading, canSignIn, canSignOut, signIn, signOut, username, sudo} = useAuth()
|
||||||
const [sudoOpen, setSudoOpen] = useState(false)
|
const [sudoOpen, setSudoOpen] = useState(false)
|
||||||
const [helpOpen, setHelpOpen] = useState(false)
|
const [helpOpen, setHelpOpen] = useState(false)
|
||||||
const [users, setUsers] = useState<DemoUser[]>([])
|
const [users, setUsers] = useState<DemoUser[]>([])
|
||||||
|
|
@ -24,7 +24,10 @@ export function Shell({children}: {children: ReactNode}) {
|
||||||
<span className="brand-cursor">_</span><small>linux · go · java · ideas</small></Link>
|
<span className="brand-cursor">_</span><small>linux · go · java · ideas</small></Link>
|
||||||
<div className="header-actions">
|
<div className="header-actions">
|
||||||
{!loading && <button className="mode-badge" type="button" onClick={() => {
|
{!loading && <button className="mode-badge" type="button" onClick={() => {
|
||||||
if (authenticated) return
|
if (authenticated) {
|
||||||
|
if (canSignOut) signOut()
|
||||||
|
return
|
||||||
|
}
|
||||||
if (users.length > 0) setSudoOpen(true)
|
if (users.length > 0) setSudoOpen(true)
|
||||||
else if (canSignIn) signIn()
|
else if (canSignIn) signIn()
|
||||||
}}>
|
}}>
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,12 @@ export type SessionResponse = {
|
||||||
authenticated?: boolean
|
authenticated?: boolean
|
||||||
canEdit?: boolean
|
canEdit?: boolean
|
||||||
username?: string
|
username?: string
|
||||||
|
email?: string
|
||||||
|
displayName?: string
|
||||||
|
// Go Loose advertises its own login and logout endpoints; the UI follows them
|
||||||
|
// instead of sending the browser to a single-sign-on proxy.
|
||||||
|
loginUrl?: string
|
||||||
|
logoutUrl?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DemoUser = { username: string; name: string }
|
export type DemoUser = { username: string; name: string }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue