diff --git a/.github/workflows/build-publish.yaml b/.github/workflows/build-publish.yaml index 7d54f81..eb5ddb0 100644 --- a/.github/workflows/build-publish.yaml +++ b/.github/workflows/build-publish.yaml @@ -28,6 +28,11 @@ jobs: cache: pnpm cache-dependency-path: pnpm-lock.yaml + - name: Set up Helm + uses: azure/setup-helm@v4 + with: + version: v3.16.3 + - name: Install dependencies run: pnpm install --frozen-lockfile @@ -36,3 +41,9 @@ jobs: - name: Test run: pnpm test + + - name: Helm lint + run: make helm-lint + + - name: Helm template + run: make helm-template diff --git a/.github/workflows/chart-release.yaml b/.github/workflows/chart-release.yaml new file mode 100644 index 0000000..323b7f3 --- /dev/null +++ b/.github/workflows/chart-release.yaml @@ -0,0 +1,70 @@ +name: Chart Release + +on: + push: + branches: [ master ] + paths: + - .github/workflows/chart-release.yaml + - artifacthub-repo.yml + - charts/** + workflow_dispatch: + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Configure Git + run: | + git config user.name "${GITHUB_ACTOR}" + git config user.email "${GITHUB_ACTOR}@users.noreply.github.com" + + - name: Run chart-releaser + uses: helm/chart-releaser-action@v1.7.0 + with: + charts_dir: charts + pages_branch: gh-pages + packages_with_index: true + skip_existing: true + env: + CR_TOKEN: "${{ secrets.GITHUB_TOKEN }}" + + - name: Publish Artifact Hub repository metadata + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + + if ! git ls-remote --exit-code --heads origin gh-pages >/dev/null 2>&1; then + echo "gh-pages branch does not exist yet; skipping artifacthub-repo.yml publication" + exit 0 + fi + + tmp_dir="$(mktemp -d)" + trap 'rm -rf "${tmp_dir}"' EXIT + + git clone --branch gh-pages --single-branch \ + "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" \ + "${tmp_dir}" + + install -m 0644 artifacthub-repo.yml "${tmp_dir}/artifacthub-repo.yml" + + cd "${tmp_dir}" + if git diff --quiet -- artifacthub-repo.yml; then + echo "artifacthub-repo.yml already up to date on gh-pages" + exit 0 + fi + + git config user.name "${GITHUB_ACTOR}" + git config user.email "${GITHUB_ACTOR}@users.noreply.github.com" + git add artifacthub-repo.yml + git commit -m "Publish Artifact Hub repository metadata" + git push origin gh-pages diff --git a/AGENTS.md b/AGENTS.md index 9e6676f..95d6a93 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,18 +13,20 @@ - Start: `pnpm start` - Lint: `pnpm lint` - Test: `pnpm test` +- Helm lint: `make helm-lint` or `helm lint charts/readability-js-server` +- Helm template: `make helm-template` or `helm template readability-js-server charts/readability-js-server` - Memory soak: `make soak` or `node scripts/memory-soak.js --requests 100 --concurrency 2 --sample-every 10` - Docker build: `docker build -t readability-js .` - Docker run: `docker run --rm -p 3000:3000 readability-js` - Release tag: `make release-tag VERSION=1.8.0` -The Makefile mirrors those workflows with `make install`, `make start`, `make lint`, `make lint-fix`, `make build-container`, `make run-container`, `make release-tag`, and `make example-request`. +The Makefile mirrors those workflows with `make install`, `make start`, `make lint`, `make lint-fix`, `make helm-lint`, `make helm-template`, `make helm-verify`, `make build-container`, `make run-container`, `make release-tag`, and `make example-request`. `package.json` is the single source of truth for the service version. Release publishing is tag-driven: bump `package.json`'s `version`, commit it, create a matching `vX.Y.Z` tag, and push the tag to trigger Docker publish plus GitHub Release creation. ## Testing expectations -- Run `pnpm lint` and `pnpm test` for any docs, API, config, or dependency change. +- Run `pnpm lint`, `pnpm test`, and the Helm verification targets for any docs, API, config, chart, or dependency change. - Add or update tests when a change touches response shape, error normalization, URL validation, sanitization, redirect handling, concurrency gating, or config parsing. - Use the memory soak script when a change could affect allocation behavior or long-run stability. diff --git a/Makefile b/Makefile index 06a9028..29beabb 100644 --- a/Makefile +++ b/Makefile @@ -10,6 +10,17 @@ lint: lint-fix: pnpm lint:fix +HELM_CHART ?= charts/readability-js-server +HELM_RELEASE_NAME ?= readability-js-server + +helm-lint: + helm lint $(HELM_CHART) + +helm-template: + helm template $(HELM_RELEASE_NAME) $(HELM_CHART) + +helm-verify: helm-lint helm-template + build-container: docker build -t readability-js . diff --git a/README.md b/README.md index 0fb5b04..23728ac 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ At the time of this uplift, `@mozilla/readability@0.6.0` was already the latest - Sanitization: DOMPurify 3 - Deployment image: `node:24-alpine` -The container runs as a non-root user and the service exposes a single `POST /` endpoint. +The container runs as a non-root user and the service exposes `POST /` plus a lightweight `GET /healthz` probe endpoint. The Compose example uses that same `/healthz` path for its container healthcheck. ## API @@ -131,6 +131,34 @@ make start Release versions come from [`package.json`](package.json). To publish a release, bump `version`, commit the change, create a `vX.Y.Z` tag, and push that tag. The release workflow publishes Docker images for `X.Y.Z`, `X.Y`, `X`, and `latest`, and creates the matching GitHub Release with generated notes. +## Helm chart + +The Kubernetes chart is published as a conventional Helm repository on GitHub Pages: + +```bash +helm repo add phpdocker-io https://phpdocker-io.github.io/readability-js-server +helm repo update +helm install readability-js-server phpdocker-io/readability-js-server \ + --namespace readability \ + --create-namespace +``` + +For local chart development, install from the checkout with `helm install readability-js-server ./charts/readability-js-server`. + +Artifact Hub should reference the external Helm repository URL `https://phpdocker-io.github.io/readability-js-server`. It should not be configured to ingest GitHub release assets directly. + +## Release and versioning + +Docker image publishing remains tag-driven. Bump [`package.json`](package.json), commit it, create the matching `vX.Y.Z` tag, and push the tag to publish the container image and GitHub Release. + +Helm chart publishing is separate and runs from chart changes on `master` or an explicit manual trigger. It packages changed charts from `charts/`, updates the GitHub Pages repository on `gh-pages`, and publishes `artifacthub-repo.yml` next to `index.yaml` for Artifact Hub. + +For the chart itself: + +- Bump `charts/readability-js-server/Chart.yaml` `version` for any chart package change, including templates, defaults, README content, metadata, or publishing annotations. +- Bump `appVersion` only when the chart's default application image tag changes. +- When the default application image tag changes, bump both `version` and `appVersion`. + ## Testing Run the lint and test suites with pnpm: @@ -140,6 +168,13 @@ pnpm lint pnpm test ``` +Run the Helm chart checks with the Makefile: + +```bash +make helm-lint +make helm-template +``` + The repo also exposes a memory soak harness: ```bash @@ -155,6 +190,7 @@ The Makefile provides the same checks: ```bash make lint make lint-fix +make helm-verify ``` For release tagging there is also: @@ -174,9 +210,9 @@ docker run --rm -p 3000:3000 readability-js The image is based on `node:24-alpine`, installs production dependencies only, and runs the service as a non-root user. -CI on pull requests and pushes to `master` runs lint and tests only. Container publishing happens from the tag-triggered release workflow. +CI on pull requests and pushes to `master` runs lint, tests, and Helm chart verification. Container publishing happens from the tag-triggered release workflow. -For Docker Compose setup, see [`examples/compose.yaml`](examples/compose.yaml). +For Docker Compose setup, see [`examples/compose.yaml`](examples/compose.yaml). That example publishes port `3000` and checks `GET /healthz` from inside the container using the `node` runtime that ships in the published image. ## Security posture @@ -193,7 +229,7 @@ This service is still an untrusted content fetcher. Do not relax the defaults wi ## Limits -- Single endpoint only: `POST /` +- Public API endpoints: `POST /` and `GET /healthz` - No authentication - No cache - No persistence diff --git a/artifacthub-repo.yml b/artifacthub-repo.yml new file mode 100644 index 0000000..2c8118b --- /dev/null +++ b/artifacthub-repo.yml @@ -0,0 +1,11 @@ +# Artifact Hub repository metadata for the published Helm repository. +# This file must be served from the same path as index.yaml: +# https://phpdocker-io.github.io/readability-js-server/artifacthub-repo.yml +# +# Add repositoryID after the repository has been created in Artifact Hub to +# enable verified publisher checks. +# repositoryID: 00000000-0000-0000-0000-000000000000 + +owners: + - name: Luis Pabon + email: luis.pabon@auronconsulting.co.uk diff --git a/charts/readability-js-server/Chart.yaml b/charts/readability-js-server/Chart.yaml new file mode 100644 index 0000000..8a59de3 --- /dev/null +++ b/charts/readability-js-server/Chart.yaml @@ -0,0 +1,35 @@ +apiVersion: v2 +name: readability-js-server +description: A production-usable Helm chart for Readability JS Server +type: application +version: 0.1.0 +appVersion: "1.8.0" +home: https://github.com/phpdocker-io/readability-js-server +annotations: + artifacthub.io/license: Apache-2.0 + artifacthub.io/links: | + - name: source + url: https://github.com/phpdocker-io/readability-js-server + - name: support + url: https://github.com/phpdocker-io/readability-js-server/issues + - name: chart repository + url: https://phpdocker-io.github.io/readability-js-server + artifacthub.io/maintainers: | + - name: Luis Pabon + email: luis.pabon@auronconsulting.co.uk + artifacthub.io/changes: | + - kind: added + description: Publish chart packages and index.yaml to the GitHub Pages Helm repository. + - kind: added + description: Add Artifact Hub repository metadata and installation guidance for the hosted chart repository. +sources: + - https://github.com/phpdocker-io/readability-js-server +keywords: + - readability + - article-extraction + - express + - nodejs +maintainers: + - name: Luis Pabon + email: luis.pabon@auronconsulting.co.uk + url: https://github.com/luispabon diff --git a/charts/readability-js-server/README.md b/charts/readability-js-server/README.md new file mode 100644 index 0000000..b43bd0e --- /dev/null +++ b/charts/readability-js-server/README.md @@ -0,0 +1,105 @@ +# readability-js-server Helm chart + +This chart deploys Readability JS Server as a single-replica `Deployment` behind a `ClusterIP` `Service` by default. Optional `Ingress`, `HorizontalPodAutoscaler`, and `PodDisruptionBudget` resources stay disabled until you opt in. + +## Prerequisites + +- Kubernetes 1.26 or newer +- Helm 3.12 or newer + +## Installation + +Add the published Helm repository and install the chart: + +```bash +helm repo add phpdocker-io https://phpdocker-io.github.io/readability-js-server +helm repo update +helm install readability-js-server phpdocker-io/readability-js-server \ + --namespace readability \ + --create-namespace +``` + +For local chart development, install directly from the repository checkout: + +```bash +helm install readability-js-server ./charts/readability-js-server +``` + +Override values at install or upgrade time: + +```bash +helm upgrade --install readability-js-server ./charts/readability-js-server \ + --namespace readability \ + --create-namespace \ + --set image.tag=1.8.0 \ + --set ingress.enabled=true \ + --set ingress.hosts[0].host=readability.example.com +``` + +## Defaults + +- Chart version: `0.1.0` +- App version: `1.8.0` +- Image repository: `phpdockerio/readability-js-server` +- Image tag: `1.8.0` +- Container listen port: `3000` +- Service type: `ClusterIP` +- Replica count: `1` +- Liveness and readiness probes: `GET /healthz` + +## Configuration + +The chart exposes the settings most clusters need without requiring a fork: + +| Area | Values | +| --- | --- | +| Image | `image.repository`, `image.tag`, `image.pullPolicy`, `imagePullSecrets` | +| Service account | `serviceAccount.create`, `serviceAccount.name`, `serviceAccount.annotations`, `serviceAccount.labels`, `serviceAccount.automountServiceAccountToken` | +| Pod metadata | `podLabels`, `podAnnotations`, `priorityClassName` | +| Security | `podSecurityContext`, `containerSecurityContext` | +| Scheduling | `nodeSelector`, `tolerations`, `affinity`, `topologySpreadConstraints` | +| Networking | `service.type`, `service.port`, `service.annotations`, `service.labels`, `ingress.*` | +| Capacity | `replicaCount`, `resources`, `autoscaling.*`, `pdb.*`, `terminationGracePeriodSeconds` | +| Probes | `probes.liveness.*`, `probes.readiness.*`, `probes.startup.*` | +| App config | `appConfig.PORT`, `appConfig.REQUEST_BODY_LIMIT`, `appConfig.FETCH_TIMEOUT_MS`, `appConfig.FETCH_MAX_BYTES`, `appConfig.FETCH_MAX_REDIRECTS`, `appConfig.BLOCK_PRIVATE_NETWORKS`, `appConfig.READABILITY_MAX_ELEMS`, `appConfig.MAX_CONCURRENT_REQUESTS`, `appConfig.CONTENT_FORMAT` | + +`appConfig` maps directly to the environment variables already supported by `src/config.js`. `READABILITY_MAX_ELEMS` is unset by default so the service preserves its current runtime behavior until you choose a limit. + +## Optional resources + +- `ingress.enabled=true` renders a Kubernetes `Ingress`. Configure hostnames, annotations, class name, paths, and TLS through `ingress.*`. +- `autoscaling.enabled=true` renders an `autoscaling/v2` `HorizontalPodAutoscaler`. You can use the built-in CPU and memory targets or provide a full `autoscaling.metrics` list plus `autoscaling.behavior`. +- `pdb.enabled=true` renders a `PodDisruptionBudget`. Set exactly one of `pdb.minAvailable` or `pdb.maxUnavailable`. + +## Versioning policy + +Chart and application versions are intentionally separate: + +- `version` tracks chart package changes. Bump it for any chart content change, including templates, values, `Chart.yaml` metadata, README updates, or Artifact Hub annotations. +- `appVersion` tracks the default Readability JS Server image version used by the chart. + +When the chart changes without a new application release, only the chart `version` should move. When the default image tag changes, bump both the chart `version` and `appVersion`. + +Docker image publishing remains tag-driven from the root release workflow and follows `package.json`. Helm chart publishing is separate: pushes to `master` that change `charts/**`, plus explicit manual workflow runs, publish the chart repository to GitHub Pages. + +## Hosted repository and Artifact Hub + +The published Helm repository URL is: + +```text +https://phpdocker-io.github.io/readability-js-server +``` + +Artifact Hub should register that external Helm repository URL. It should not be pointed at GitHub Releases or at the source repository itself. + +Repository-level Artifact Hub metadata lives in `artifacthub-repo.yml`. The chart release workflow copies that file onto `gh-pages` so it is served next to `index.yaml`, which is the layout Artifact Hub expects for ownership claims and verified publisher metadata. + +## Verification + +Render and lint the chart locally: + +```bash +helm lint charts/readability-js-server +helm template readability-js-server charts/readability-js-server +helm package charts/readability-js-server +``` diff --git a/charts/readability-js-server/templates/NOTES.txt b/charts/readability-js-server/templates/NOTES.txt new file mode 100644 index 0000000..3889909 --- /dev/null +++ b/charts/readability-js-server/templates/NOTES.txt @@ -0,0 +1,18 @@ +1. Check the release status: + kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/instance={{ .Release.Name }}" + +2. Port-forward the service locally: + kubectl --namespace {{ .Release.Namespace }} port-forward svc/{{ include "readability-js-server.fullname" . }} 3000:{{ .Values.service.port }} + +3. Probe the service: + curl -i http://127.0.0.1:3000/healthz +{{- if .Values.ingress.enabled }} + +4. If your ingress controller is ready, test the configured hostnames: +{{- range .Values.ingress.hosts }} +{{- $host := .host }} +{{- range .paths }} + curl -i http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host }}{{ .path }} +{{- end }} +{{- end }} +{{- end }} diff --git a/charts/readability-js-server/templates/_helpers.tpl b/charts/readability-js-server/templates/_helpers.tpl new file mode 100644 index 0000000..c6e3dac --- /dev/null +++ b/charts/readability-js-server/templates/_helpers.tpl @@ -0,0 +1,73 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "readability-js-server.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{/* +Create a default fully qualified app name. +*/}} +{{- define "readability-js-server.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 -}} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "readability-js-server.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{/* +Common labels. +*/}} +{{- define "readability-js-server.labels" -}} +helm.sh/chart: {{ include "readability-js-server.chart" . }} +app.kubernetes.io/name: {{ include "readability-js-server.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end -}} + +{{/* +Selector labels. +*/}} +{{- define "readability-js-server.selectorLabels" -}} +app.kubernetes.io/name: {{ include "readability-js-server.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end -}} + +{{/* +Create the name of the service account to use. +*/}} +{{- define "readability-js-server.serviceAccountName" -}} +{{- if .Values.serviceAccount.create -}} +{{- default (include "readability-js-server.fullname" .) .Values.serviceAccount.name -}} +{{- else -}} +{{- default "default" .Values.serviceAccount.name -}} +{{- end -}} +{{- end -}} + +{{/* +Render a standard HTTP probe. +*/}} +{{- define "readability-js-server.httpProbe" -}} +httpGet: + path: {{ .path | quote }} + port: http +initialDelaySeconds: {{ .initialDelaySeconds }} +periodSeconds: {{ .periodSeconds }} +timeoutSeconds: {{ .timeoutSeconds }} +successThreshold: {{ .successThreshold }} +failureThreshold: {{ .failureThreshold }} +{{- end -}} diff --git a/charts/readability-js-server/templates/deployment.yaml b/charts/readability-js-server/templates/deployment.yaml new file mode 100644 index 0000000..8ffb4e0 --- /dev/null +++ b/charts/readability-js-server/templates/deployment.yaml @@ -0,0 +1,102 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "readability-js-server.fullname" . }} + labels: + {{- include "readability-js-server.labels" . | nindent 4 }} +spec: + {{- if not .Values.autoscaling.enabled }} + replicas: {{ .Values.replicaCount }} + {{- end }} + selector: + matchLabels: + {{- include "readability-js-server.selectorLabels" . | nindent 6 }} + template: + metadata: + labels: + {{- include "readability-js-server.selectorLabels" . | nindent 8 }} + {{- with .Values.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + serviceAccountName: {{ include "readability-js-server.serviceAccountName" . }} + automountServiceAccountToken: {{ .Values.serviceAccount.automountServiceAccountToken }} + terminationGracePeriodSeconds: {{ .Values.terminationGracePeriodSeconds }} + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.podSecurityContext }} + securityContext: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.priorityClassName }} + priorityClassName: {{ . | quote }} + {{- end }} + containers: + - name: {{ include "readability-js-server.name" . }} + image: "{{ .Values.image.repository }}:{{ default .Chart.AppVersion .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + {{- with .Values.containerSecurityContext }} + securityContext: + {{- toYaml . | nindent 12 }} + {{- end }} + ports: + - name: http + containerPort: {{ default .Values.appConfig.PORT .Values.containerPort }} + protocol: TCP + env: + - name: PORT + value: {{ printf "%d" (int64 .Values.appConfig.PORT) | quote }} + - name: REQUEST_BODY_LIMIT + value: {{ .Values.appConfig.REQUEST_BODY_LIMIT | quote }} + - name: FETCH_TIMEOUT_MS + value: {{ printf "%d" (int64 .Values.appConfig.FETCH_TIMEOUT_MS) | quote }} + - name: FETCH_MAX_BYTES + value: {{ printf "%d" (int64 .Values.appConfig.FETCH_MAX_BYTES) | quote }} + - name: FETCH_MAX_REDIRECTS + value: {{ printf "%d" (int64 .Values.appConfig.FETCH_MAX_REDIRECTS) | quote }} + - name: BLOCK_PRIVATE_NETWORKS + value: {{ printf "%v" .Values.appConfig.BLOCK_PRIVATE_NETWORKS | quote }} + {{- if ne .Values.appConfig.READABILITY_MAX_ELEMS nil }} + - name: READABILITY_MAX_ELEMS + value: {{ printf "%d" (int64 .Values.appConfig.READABILITY_MAX_ELEMS) | quote }} + {{- end }} + - name: MAX_CONCURRENT_REQUESTS + value: {{ printf "%d" (int64 .Values.appConfig.MAX_CONCURRENT_REQUESTS) | quote }} + - name: CONTENT_FORMAT + value: {{ .Values.appConfig.CONTENT_FORMAT | quote }} + {{- if .Values.probes.startup.enabled }} + startupProbe: + {{- include "readability-js-server.httpProbe" .Values.probes.startup | nindent 12 }} + {{- end }} + {{- if .Values.probes.liveness.enabled }} + livenessProbe: + {{- include "readability-js-server.httpProbe" .Values.probes.liveness | nindent 12 }} + {{- end }} + {{- if .Values.probes.readiness.enabled }} + readinessProbe: + {{- include "readability-js-server.httpProbe" .Values.probes.readiness | nindent 12 }} + {{- end }} + resources: + {{- toYaml .Values.resources | nindent 12 }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.topologySpreadConstraints }} + topologySpreadConstraints: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/charts/readability-js-server/templates/hpa.yaml b/charts/readability-js-server/templates/hpa.yaml new file mode 100644 index 0000000..3c1f9ee --- /dev/null +++ b/charts/readability-js-server/templates/hpa.yaml @@ -0,0 +1,41 @@ +{{- if .Values.autoscaling.enabled }} +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: {{ include "readability-js-server.fullname" . }} + labels: + {{- include "readability-js-server.labels" . | nindent 4 }} +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: {{ include "readability-js-server.fullname" . }} + minReplicas: {{ .Values.autoscaling.minReplicas }} + maxReplicas: {{ .Values.autoscaling.maxReplicas }} + {{- if .Values.autoscaling.metrics }} + metrics: + {{- toYaml .Values.autoscaling.metrics | nindent 4 }} + {{- else }} + metrics: + {{- if ne .Values.autoscaling.targetCPUUtilizationPercentage nil }} + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }} + {{- end }} + {{- if ne .Values.autoscaling.targetMemoryUtilizationPercentage nil }} + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }} + {{- end }} + {{- end }} + {{- with .Values.autoscaling.behavior }} + behavior: + {{- toYaml . | nindent 4 }} + {{- end }} +{{- end }} diff --git a/charts/readability-js-server/templates/ingress.yaml b/charts/readability-js-server/templates/ingress.yaml new file mode 100644 index 0000000..c7a0f0a --- /dev/null +++ b/charts/readability-js-server/templates/ingress.yaml @@ -0,0 +1,35 @@ +{{- if .Values.ingress.enabled }} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "readability-js-server.fullname" . }} + labels: + {{- include "readability-js-server.labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- with .Values.ingress.className }} + ingressClassName: {{ . | quote }} + {{- end }} + {{- with .Values.ingress.tls }} + tls: + {{- toYaml . | nindent 4 }} + {{- end }} + rules: + {{- range .Values.ingress.hosts }} + - host: {{ .host | quote }} + http: + paths: + {{- range .paths }} + - path: {{ .path | quote }} + pathType: {{ .pathType }} + backend: + service: + name: {{ include "readability-js-server.fullname" $ }} + port: + number: {{ $.Values.service.port }} + {{- end }} + {{- end }} +{{- end }} diff --git a/charts/readability-js-server/templates/pdb.yaml b/charts/readability-js-server/templates/pdb.yaml new file mode 100644 index 0000000..080f1e6 --- /dev/null +++ b/charts/readability-js-server/templates/pdb.yaml @@ -0,0 +1,25 @@ +{{- if .Values.pdb.enabled }} +{{- $hasMin := ne .Values.pdb.minAvailable nil }} +{{- $hasMax := ne .Values.pdb.maxUnavailable nil }} +{{- if and $hasMin $hasMax }} +{{- fail "pdb.minAvailable and pdb.maxUnavailable are mutually exclusive" }} +{{- end }} +{{- if and (not $hasMin) (not $hasMax) }} +{{- fail "set pdb.minAvailable or pdb.maxUnavailable when pdb.enabled=true" }} +{{- end }} +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: {{ include "readability-js-server.fullname" . }} + labels: + {{- include "readability-js-server.labels" . | nindent 4 }} +spec: + selector: + matchLabels: + {{- include "readability-js-server.selectorLabels" . | nindent 6 }} + {{- if $hasMin }} + minAvailable: {{ .Values.pdb.minAvailable }} + {{- else }} + maxUnavailable: {{ .Values.pdb.maxUnavailable }} + {{- end }} +{{- end }} diff --git a/charts/readability-js-server/templates/service.yaml b/charts/readability-js-server/templates/service.yaml new file mode 100644 index 0000000..d7c778a --- /dev/null +++ b/charts/readability-js-server/templates/service.yaml @@ -0,0 +1,22 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "readability-js-server.fullname" . }} + labels: + {{- include "readability-js-server.labels" . | nindent 4 }} + {{- with .Values.service.labels }} + {{- toYaml . | nindent 4 }} + {{- end }} + {{- with .Values.service.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: http + protocol: TCP + name: http + selector: + {{- include "readability-js-server.selectorLabels" . | nindent 4 }} diff --git a/charts/readability-js-server/templates/serviceaccount.yaml b/charts/readability-js-server/templates/serviceaccount.yaml new file mode 100644 index 0000000..481d219 --- /dev/null +++ b/charts/readability-js-server/templates/serviceaccount.yaml @@ -0,0 +1,16 @@ +{{- if .Values.serviceAccount.create }} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "readability-js-server.serviceAccountName" . }} + labels: + {{- include "readability-js-server.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.labels }} + {{- toYaml . | nindent 4 }} + {{- end }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +automountServiceAccountToken: {{ .Values.serviceAccount.automountServiceAccountToken }} +{{- end }} diff --git a/charts/readability-js-server/values.yaml b/charts/readability-js-server/values.yaml new file mode 100644 index 0000000..3184261 --- /dev/null +++ b/charts/readability-js-server/values.yaml @@ -0,0 +1,117 @@ +replicaCount: 1 + +nameOverride: "" +fullnameOverride: "" + +image: + repository: phpdockerio/readability-js-server + pullPolicy: IfNotPresent + tag: "1.8.0" + +imagePullSecrets: [] + +serviceAccount: + create: true + name: "" + annotations: {} + labels: {} + automountServiceAccountToken: true + +podAnnotations: {} +podLabels: {} + +podSecurityContext: + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault + +containerSecurityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + +priorityClassName: "" +nodeSelector: {} +tolerations: [] +affinity: {} +topologySpreadConstraints: [] +terminationGracePeriodSeconds: 30 + +containerPort: null + +service: + type: ClusterIP + port: 3000 + annotations: {} + labels: {} + +resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: 500m + memory: 512Mi + +appConfig: + PORT: 3000 + REQUEST_BODY_LIMIT: 16kb + FETCH_TIMEOUT_MS: 10000 + FETCH_MAX_BYTES: 5242880 + FETCH_MAX_REDIRECTS: 5 + BLOCK_PRIVATE_NETWORKS: true + READABILITY_MAX_ELEMS: null + MAX_CONCURRENT_REQUESTS: 10 + CONTENT_FORMAT: markdown + +probes: + liveness: + enabled: true + path: /healthz + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 2 + successThreshold: 1 + failureThreshold: 3 + readiness: + enabled: true + path: /healthz + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 2 + successThreshold: 1 + failureThreshold: 3 + startup: + enabled: false + path: /healthz + initialDelaySeconds: 0 + periodSeconds: 10 + timeoutSeconds: 2 + successThreshold: 1 + failureThreshold: 30 + +ingress: + enabled: false + className: "" + annotations: {} + hosts: + - host: readability-js-server.local + paths: + - path: / + pathType: Prefix + tls: [] + +autoscaling: + enabled: false + minReplicas: 2 + maxReplicas: 5 + targetCPUUtilizationPercentage: 80 + targetMemoryUtilizationPercentage: null + metrics: [] + behavior: {} + +pdb: + enabled: false + minAvailable: 1 + maxUnavailable: null diff --git a/examples/compose.yaml b/examples/compose.yaml index 7b9a52d..5f7d1cb 100644 --- a/examples/compose.yaml +++ b/examples/compose.yaml @@ -3,6 +3,18 @@ services: image: phpdockerio/readability-js-server:latest ports: - "3000:3000" + healthcheck: + test: + [ + "CMD", + "node", + "-e", + "fetch('http://127.0.0.1:3000/healthz').then((response) => process.exit(response.ok ? 0 : 1)).catch(() => process.exit(1));", + ] + interval: 10s + timeout: 3s + retries: 3 + start_period: 5s # Uncomment and set any of the following environment variables to override defaults: # environment: # PORT: 3000 diff --git a/src/app.js b/src/app.js index cbc6c9f..126ea2a 100644 --- a/src/app.js +++ b/src/app.js @@ -436,6 +436,12 @@ function createApp(configInput, loggerInput) { const logger = loggerInput || createLogger(); const app = express(); + app.get("/healthz", (_req, res) => { + res.status(200).json({ + ok: true, + }); + }); + app.use(express.json({ limit: config.requestBodyLimit })); app.use(createConcurrencyGate(config.maxConcurrentRequests)); diff --git a/test/validation.test.js b/test/validation.test.js index 8167402..34b2b7b 100644 --- a/test/validation.test.js +++ b/test/validation.test.js @@ -13,6 +13,23 @@ test("GET / returns guidance for POST JSON", async () => { }); }); +test("GET /healthz returns a lightweight probe response", async (t) => { + const fetchMock = t.mock.method(global, "fetch", async () => { + throw new Error("fetch should not be called for health checks"); + }); + + t.after(() => { + fetchMock.mock.restore(); + }); + + const response = await supertest(createTestApp()).get("/healthz").expect(200); + + assert.deepEqual(response.body, { + ok: true, + }); + assert.equal(fetchMock.mock.callCount(), 0); +}); + test("POST / rejects missing and empty url values", async () => { const app = createTestApp();