mirror of
https://github.com/andwati/zitadel.git
synced 2026-10-18 05:52:51 +00:00
docs: add missing k8s example (#11474)
This pull request significantly expands and restructures the Kubernetes deployment documentation for Zitadel. It introduces detailed, task-focused guides for installation, configuration, ingress, and database setup, replacing the previous minimal documentation. The new docs provide comprehensive instructions and best practices for deploying Zitadel on Kubernetes using Helm, including security, scaling, and high availability considerations. The most important changes are: **Documentation Restructuring and Overview** - Added a new `index.mdx` providing an overview of the Zitadel Helm chart, architecture, prerequisites, and quick links to relevant resources and next steps. - Removed the old minimal Kubernetes deployment page and replaced it with a structured, multi-page documentation set. **Installation and Setup** - Added an `installation.mdx` guide with step-by-step instructions for preparing prerequisites, creating Kubernetes secrets, configuring `values.yaml`, installing the Helm chart, and verifying the deployment. **Configuration and Best Practices** - Added a `configuration.mdx` guide covering Helm chart configuration options, including replica count, container images, security contexts, external domain setup, secrets management, scaling, autoscaling, pod disruption budgets, and anti-affinity rules. **Ingress and Database Configuration** - Added an `ingress.mdx` guide detailing how to configure Kubernetes ingress resources for Zitadel and Login containers, including TLS termination and certificate management. - Added a `database.mdx` guide explaining how to connect Zitadel to PostgreSQL with various TLS/security options and how to manage database credentials and certificates using Kubernetes secrets.
This commit is contained in:
@@ -1,8 +0,0 @@
|
||||
---
|
||||
title: Set up ZITADEL on Kubernetes
|
||||
sidebar_label: Kubernetes
|
||||
---
|
||||
|
||||
For getting started with an easily testable insecure setup with Postgres, follow the [Insecure Postgres Example](https://github.com/zitadel/zitadel-charts/tree/main/examples/1-postgres-insecure).
|
||||
|
||||
For more information and configuration examples, go to the [ZITADEL charts repo](https://github.com/zitadel/zitadel-charts).
|
||||
@@ -0,0 +1,313 @@
|
||||
---
|
||||
title: Configuration
|
||||
sidebar_label: Configuration
|
||||
---
|
||||
|
||||
|
||||
|
||||
This guide covers the major configuration options for the Zitadel Helm chart. For a complete list of options, see the [values.yaml](https://github.com/zitadel/zitadel-charts/blob/main/charts/zitadel/values.yaml) in the chart repository.
|
||||
|
||||
## Global Settings
|
||||
|
||||
Global settings affect multiple components of the deployment.
|
||||
|
||||
### Replica Count
|
||||
|
||||
Set the number of Zitadel replicas:
|
||||
|
||||
```yaml
|
||||
replicaCount: 2
|
||||
```
|
||||
|
||||
### Container Images
|
||||
|
||||
#### Zitadel Image
|
||||
|
||||
Configure the Zitadel container image:
|
||||
|
||||
```yaml
|
||||
image:
|
||||
repository: ghcr.io/zitadel/zitadel
|
||||
tag: "v4.9.1"
|
||||
pullPolicy: IfNotPresent
|
||||
```
|
||||
|
||||
#### Login Image
|
||||
|
||||
Configure the Login container image:
|
||||
|
||||
```yaml
|
||||
login:
|
||||
image:
|
||||
repository: ghcr.io/zitadel/login
|
||||
tag: "v4.9.1"
|
||||
pullPolicy: IfNotPresent
|
||||
```
|
||||
|
||||
### Pod Security Context
|
||||
|
||||
Configure security settings for the pods. These settings apply globally to all pods in the deployment:
|
||||
|
||||
```yaml
|
||||
podSecurityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1000
|
||||
fsGroup: 1000
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1000
|
||||
readOnlyRootFilesystem: true
|
||||
privileged: false
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
```
|
||||
|
||||
## Zitadel Settings
|
||||
|
||||
### ExternalDomain
|
||||
|
||||
The domain where Zitadel is accessible. This is used for generating URLs, cookies, and OIDC endpoints.
|
||||
|
||||
```yaml
|
||||
zitadel:
|
||||
configmapConfig:
|
||||
ExternalDomain: "zitadel.example.com"
|
||||
ExternalPort: 443
|
||||
ExternalSecure: true
|
||||
```
|
||||
|
||||
| Setting | Description |
|
||||
|---------|-------------|
|
||||
| `ExternalDomain` | The public domain name (no protocol or port) |
|
||||
| `ExternalPort` | The public port (443 for HTTPS, 80 for HTTP) |
|
||||
| `ExternalSecure` | Whether the external connection uses HTTPS |
|
||||
|
||||
### TLS
|
||||
|
||||
TLS is terminated at the ingress controller. The Zitadel containers do not handle TLS termination:
|
||||
|
||||
```yaml
|
||||
zitadel:
|
||||
configmapConfig:
|
||||
TLS:
|
||||
Enabled: false
|
||||
```
|
||||
|
||||
### FirstInstance / Bootstrapping
|
||||
|
||||
Configure the initial Zitadel instance created during setup.
|
||||
|
||||
```yaml
|
||||
zitadel:
|
||||
configmapConfig:
|
||||
FirstInstance:
|
||||
Org:
|
||||
Human:
|
||||
UserName: "admin"
|
||||
Password: "SecurePassword123!"
|
||||
FirstName: "Zitadel"
|
||||
LastName: "Admin"
|
||||
Email: "[email protected]"
|
||||
PasswordChangeRequired: false
|
||||
```
|
||||
|
||||
#### Machine User for System API
|
||||
|
||||
For programmatic access, configure a system user with RSA keys:
|
||||
|
||||
```yaml
|
||||
zitadel:
|
||||
configmapConfig:
|
||||
SystemAPIUsers:
|
||||
systemuser:
|
||||
KeyData: |
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...
|
||||
-----END PUBLIC KEY-----
|
||||
```
|
||||
|
||||
Generate the RSA private key:
|
||||
|
||||
```bash
|
||||
openssl genrsa -out system-user-private.pem 2048
|
||||
```
|
||||
|
||||
Extract the public key:
|
||||
|
||||
```bash
|
||||
openssl rsa -in system-user-private.pem -pubout -out system-user-public.pem
|
||||
```
|
||||
|
||||
## Secrets
|
||||
|
||||
### Referenced Secrets
|
||||
|
||||
Reference Kubernetes Secrets for sensitive values:
|
||||
|
||||
```yaml
|
||||
zitadel:
|
||||
masterkeySecretName: zitadel-masterkey
|
||||
configSecretName: zitadel-config-secret
|
||||
```
|
||||
|
||||
Create the masterkey secret:
|
||||
|
||||
```bash
|
||||
kubectl create secret generic zitadel-masterkey \
|
||||
--from-literal=masterkey="$(tr -dc A-Za-z0-9 </dev/urandom | head -c 32)"
|
||||
```
|
||||
|
||||
Create a config secret for sensitive values. Save this as `zitadel-config-secret.yaml`:
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: zitadel-config-secret
|
||||
type: Opaque
|
||||
stringData:
|
||||
config.yaml: |
|
||||
Database:
|
||||
Postgres:
|
||||
User:
|
||||
Password: "secure-app-password"
|
||||
Admin:
|
||||
Password: "secure-admin-password"
|
||||
```
|
||||
|
||||
Apply the secret:
|
||||
|
||||
```bash
|
||||
kubectl apply --filename zitadel-config-secret.yaml
|
||||
```
|
||||
|
||||
Reference it in your values:
|
||||
|
||||
```yaml
|
||||
zitadel:
|
||||
configSecretName: zitadel-config-secret
|
||||
configSecretKey: config.yaml
|
||||
```
|
||||
|
||||
## Scaling and Availability
|
||||
|
||||
Zitadel is designed to run as a stateless application, which makes horizontal scaling straightforward. For production deployments, you should run multiple replicas to ensure availability during node failures, deployments, and other disruptions.
|
||||
|
||||
### Replica Count
|
||||
|
||||
Running at least two replicas ensures that your Zitadel deployment remains available if one pod fails or is evicted. For larger deployments with higher traffic, you may want to run more replicas.
|
||||
|
||||
```yaml
|
||||
replicaCount: 2
|
||||
```
|
||||
|
||||
### Autoscaling
|
||||
|
||||
The chart supports the [Horizontal Pod Autoscaler](https://kubernetes.io/docs/tasks/run-application/horizontal-pod-autoscale/) (HPA) to automatically scale the number of Zitadel replicas based on resource utilization. When autoscaling is enabled, the HPA overrides the `replicaCount` value.
|
||||
|
||||
Enable autoscaling with CPU-based scaling:
|
||||
|
||||
```yaml
|
||||
autoscaling:
|
||||
enabled: true
|
||||
minReplicas: 3
|
||||
maxReplicas: 10
|
||||
targetCPU: 80
|
||||
```
|
||||
|
||||
You can also scale based on memory utilization:
|
||||
|
||||
```yaml
|
||||
autoscaling:
|
||||
enabled: true
|
||||
minReplicas: 3
|
||||
maxReplicas: 10
|
||||
targetMemory: 80
|
||||
```
|
||||
|
||||
For more advanced scaling, you can use custom metrics exposed by Zitadel. This requires a metrics server such as [Prometheus](https://prometheus.io/) and a metrics adapter such as [prometheus-adapter](https://github.com/kubernetes-sigs/prometheus-adapter) running in your cluster.
|
||||
|
||||
The following example scales when the average number of goroutines per pod exceeds 150. The `go_goroutines` metric is a good proxy for concurrent load. You should observe your application's baseline to find a suitable value for your workload.
|
||||
|
||||
```yaml
|
||||
autoscaling:
|
||||
enabled: true
|
||||
minReplicas: 3
|
||||
maxReplicas: 10
|
||||
metrics:
|
||||
- type: Pods
|
||||
pods:
|
||||
metric:
|
||||
name: "go_goroutines"
|
||||
target:
|
||||
type: AverageValue
|
||||
averageValue: "150"
|
||||
```
|
||||
|
||||
You can also configure the [scaling behavior](https://kubernetes.io/docs/tasks/run-application/horizontal-pod-autoscale/#configurable-scaling-behavior) to control how quickly the HPA scales up or down:
|
||||
|
||||
```yaml
|
||||
autoscaling:
|
||||
enabled: true
|
||||
minReplicas: 3
|
||||
maxReplicas: 10
|
||||
targetCPU: 80
|
||||
behavior:
|
||||
scaleDown:
|
||||
stabilizationWindowSeconds: 300
|
||||
policies:
|
||||
- type: Percent
|
||||
value: 10
|
||||
periodSeconds: 60
|
||||
scaleUp:
|
||||
stabilizationWindowSeconds: 0
|
||||
policies:
|
||||
- type: Percent
|
||||
value: 100
|
||||
periodSeconds: 15
|
||||
```
|
||||
|
||||
### Pod Disruption Budget
|
||||
|
||||
A [Pod Disruption Budget](https://kubernetes.io/docs/tasks/run-application/configure-pdb/) ensures that a minimum number of pods remain available during voluntary disruptions such as node drains, cluster upgrades, or deployment rollouts. Without a PDB, Kubernetes may evict all your pods simultaneously during maintenance.
|
||||
|
||||
```yaml
|
||||
podDisruptionBudget:
|
||||
enabled: true
|
||||
minAvailable: 1
|
||||
```
|
||||
|
||||
With `minAvailable: 1`, Kubernetes guarantees that at least one Zitadel pod is always running. If you have more replicas, you can increase this value accordingly.
|
||||
|
||||
### Pod Anti-Affinity
|
||||
|
||||
Pod anti-affinity rules tell Kubernetes to schedule Zitadel pods on different nodes. This prevents a single node failure from taking down all your Zitadel replicas.
|
||||
|
||||
```yaml
|
||||
affinity:
|
||||
podAntiAffinity:
|
||||
preferredDuringSchedulingIgnoredDuringExecution:
|
||||
- weight: 100
|
||||
podAffinityTerm:
|
||||
labelSelector:
|
||||
matchExpressions:
|
||||
- key: app.kubernetes.io/name
|
||||
operator: In
|
||||
values:
|
||||
- zitadel
|
||||
topologyKey: kubernetes.io/hostname
|
||||
```
|
||||
|
||||
The `preferredDuringSchedulingIgnoredDuringExecution` rule is a soft preference. Kubernetes will try to spread pods across nodes, but will still schedule pods on the same node if no other nodes are available. For stricter requirements, you can use `requiredDuringSchedulingIgnoredDuringExecution` instead.
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Configuring the Ingress](./ingress) — Set up ingress for Zitadel and Login containers
|
||||
- [Configuring the Database](./database) — Connect to PostgreSQL
|
||||
- [Configuring Observability](./observability) — Collect traces, metrics, and logs
|
||||
- [Operations](./operations) — Learn about upgrades and scaling
|
||||
- [Uninstalling](./uninstalling) — Remove Zitadel from your cluster
|
||||
@@ -0,0 +1,123 @@
|
||||
---
|
||||
title: Database
|
||||
sidebar_label: Database
|
||||
---
|
||||
|
||||
|
||||
|
||||
## PostgreSQL
|
||||
|
||||
Zitadel requires PostgreSQL 14 or later. The chart supports multiple ways to connect to PostgreSQL depending on your security requirements.
|
||||
|
||||
### Connecting Without TLS (Not Recommended)
|
||||
|
||||
This method connects to PostgreSQL without encryption. Only use this for testing or when the database is on a private network with no risk of interception.
|
||||
|
||||
```yaml
|
||||
zitadel:
|
||||
configmapConfig:
|
||||
Database:
|
||||
Postgres:
|
||||
Host: "postgres.database.svc.cluster.local"
|
||||
Port: 5432
|
||||
Database: "zitadel"
|
||||
User:
|
||||
Username: "zitadel"
|
||||
Password: "your-password"
|
||||
SSL:
|
||||
Mode: "disable"
|
||||
Admin:
|
||||
Username: "postgres"
|
||||
Password: "your-admin-password"
|
||||
SSL:
|
||||
Mode: "disable"
|
||||
```
|
||||
|
||||
The `SSL.Mode: "disable"` setting turns off TLS entirely. Traffic between Zitadel and PostgreSQL is unencrypted.
|
||||
|
||||
### Connecting with Credentials and TLS
|
||||
|
||||
This method connects to PostgreSQL with TLS encryption but without certificate verification. Use this when you trust the network path but want encryption in transit.
|
||||
|
||||
```yaml
|
||||
zitadel:
|
||||
configSecretName: zitadel-db-credentials
|
||||
configmapConfig:
|
||||
Database:
|
||||
Postgres:
|
||||
Host: "postgres.database.svc.cluster.local"
|
||||
Port: 5432
|
||||
Database: "zitadel"
|
||||
User:
|
||||
Username: "zitadel"
|
||||
SSL:
|
||||
Mode: "require"
|
||||
Admin:
|
||||
Username: "postgres"
|
||||
SSL:
|
||||
Mode: "require"
|
||||
```
|
||||
|
||||
Create a secret for the database passwords:
|
||||
|
||||
```bash
|
||||
kubectl create secret generic zitadel-db-credentials \
|
||||
--from-literal=config.yaml="
|
||||
Database:
|
||||
Postgres:
|
||||
User:
|
||||
Password: your-secure-app-password
|
||||
Admin:
|
||||
Password: your-secure-admin-password
|
||||
"
|
||||
```
|
||||
|
||||
The `SSL.Mode: "require"` setting enforces TLS but does not verify the server certificate. This protects against passive eavesdropping but not against man-in-the-middle attacks.
|
||||
|
||||
### Connecting with Certificates
|
||||
|
||||
This method connects to PostgreSQL with full TLS verification using certificates. Use this for production deployments where you need to verify the database server's identity.
|
||||
|
||||
```yaml
|
||||
zitadel:
|
||||
configSecretName: zitadel-db-credentials
|
||||
dbSslCaCrt: "ca.crt"
|
||||
dbSslCaCrtSecret: "postgres-ca-cert"
|
||||
configmapConfig:
|
||||
Database:
|
||||
Postgres:
|
||||
Host: "postgres.database.svc.cluster.local"
|
||||
Port: 5432
|
||||
Database: "zitadel"
|
||||
User:
|
||||
Username: "zitadel"
|
||||
SSL:
|
||||
Mode: "verify-full"
|
||||
Admin:
|
||||
Username: "postgres"
|
||||
SSL:
|
||||
Mode: "verify-full"
|
||||
```
|
||||
|
||||
Create a secret containing the CA certificate:
|
||||
|
||||
```bash
|
||||
kubectl create secret generic postgres-ca-cert \
|
||||
--from-file=ca.crt=/path/to/your/ca-certificate.crt
|
||||
```
|
||||
|
||||
Create a secret for the database passwords:
|
||||
|
||||
```bash
|
||||
kubectl create secret generic zitadel-db-credentials \
|
||||
--from-literal=config.yaml="
|
||||
Database:
|
||||
Postgres:
|
||||
User:
|
||||
Password: your-secure-app-password
|
||||
Admin:
|
||||
Password: your-secure-admin-password
|
||||
"
|
||||
```
|
||||
|
||||
The `SSL.Mode: "verify-full"` setting enforces TLS and verifies that the server certificate is signed by the CA and that the server hostname matches the certificate. This provides full protection against eavesdropping and man-in-the-middle attacks. The `dbSslCaCrtSecret` references the Kubernetes Secret containing the CA certificate, and `dbSslCaCrt` specifies the key within that secret.
|
||||
@@ -0,0 +1,62 @@
|
||||
---
|
||||
title: Kubernetes
|
||||
sidebar_label: Overview
|
||||
---
|
||||
|
||||
|
||||
|
||||
Deploy Zitadel, the open-source identity and access management platform, on Kubernetes using Helm.
|
||||
|
||||
## What's in the Chart
|
||||
|
||||
By default, this chart installs a highly available Zitadel deployment. The chart deploys:
|
||||
|
||||
- A Zitadel **init job** that prepares the database schema
|
||||
- A Zitadel **setup job** that bootstraps the first instance and admin user
|
||||
- A Zitadel **deployment** with configurable replicas for high availability
|
||||
|
||||
The execution order is orchestrated using Helm hooks on installations and upgrades.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
Zitadel is deployed as two separate containers within your Kubernetes cluster. The main Zitadel container handles all API traffic, the console UI, and OIDC endpoints, while the Login container serves the login UI at the `/ui/v2/login` path. Both containers are stateless and can be scaled horizontally to handle increased load.
|
||||
|
||||
Traffic enters the cluster through an ingress controller such as Traefik or NGINX. The chart creates two separate Ingress resources, one for each container, both typically sharing the same domain and TLS certificate.
|
||||
|
||||
TLS termination happens at the ingress controller, which forwards unencrypted HTTP/2 traffic to the pods. cert-manager or the ingress controller's built-in ACME support can be used to automatically obtain and renew TLS certificates from Let's Encrypt.
|
||||
|
||||
Zitadel stores all persistent data in PostgreSQL. The database holds user accounts, organizations, projects, applications, sessions, and all other identity data. Zitadel uses two database connections: an admin connection for schema migrations during startup and an application connection for normal operations. Both connections should use TLS in production environments.
|
||||
|
||||
For observability, Zitadel pushes traces to an OpenTelemetry Collector using the OTLP protocol over gRPC. Prometheus scrapes metrics from the `/debug/metrics` endpoint on the Zitadel pods. Both containers write structured logs to stdout, which must be collected by a log collector such as Fluent Bit or Vector. The Login container does not support tracing or metrics, only log collection. All observability data can be forwarded to a unified backend such as Grafana, OpenObserve, or your preferred monitoring stack.
|
||||
|
||||
## Prerequisites at a Glance
|
||||
|
||||
| Requirement | Minimum Version |
|
||||
|-------------|-----------------|
|
||||
| Kubernetes | 1.30+ |
|
||||
| Helm | 3.x or 4.x |
|
||||
| kubectl | 1.30+ |
|
||||
| PostgreSQL | 14+ |
|
||||
|
||||
You also need:
|
||||
- An ingress controller (Traefik, NGINX, or cloud-native)
|
||||
- A domain with DNS configured
|
||||
- TLS certificates (via cert-manager, ACME, or manual)
|
||||
|
||||
## Quick Links
|
||||
|
||||
| Resource | Link |
|
||||
|----------|------|
|
||||
| Helm Chart Repository | [charts.zitadel.com](https://charts.zitadel.com) |
|
||||
| Source Code | [github.com/zitadel/zitadel-charts](https://github.com/zitadel/zitadel-charts) |
|
||||
| Zitadel Documentation | [zitadel.com/docs](https://zitadel.com/docs) |
|
||||
| Artifact Hub | [artifacthub.io/packages/helm/zitadel/zitadel](https://artifacthub.io/packages/helm/zitadel/zitadel) |
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Installation](/self-hosting/deploy/kubernetes/installation) — Get Zitadel running
|
||||
- [Configuration](/self-hosting/deploy/kubernetes/configuration) — Customize your deployment
|
||||
- [Configuring the Ingress](/self-hosting/deploy/kubernetes/ingress) — Set up ingress for Zitadel and Login containers
|
||||
- [Configuring the Database](/self-hosting/deploy/kubernetes/database) — Connect to PostgreSQL
|
||||
- [Operations](/self-hosting/deploy/kubernetes/operations) — Upgrades and scaling
|
||||
- [Uninstalling](/self-hosting/deploy/kubernetes/uninstalling) — Remove Zitadel from your cluster
|
||||
@@ -0,0 +1,59 @@
|
||||
---
|
||||
title: Ingress
|
||||
sidebar_label: Ingress
|
||||
---
|
||||
|
||||
|
||||
|
||||
The chart creates standard [Kubernetes Ingress](https://kubernetes.io/docs/concepts/services-networking/ingress/) resources. There are separate Ingress resources for the Zitadel container and the Login container.
|
||||
|
||||
TLS must be terminated at the ingress controller; the Zitadel and Login containers do not handle TLS termination. Both ingresses typically use the same domain and TLS certificate. The Login ingress serves the `/ui/v2/login` path while the Zitadel ingress serves everything else.
|
||||
|
||||
## Zitadel Ingress
|
||||
|
||||
The Zitadel ingress handles all API traffic, the console UI, and OIDC endpoints. The example below uses [Traefik](https://traefik.io/traefik/) as the ingress controller:
|
||||
|
||||
```yaml
|
||||
ingress:
|
||||
enabled: true
|
||||
className: traefik
|
||||
annotations:
|
||||
traefik.ingress.kubernetes.io/router.entrypoints: websecure
|
||||
traefik.ingress.kubernetes.io/router.tls: "true"
|
||||
hosts:
|
||||
- host: zitadel.example.com
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
tls:
|
||||
- secretName: zitadel-tls
|
||||
hosts:
|
||||
- zitadel.example.com
|
||||
```
|
||||
|
||||
The `tls.secretName` references a Kubernetes Secret containing your TLS certificate. You can create this secret manually, or have your ingress controller issue certificates automatically. For automatic certificate issuance with [Traefik ACME](https://doc.traefik.io/traefik/https/acme/), add the `traefik.ingress.kubernetes.io/router.tls.certresolver` annotation. For [cert-manager](https://cert-manager.io/), add the `cert-manager.io/cluster-issuer` annotation.
|
||||
|
||||
## Login Ingress
|
||||
|
||||
The Login ingress handles the login UI. It must be configured separately:
|
||||
|
||||
```yaml
|
||||
login:
|
||||
ingress:
|
||||
enabled: true
|
||||
className: traefik
|
||||
annotations:
|
||||
traefik.ingress.kubernetes.io/router.entrypoints: websecure
|
||||
traefik.ingress.kubernetes.io/router.tls: "true"
|
||||
hosts:
|
||||
- host: zitadel.example.com
|
||||
paths:
|
||||
- path: /ui/v2/login
|
||||
pathType: Prefix
|
||||
tls:
|
||||
- secretName: zitadel-tls
|
||||
hosts:
|
||||
- zitadel.example.com
|
||||
```
|
||||
|
||||
The `tls.secretName` references a Kubernetes Secret containing your TLS certificate. You can use the same secret as the Zitadel ingress since both use the same domain. For automatic certificate issuance with [Traefik ACME](https://doc.traefik.io/traefik/https/acme/), add the `traefik.ingress.kubernetes.io/router.tls.certresolver` annotation. For [cert-manager](https://cert-manager.io/), add the `cert-manager.io/cluster-issuer` annotation.
|
||||
@@ -0,0 +1,225 @@
|
||||
---
|
||||
title: Installation
|
||||
sidebar_label: Installation
|
||||
---
|
||||
|
||||
|
||||
|
||||
This guide walks you through deploying Zitadel on Kubernetes using Helm.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
You need a running Kubernetes cluster. Supported versions:
|
||||
|
||||
- Kubernetes 1.30.13+
|
||||
- Kubernetes 1.31.9+
|
||||
- Kubernetes 1.32.5+
|
||||
- Kubernetes 1.33.1+
|
||||
|
||||
### An Ingress Controller
|
||||
|
||||
Zitadel requires an ingress controller to route traffic to the Zitadel and Login pods. The chart creates standard Kubernetes Ingress resources that work with any ingress controller, including Traefik, NGINX, and cloud-native options like AWS ALB or GCP Cloud Load Balancer.
|
||||
|
||||
TLS must be terminated at the ingress controller. The Zitadel and Login containers do not handle TLS termination. Your ingress controller is responsible for obtaining and renewing TLS certificates (via ACME, cert-manager, or manually), terminating TLS connections, and forwarding traffic to the Zitadel pods.
|
||||
|
||||
Without an ingress controller and valid TLS certificates, Zitadel will not work. The chart configures the ingress to use HTTP/2 for communication between the ingress controller and Zitadel pods.
|
||||
|
||||
### A PostgreSQL Database
|
||||
|
||||
Zitadel requires PostgreSQL 14 or later. You can use a managed PostgreSQL service such as Amazon RDS, Google Cloud SQL, or Azure Database for PostgreSQL, or run PostgreSQL in your cluster using a Helm chart or operator. The database must be accessible from the Zitadel pods.
|
||||
|
||||
Zitadel uses two database users: an admin user for schema migrations and an application user for normal operations. See the [Database](./database) page for connection options.
|
||||
|
||||
### Helm
|
||||
|
||||
Install Helm 3.x or 4.x following the [official instructions](https://helm.sh/docs/intro/install/). Verify your installation:
|
||||
|
||||
```bash
|
||||
helm version
|
||||
```
|
||||
|
||||
### kubectl
|
||||
|
||||
Install kubectl following the [Kubernetes documentation](https://kubernetes.io/docs/tasks/tools/). Verify connectivity to your cluster:
|
||||
|
||||
```bash
|
||||
kubectl cluster-info
|
||||
```
|
||||
|
||||
## Add the Helm Repository
|
||||
|
||||
Add the Zitadel Helm repository:
|
||||
|
||||
```bash
|
||||
helm repo add zitadel https://charts.zitadel.com
|
||||
```
|
||||
|
||||
Update your local repository cache:
|
||||
|
||||
```bash
|
||||
helm repo update
|
||||
```
|
||||
|
||||
Verify the repository was added:
|
||||
|
||||
```bash
|
||||
helm search repo zitadel
|
||||
```
|
||||
|
||||
You should see output similar to:
|
||||
|
||||
```
|
||||
NAME CHART VERSION APP VERSION DESCRIPTION
|
||||
zitadel/zitadel 9.x.x v4.x.x A Helm chart for Zitadel
|
||||
```
|
||||
|
||||
## Install
|
||||
|
||||
### Step 1: Create Secrets
|
||||
|
||||
Create the masterkey secret:
|
||||
|
||||
```bash
|
||||
kubectl create secret generic zitadel-masterkey \
|
||||
--from-literal=masterkey="$(tr -dc A-Za-z0-9 </dev/urandom | head -c 32)"
|
||||
```
|
||||
|
||||
Create the database credentials secret:
|
||||
|
||||
```bash
|
||||
kubectl create secret generic zitadel-db-credentials \
|
||||
--from-literal=config.yaml="
|
||||
Database:
|
||||
Postgres:
|
||||
User:
|
||||
Password: your-secure-app-password
|
||||
Admin:
|
||||
Password: your-secure-admin-password
|
||||
"
|
||||
```
|
||||
|
||||
### Step 2: Configure Values
|
||||
|
||||
Create a `values.yaml` file. Replace `zitadel.example.com` with your actual domain:
|
||||
|
||||
```yaml
|
||||
replicaCount: 2
|
||||
|
||||
zitadel:
|
||||
masterkeySecretName: zitadel-masterkey
|
||||
configSecretName: zitadel-db-credentials
|
||||
configmapConfig:
|
||||
ExternalDomain: "zitadel.example.com"
|
||||
ExternalSecure: true
|
||||
ExternalPort: 443
|
||||
TLS:
|
||||
Enabled: false
|
||||
Database:
|
||||
Postgres:
|
||||
Host: "postgres.database.svc.cluster.local"
|
||||
Port: 5432
|
||||
Database: "zitadel"
|
||||
User:
|
||||
Username: "zitadel"
|
||||
SSL:
|
||||
Mode: "verify-full"
|
||||
Admin:
|
||||
Username: "postgres"
|
||||
SSL:
|
||||
Mode: "verify-full"
|
||||
FirstInstance:
|
||||
Org:
|
||||
Human:
|
||||
UserName: "admin"
|
||||
FirstName: "Zitadel"
|
||||
LastName: "Admin"
|
||||
Email: "[email protected]"
|
||||
Password: "YourSecurePassword123!"
|
||||
PasswordChangeRequired: true
|
||||
|
||||
ingress:
|
||||
enabled: true
|
||||
className: traefik
|
||||
annotations:
|
||||
traefik.ingress.kubernetes.io/router.entrypoints: websecure
|
||||
traefik.ingress.kubernetes.io/router.tls: "true"
|
||||
traefik.ingress.kubernetes.io/router.tls.certresolver: letsencrypt
|
||||
hosts:
|
||||
- host: zitadel.example.com
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
tls:
|
||||
- secretName: zitadel-tls
|
||||
hosts:
|
||||
- zitadel.example.com
|
||||
|
||||
podDisruptionBudget:
|
||||
enabled: true
|
||||
minAvailable: 1
|
||||
```
|
||||
|
||||
### Step 3: Install the Chart
|
||||
|
||||
```bash
|
||||
helm install zitadel zitadel/zitadel --values values.yaml
|
||||
```
|
||||
|
||||
Install Zitadel:
|
||||
|
||||
```bash
|
||||
helm install my-zitadel zitadel/zitadel --values values.yaml
|
||||
```
|
||||
|
||||
## Verify Deployment
|
||||
|
||||
Watch the pods come up:
|
||||
|
||||
```bash
|
||||
kubectl get pods --watch
|
||||
```
|
||||
|
||||
You should see:
|
||||
1. `my-zitadel-init-*` job completes
|
||||
2. `my-zitadel-setup-*` job completes
|
||||
3. `my-zitadel-*` deployment pods become Ready
|
||||
|
||||
Check the deployment status:
|
||||
|
||||
```bash
|
||||
helm status my-zitadel
|
||||
```
|
||||
|
||||
Verify all pods are running:
|
||||
|
||||
```bash
|
||||
kubectl get pods --selector app.kubernetes.io/name=zitadel
|
||||
```
|
||||
|
||||
You should see output similar to:
|
||||
|
||||
```
|
||||
NAME READY STATUS RESTARTS AGE
|
||||
my-zitadel-xxxxxxxxx-xxxxx 1/1 Running 0 5m
|
||||
my-zitadel-xxxxxxxxx-xxxxx 1/1 Running 0 5m
|
||||
```
|
||||
|
||||
## Access the UI
|
||||
|
||||
Access Zitadel at:
|
||||
|
||||
```
|
||||
https://zitadel.example.com/ui/console
|
||||
```
|
||||
|
||||
## First Login
|
||||
|
||||
Access the console at `https://zitadel.example.com/ui/console` and log in with the admin credentials configured in your `values.yaml`.
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Configuration](./configuration) — Customize Zitadel settings
|
||||
- [Configuring the Ingress](./ingress) — Set up ingress for Zitadel and Login containers
|
||||
- [Configuring the Database](./database) — Connect to PostgreSQL
|
||||
- [Operations](./operations) — Learn about upgrades and scaling
|
||||
- [Uninstalling](./uninstalling) — Remove Zitadel from your cluster
|
||||
@@ -0,0 +1,82 @@
|
||||
---
|
||||
title: Observability
|
||||
sidebar_label: Observability
|
||||
---
|
||||
|
||||
|
||||
|
||||
This guide covers how to collect traces, metrics, and logs from the Zitadel and Login containers.
|
||||
|
||||
## Zitadel Container
|
||||
|
||||
### Traces
|
||||
|
||||
Zitadel can push traces to an [OpenTelemetry Collector](https://opentelemetry.io/docs/collector/) using the [OTLP protocol](https://opentelemetry.io/docs/specs/otlp/) over gRPC:
|
||||
|
||||
```yaml
|
||||
env:
|
||||
- name: ZITADEL_TRACING_TYPE
|
||||
value: "otel"
|
||||
- name: ZITADEL_TRACING_ENDPOINT
|
||||
value: "otelcol-opentelemetry-collector.monitoring.svc.cluster.local:4317"
|
||||
- name: ZITADEL_TRACING_SERVICENAME
|
||||
value: "zitadel"
|
||||
```
|
||||
|
||||
The `ZITADEL_TRACING_ENDPOINT` should point to your OpenTelemetry Collector's gRPC receiver, typically listening on port 4317. The collector can then forward traces to your preferred backend such as [Jaeger](https://www.jaegertracing.io/), [Grafana Tempo](https://grafana.com/oss/tempo/), or [OpenObserve](https://openobserve.ai/).
|
||||
|
||||
You can deploy an OpenTelemetry Collector in your cluster using the [OpenTelemetry Operator](https://github.com/open-telemetry/opentelemetry-operator) or the [OpenTelemetry Collector Helm chart](https://github.com/open-telemetry/opentelemetry-helm-charts/tree/main/charts/opentelemetry-collector).
|
||||
|
||||
### Metrics
|
||||
|
||||
Zitadel exposes [Prometheus](https://prometheus.io/) metrics at `/debug/metrics`. Metrics must be scraped by Prometheus. Pushing metrics is not supported.
|
||||
|
||||
Enable the metrics endpoint:
|
||||
|
||||
```yaml
|
||||
metrics:
|
||||
enabled: true
|
||||
serviceMonitor:
|
||||
enabled: false
|
||||
```
|
||||
|
||||
Set `serviceMonitor.enabled` to `true` if you are using the [Prometheus Operator](https://prometheus-operator.dev/). This creates a [ServiceMonitor](https://prometheus-operator.dev/docs/operator/api/#monitoring.coreos.com/v1.ServiceMonitor) resource that configures Prometheus to scrape the Zitadel pods automatically.
|
||||
|
||||
For standard Prometheus without the Operator, add scrape annotations to the pods:
|
||||
|
||||
```yaml
|
||||
podAnnotations:
|
||||
prometheus.io/scrape: "true"
|
||||
prometheus.io/port: "8080"
|
||||
prometheus.io/path: "/debug/metrics"
|
||||
```
|
||||
|
||||
Your Prometheus configuration must include a [scrape job](https://prometheus.io/docs/prometheus/latest/configuration/configuration/#scrape_config) that discovers pods with these annotations. If you are using [kube-prometheus-stack](https://github.com/prometheus-community/helm-charts/tree/main/charts/kube-prometheus-stack), pod annotation discovery is typically enabled by default.
|
||||
|
||||
### Logs
|
||||
|
||||
Zitadel writes structured JSON logs to stdout. Logs must be collected by a log collector running in your cluster. Pushing logs is not supported.
|
||||
|
||||
Common log collectors include:
|
||||
- [Fluent Bit](https://fluentbit.io/) — Lightweight log processor and forwarder
|
||||
- [Vector](https://vector.dev/) — High-performance observability data pipeline
|
||||
- [Fluentd](https://www.fluentd.org/) — Unified logging layer
|
||||
- [Promtail](https://grafana.com/docs/loki/latest/clients/promtail/) — Log collector for Grafana Loki
|
||||
|
||||
Configure your log collector to tail container logs from the Zitadel pods and forward them to your preferred backend such as [Grafana Loki](https://grafana.com/oss/loki/), [Elasticsearch](https://www.elastic.co/elasticsearch/), or [OpenObserve](https://openobserve.ai/).
|
||||
|
||||
## Login Container
|
||||
|
||||
### Traces
|
||||
|
||||
The login container does not support tracing. Traces cannot be collected from the login container.
|
||||
|
||||
### Metrics
|
||||
|
||||
The login container does not expose metrics. Prometheus metrics are not available for the login container.
|
||||
|
||||
### Logs
|
||||
|
||||
The login container writes logs to stdout. Logs must be collected by a log collector running in your cluster. Pushing logs is not supported.
|
||||
|
||||
Configure your log collector to tail container logs from the login pods. The login container runs as a separate deployment, so ensure your log collector is configured to collect from all pods in the namespace or specifically from pods with the login container labels.
|
||||
@@ -0,0 +1,148 @@
|
||||
---
|
||||
title: Operations
|
||||
sidebar_label: Operations
|
||||
---
|
||||
|
||||
|
||||
|
||||
This guide covers day-2 operations for Zitadel on Kubernetes.
|
||||
|
||||
## Upgrades
|
||||
|
||||
### General Upgrade Process
|
||||
|
||||
1. Review the release notes for your target version
|
||||
2. Back up your database
|
||||
3. Update your `values.yaml` with any required changes
|
||||
4. Upgrade the Helm release:
|
||||
|
||||
```bash
|
||||
helm repo update
|
||||
helm upgrade my-zitadel zitadel/zitadel --values values.yaml --version <target-version>
|
||||
```
|
||||
|
||||
5. Monitor the upgrade. Watch the pods:
|
||||
|
||||
```bash
|
||||
kubectl get pods --watch
|
||||
```
|
||||
|
||||
Check the Helm release status:
|
||||
|
||||
```bash
|
||||
helm status my-zitadel
|
||||
```
|
||||
|
||||
## Scaling
|
||||
|
||||
### Manual Scaling
|
||||
|
||||
Adjust the replica count in your values:
|
||||
|
||||
```yaml
|
||||
replicaCount: 3
|
||||
```
|
||||
|
||||
Or scale directly:
|
||||
|
||||
```bash
|
||||
kubectl scale deployment my-zitadel --replicas=3
|
||||
```
|
||||
|
||||
### Horizontal Pod Autoscaler
|
||||
|
||||
Enable HPA for automatic scaling based on resource utilization:
|
||||
|
||||
```yaml
|
||||
zitadel:
|
||||
autoscaling:
|
||||
enabled: true
|
||||
minReplicas: 2
|
||||
maxReplicas: 10
|
||||
targetCPUUtilizationPercentage: 80
|
||||
targetMemoryUtilizationPercentage: 80
|
||||
```
|
||||
|
||||
This creates an HPA that:
|
||||
- Maintains at least 2 replicas
|
||||
- Scales up to 10 replicas
|
||||
- Targets 80% CPU and memory utilization
|
||||
|
||||
View HPA status:
|
||||
|
||||
```bash
|
||||
kubectl get hpa
|
||||
```
|
||||
|
||||
Get detailed HPA information:
|
||||
|
||||
```bash
|
||||
kubectl describe hpa my-zitadel
|
||||
```
|
||||
|
||||
### Resource Requests and Limits
|
||||
|
||||
Configure appropriate resource allocations:
|
||||
|
||||
```yaml
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 256Mi
|
||||
limits:
|
||||
cpu: 1000m
|
||||
memory: 1Gi
|
||||
```
|
||||
|
||||
**Recommendations by deployment size:**
|
||||
|
||||
| Size | CPU Request | CPU Limit | Memory Request | Memory Limit |
|
||||
|------|-------------|-----------|----------------|--------------|
|
||||
| Small (dev) | 100m | 500m | 256Mi | 512Mi |
|
||||
| Medium | 250m | 1000m | 512Mi | 1Gi |
|
||||
| Large | 500m | 2000m | 1Gi | 2Gi |
|
||||
|
||||
### Pod Disruption Budget
|
||||
|
||||
Ensure availability during voluntary disruptions by using `minAvailable`:
|
||||
|
||||
```yaml
|
||||
podDisruptionBudget:
|
||||
enabled: true
|
||||
minAvailable: 1
|
||||
```
|
||||
|
||||
Alternatively, use `maxUnavailable`:
|
||||
|
||||
```yaml
|
||||
podDisruptionBudget:
|
||||
enabled: true
|
||||
maxUnavailable: 1
|
||||
```
|
||||
|
||||
This ensures at least one pod remains available during node drains, upgrades, or other voluntary disruptions.
|
||||
|
||||
### Database Scaling Considerations
|
||||
|
||||
When scaling Zitadel horizontally, ensure your PostgreSQL database can handle the increased connection load:
|
||||
- Each Zitadel pod opens multiple connections
|
||||
- Consider using PgBouncer for connection pooling
|
||||
- Monitor database connection usage
|
||||
|
||||
Example connection pooling setup with PgBouncer:
|
||||
|
||||
```yaml
|
||||
zitadel:
|
||||
configmapConfig:
|
||||
Database:
|
||||
Postgres:
|
||||
Host: "pgbouncer.database.svc.cluster.local"
|
||||
Port: 6432
|
||||
MaxOpenConns: 20
|
||||
MaxIdleConns: 10
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Configuration](./configuration) — Review all configuration options
|
||||
- [Uninstalling](./uninstalling) — Remove Zitadel from your cluster
|
||||
@@ -0,0 +1,59 @@
|
||||
---
|
||||
title: Uninstalling
|
||||
sidebar_label: Uninstalling
|
||||
---
|
||||
|
||||
|
||||
|
||||
This guide covers how to remove Zitadel from your Kubernetes cluster.
|
||||
|
||||
## Uninstall the Helm Release
|
||||
|
||||
```bash
|
||||
helm uninstall my-zitadel
|
||||
```
|
||||
|
||||
## Clean Up Helm Hooks
|
||||
|
||||
> ⚠️ **Important:** The Zitadel chart uses Helm hooks, which are [not garbage collected by helm uninstall](https://helm.sh/docs/topics/charts_hooks/#hook-resources-are-not-managed-with-corresponding-releases).
|
||||
|
||||
Remove hook resources manually:
|
||||
|
||||
```bash
|
||||
for k8sresourcetype in job configmap secret rolebinding role serviceaccount; do
|
||||
kubectl delete $k8sresourcetype \
|
||||
--selector app.kubernetes.io/name=zitadel,app.kubernetes.io/managed-by=Helm
|
||||
done
|
||||
```
|
||||
|
||||
## Complete Namespace Teardown
|
||||
|
||||
To remove everything including the namespace and any Persistent Volume Claims:
|
||||
|
||||
```bash
|
||||
kubectl delete namespace zitadel
|
||||
```
|
||||
|
||||
> ⚠️ **Warning:** This permanently deletes all data in the namespace, including any PVCs. Ensure you have backups if needed.
|
||||
|
||||
## Verify Removal
|
||||
|
||||
Confirm all resources have been removed.
|
||||
|
||||
Check for remaining Zitadel resources:
|
||||
|
||||
```bash
|
||||
kubectl get all --selector app.kubernetes.io/name=zitadel
|
||||
```
|
||||
|
||||
Check for remaining secrets:
|
||||
|
||||
```bash
|
||||
kubectl get secrets --selector app.kubernetes.io/name=zitadel
|
||||
```
|
||||
|
||||
If you did not delete the namespace, check for remaining PVCs:
|
||||
|
||||
```bash
|
||||
kubectl get pvc --selector app.kubernetes.io/name=zitadel
|
||||
```
|
||||
@@ -739,7 +739,24 @@ export const guidesSidebar: readonly SidebarItem[] = [
|
||||
"self-hosting/deploy/macos",
|
||||
"self-hosting/deploy/devcontainer",
|
||||
"self-hosting/deploy/compose",
|
||||
"self-hosting/deploy/kubernetes",
|
||||
{
|
||||
type: "category",
|
||||
label: "Kubernetes",
|
||||
collapsed: true,
|
||||
link: {
|
||||
type: "doc",
|
||||
id: "self-hosting/deploy/kubernetes/index",
|
||||
},
|
||||
items: [
|
||||
"self-hosting/deploy/kubernetes/installation",
|
||||
"self-hosting/deploy/kubernetes/configuration",
|
||||
"self-hosting/deploy/kubernetes/ingress",
|
||||
"self-hosting/deploy/kubernetes/database",
|
||||
"self-hosting/deploy/kubernetes/observability",
|
||||
"self-hosting/deploy/kubernetes/operations",
|
||||
"self-hosting/deploy/kubernetes/uninstalling",
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "category",
|
||||
label: "Manage",
|
||||
|
||||
@@ -50,15 +50,15 @@ function filterVersions(tags) {
|
||||
const versions = tags
|
||||
.map(t => t.name)
|
||||
.filter(v => {
|
||||
const valid = semver.valid(v);
|
||||
if (!valid) return false;
|
||||
// Strict cutoff: Do not fetch or build anything older (including that version)
|
||||
return semver.gt(v, CUTOFF);
|
||||
const valid = semver.valid(v);
|
||||
if (!valid) return false;
|
||||
// Strict cutoff: Do not fetch or build anything older (including that version)
|
||||
return semver.gt(v, CUTOFF);
|
||||
})
|
||||
.sort((a, b) => semver.rcompare(a, b));
|
||||
|
||||
console.log(`Found ${versions.length} versions matching criteria.`);
|
||||
|
||||
|
||||
const groups = new Map();
|
||||
for (const v of versions) {
|
||||
const majorMinor = `${semver.major(v)}.${semver.minor(v)}`;
|
||||
@@ -88,7 +88,7 @@ function getFallbackSource() {
|
||||
// Fallback for local dev if git is available
|
||||
try {
|
||||
const branch = execSync('git branch --show-current').toString().trim();
|
||||
if (branch) return branch;
|
||||
// if (branch) return branch; // DISABLED: Local branch might not be pushed to remote, causing 404
|
||||
} catch (e) {
|
||||
// Ignore git errors
|
||||
}
|
||||
@@ -102,7 +102,7 @@ async function downloadVersion(tag, sourceRef) {
|
||||
const isBranch = sourceRef === 'main' || sourceRef === 'master' || !sourceRef.startsWith('v');
|
||||
const typeSegment = isBranch ? 'heads' : 'tags';
|
||||
const url = `https://github.com/${REPO}/archive/refs/${typeSegment}/${sourceRef}.tar.gz`;
|
||||
|
||||
|
||||
const tempDir = join(ROOT_DIR, `.temp/${tag}`); // Extract to tag-specific temp to avoid collisions
|
||||
fs.mkdirSync(tempDir, { recursive: true });
|
||||
|
||||
@@ -111,110 +111,66 @@ async function downloadVersion(tag, sourceRef) {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`Failed to download ${url}: ${res.statusText}`);
|
||||
|
||||
const tarArgs = [
|
||||
const tarArgsWildcard = [
|
||||
'-xz',
|
||||
'-C', tempDir,
|
||||
`--strip-components=1`,
|
||||
`zitadel-${sourceRef.replace(/\//g, '-')}/apps/docs/content`, // GitHub archive naming usually matches ref name with slashes replaced? No, usually zitadel-ref. For tags it might vary.
|
||||
// Actually, GitHub archives top folder is usually `repo-ref`.
|
||||
// For `v4.10.0` -> `zitadel-4.10.0`. For `fuma-docs` -> `zitadel-fuma-docs`.
|
||||
// We should probably rely on wildcard or just strict assumption.
|
||||
// Let's use a wildcard for the top directory since we strip it anyway?
|
||||
// wait, --strip-components=1 removes the top level folder, whatever it is.
|
||||
// BUT we are explicitly listing paths INSIDE that folder to extract.
|
||||
// `tar` requires separate arguments for paths.
|
||||
// If we don't know the exact top folder name, we can't restrict extraction efficiently without wildcards.
|
||||
// BUT standard `tar` doesn't always support wildcards in extraction list nicely.
|
||||
// Strategy: Extract the whole thing? No, too big.
|
||||
// Solution: The top folder name is predictable.
|
||||
// Branch: `zitadel-<branch-name>`
|
||||
// Tag: `zitadel-<tag-name>` (usually without 'v' if tag has 'v'? No, usually matches tag exactly).
|
||||
// Let's refine the top folder guess.
|
||||
'--strip-components=1'
|
||||
];
|
||||
|
||||
// GitHub archive folder name logic:
|
||||
// Refs/heads/fuma-docs -> zitadel-fuma-docs
|
||||
// Refs/tags/v4.10.0 -> zitadel-4.10.0 (often strips 'v'?) OR zitadel-v4.10.0?
|
||||
// It's inconsistent.
|
||||
// Better approach: Download and list first? Or extract everything and move?
|
||||
// Let's try to extract specific paths but use a wildcard if possible?
|
||||
// Actually, preventing the extract of the whole repo is good.
|
||||
// Let's guess:
|
||||
let topFolder = `zitadel-${sourceRef}`;
|
||||
// Tags often strip 'v' in the folder name if using 'archive/vX.Y.Z.tar.gz' vs 'archive/refs/tags/vX.Y.Z.tar.gz'
|
||||
// using refs/tags/... generally preserves it or uses the tag name.
|
||||
// Let's try to just extract *apps/docs/content* with a wildcard pattern if supported?
|
||||
// `*/apps/docs/content`
|
||||
|
||||
// Update tarArgs to be safer with wildcards if using GNU tar, but macos bsdtar differs.
|
||||
// Safest: Extract all, then filter? Repository is large.
|
||||
// Let's stick to the previous code's assumption but make it dynamic.
|
||||
// Previous code: `zitadel-${MOCK_REF}/...`
|
||||
// If sourceRef is 'v4.10.0', try `zitadel-4.10.0` or `zitadel-v4.10.0`.
|
||||
// Let's just assume `zitadel-${sourceRef}` or `zitadel-${sourceRef.replace(/^v/, '')}`
|
||||
|
||||
// Hack: We can peak at the first file entries? No.
|
||||
// Let's just try to extract `*/apps/docs/content` using --wildcards if on linux (which we are).
|
||||
|
||||
const tarArgsWildcard = [
|
||||
'-xz',
|
||||
'-C', tempDir,
|
||||
'--strip-components=1',
|
||||
'--wildcards',
|
||||
|
||||
// GNU tar (Linux) requires --wildcards for patterns, BSD tar (macOS) does not support it (and uses patterns by default)
|
||||
if (process.platform !== 'darwin') {
|
||||
tarArgsWildcard.push('--wildcards');
|
||||
}
|
||||
|
||||
tarArgsWildcard.push(
|
||||
'*/apps/docs/content',
|
||||
'*/apps/docs/public',
|
||||
'*/cmd/defaults.yaml',
|
||||
'*/cmd/setup/steps.yaml'
|
||||
];
|
||||
);
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
const tar = spawn('tar', tarArgsWildcard);
|
||||
Readable.fromWeb(res.body).pipe(tar.stdin);
|
||||
tar.on('close', (code) => (code === 0 ? resolve() : reject(new Error(`tar exited ${code}`))));
|
||||
tar.stderr.on('data', d => {
|
||||
const msg = d.toString();
|
||||
if (!msg.includes('Not found in archive')) console.error(msg);
|
||||
const msg = d.toString();
|
||||
if (!msg.includes('Not found in archive')) console.error(msg);
|
||||
});
|
||||
});
|
||||
|
||||
// Name normalization logic for destination
|
||||
// If tag is 'v4.10.0', use only 'v4.10' for folder (matches current logic)
|
||||
const versionSlug = `v${semver.major(tag)}.${semver.minor(tag)}`;
|
||||
|
||||
|
||||
const contentDest = join(CONTENT_DIR, versionSlug);
|
||||
const publicDest = join(PUBLIC_DIR, versionSlug);
|
||||
|
||||
fs.mkdirSync(dirname(contentDest), { recursive: true });
|
||||
fs.mkdirSync(dirname(publicDest), { recursive: true });
|
||||
|
||||
|
||||
fs.rmSync(contentDest, { recursive: true, force: true });
|
||||
fs.rmSync(publicDest, { recursive: true, force: true });
|
||||
|
||||
// Move content
|
||||
if (fs.existsSync(join(tempDir, 'apps/docs/content'))) {
|
||||
fs.renameSync(join(tempDir, 'apps/docs/content'), contentDest);
|
||||
fs.renameSync(join(tempDir, 'apps/docs/content'), contentDest);
|
||||
} else {
|
||||
// Fallback: sometimes unzipping structure might differ if wildcards matched differently?
|
||||
// Check if there is only one folder in tempDir?
|
||||
// With strip-components=1 and wildcards, it "should" land in tempDir/apps/docs/content directly
|
||||
// IF the wildcards matched `topfolder/apps/docs/content`.
|
||||
// If `apps/docs/content` is not there, check for `content` root?
|
||||
// Let's verify commonly used structure.
|
||||
if (!fs.existsSync(join(tempDir, 'apps/docs/content'))) {
|
||||
console.warn(`[warn] apps/docs/content not found in archive for ${tag} (ref: ${sourceRef})`);
|
||||
}
|
||||
if (!fs.existsSync(join(tempDir, 'apps/docs/content'))) {
|
||||
console.warn(`[warn] apps/docs/content not found in archive for ${tag} (ref: ${sourceRef})`);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle external files (defaults.yaml etc)
|
||||
const externalDir = join(contentDest, '_external/cmd');
|
||||
fs.mkdirSync(externalDir, { recursive: true });
|
||||
|
||||
|
||||
if (fs.existsSync(join(tempDir, 'cmd/defaults.yaml'))) {
|
||||
fs.cpSync(join(tempDir, 'cmd/defaults.yaml'), join(externalDir, 'defaults.yaml'));
|
||||
fs.cpSync(join(tempDir, 'cmd/defaults.yaml'), join(externalDir, 'defaults.yaml'));
|
||||
}
|
||||
if (fs.existsSync(join(tempDir, 'cmd/setup/steps.yaml'))) {
|
||||
fs.mkdirSync(join(externalDir, 'setup'), { recursive: true });
|
||||
fs.cpSync(join(tempDir, 'cmd/setup/steps.yaml'), join(externalDir, 'setup/steps.yaml'));
|
||||
if (fs.existsSync(join(tempDir, 'cmd/setup/steps.yaml'))) {
|
||||
fs.mkdirSync(join(externalDir, 'setup'), { recursive: true });
|
||||
fs.cpSync(join(tempDir, 'cmd/setup/steps.yaml'), join(externalDir, 'setup/steps.yaml'));
|
||||
}
|
||||
|
||||
if (fs.existsSync(join(tempDir, 'apps/docs/public'))) {
|
||||
@@ -224,266 +180,250 @@ async function downloadVersion(tag, sourceRef) {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
|
||||
|
||||
async function downloadFileContent(tagOrBranch, repoPath) {
|
||||
const url = `https://raw.githubusercontent.com/${REPO}/${tagOrBranch}/${repoPath}`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
// Fallback for some repo structures if needed, or if file doesn't exist in that version
|
||||
return null;
|
||||
}
|
||||
return await res.text();
|
||||
const url = `https://raw.githubusercontent.com/${REPO}/${tagOrBranch}/${repoPath}`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
// Fallback for some repo structures if needed, or if file doesn't exist in that version
|
||||
return null;
|
||||
}
|
||||
return await res.text();
|
||||
}
|
||||
|
||||
async function fixRelativeImports(versionDir, tagOrBranch) {
|
||||
if (!fs.existsSync(versionDir)) return;
|
||||
const files = fs.readdirSync(versionDir, { recursive: true });
|
||||
|
||||
// We'll traverse all files to fix links/imports
|
||||
for (const file of files) {
|
||||
const filePath = join(versionDir, file);
|
||||
if (!fs.statSync(filePath).isFile()) continue;
|
||||
if (!filePath.endsWith('.mdx') && !filePath.endsWith('.md')) continue;
|
||||
if (!fs.existsSync(versionDir)) return;
|
||||
const files = fs.readdirSync(versionDir, { recursive: true });
|
||||
|
||||
let content = fs.readFileSync(filePath, 'utf8');
|
||||
let changed = false;
|
||||
// We'll traverse all files to fix links/imports
|
||||
for (const file of files) {
|
||||
const filePath = join(versionDir, file);
|
||||
if (!fs.statSync(filePath).isFile()) continue;
|
||||
if (!filePath.endsWith('.mdx') && !filePath.endsWith('.md')) continue;
|
||||
|
||||
// Helper to rewrite a single path
|
||||
// Returns null if no change needed, or the new string if changed
|
||||
const rewritePath = (originalRelPath) => {
|
||||
// 1. Resolve where it was originally pointing
|
||||
const versionFolder = path.basename(versionDir);
|
||||
const relativePathInContent = filePath.split(join('content', versionFolder))[1];
|
||||
if (!relativePathInContent) return null; // Should not happen given logic
|
||||
let content = fs.readFileSync(filePath, 'utf8');
|
||||
let changed = false;
|
||||
|
||||
// The original file was at CONTENT_LATEST_DIR + relativePathInContent
|
||||
const originalFilePath = join(CONTENT_LATEST_DIR, relativePathInContent);
|
||||
const originalDir = dirname(originalFilePath);
|
||||
|
||||
// Resolve target
|
||||
// Note: resolve() handles '..' correctly
|
||||
const absoluteTarget = resolve(originalDir, originalRelPath);
|
||||
|
||||
const projectRoot = resolve(ROOT_DIR, '../..'); // Repo root (apps/docs/../..)
|
||||
|
||||
// 2. Determine where this target lives now
|
||||
// Case A: It's in 'public' -> We moved public assets to 'public/<version>'
|
||||
// Check if target starts with ROOT_DIR/public
|
||||
if (absoluteTarget.startsWith(PUBLIC_DIR)) {
|
||||
// It refers to a public asset.
|
||||
// The NEW location of this asset is PUBLIC_DIR/<version>/<rest of path>
|
||||
// But wait, PUBLIC_DIR is 'docs/public'.
|
||||
// In downloadVersion, we move 'zitadel-xxx/docs/public' to 'docs/public/<version>'.
|
||||
// So if original path was 'docs/public/img/foo.png', new path is 'docs/public/<version>/img/foo.png'.
|
||||
|
||||
const relToPublic = absoluteTarget.slice(PUBLIC_DIR.length + 1); // 'img/foo.png'
|
||||
const newTargetAbs = join(PUBLIC_DIR, versionFolder, relToPublic);
|
||||
|
||||
// Now calculate relative path from the NEW file location to this NEW target
|
||||
// New file is at 'filePath'
|
||||
const newRelPath = path.relative(dirname(filePath), newTargetAbs);
|
||||
return newRelPath.split(path.sep).join('/'); // Normalize to forward slashes
|
||||
}
|
||||
|
||||
// Case B: It's in 'docs/content' (linking to another doc)
|
||||
// If it's a relative link to another doc, we usually want to keep it relative
|
||||
// content/<ver>/a.md -> content/<ver>/b.md is same relative relationship as content/a.md -> content/b.md
|
||||
// UNLESS it crosses out of content?
|
||||
|
||||
// Case C: It's external (e.g. cmd/defaults.yaml)
|
||||
// This is what the original code was handling.
|
||||
// If it is NOT in docs/content, and NOT in docs/public, we treat it as external file to download.
|
||||
if (absoluteTarget.startsWith(projectRoot) && !absoluteTarget.startsWith(CONTENT_LATEST_DIR) && !absoluteTarget.startsWith(PublicOrRootPublic(absoluteTarget))) {
|
||||
// External file logic...
|
||||
// For now, let's keep the original logic for downloading defaults.yaml
|
||||
return null; // We handle this in the regex pass below specially or reuse this logic?
|
||||
}
|
||||
// Helper to rewrite a single path
|
||||
// Returns null if no change needed, or the new string if changed
|
||||
const rewritePath = (originalRelPath) => {
|
||||
// 1. Resolve where it was originally pointing
|
||||
const versionFolder = path.basename(versionDir);
|
||||
const relativePathInContent = filePath.split(join('content', versionFolder))[1];
|
||||
if (!relativePathInContent) return null; // Should not happen given logic
|
||||
|
||||
// Handle references to defaults.yaml or setup/steps.yaml specially
|
||||
if (originalRelPath.includes('cmd/defaults.yaml') || originalRelPath.includes('cmd/setup/steps.yaml')) {
|
||||
return null; // Let the external file handlers deal with it
|
||||
}
|
||||
// The original file was at CONTENT_LATEST_DIR + relativePathInContent
|
||||
const originalFilePath = join(CONTENT_LATEST_DIR, relativePathInContent);
|
||||
const originalDir = dirname(originalFilePath);
|
||||
|
||||
// Case D: It's a relative link to, say, components/ or other things in docs/
|
||||
// Original: docs/content/foo.md -> ../components/Bar
|
||||
// New: docs/content/<ver>/foo.md -> ../../components/Bar
|
||||
// We just need to recalculate relative path from NEW file to ORIGINAL target
|
||||
|
||||
// If it's pointing to something in docs/ (but not content/ or public/), like components/
|
||||
if (absoluteTarget.startsWith(ROOT_DIR) && !absoluteTarget.startsWith(CONTENT_LATEST_DIR)) {
|
||||
const newRelPath = path.relative(dirname(filePath), absoluteTarget);
|
||||
return newRelPath.split(path.sep).join('/');
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
// Resolve target
|
||||
// Note: resolve() handles '..' correctly
|
||||
const absoluteTarget = resolve(originalDir, originalRelPath);
|
||||
|
||||
// Helper for detecting public dir properly
|
||||
// The PUBLIC_DIR variable is 'docs/public'. But absoluteTarget might be resolved via 'src'
|
||||
function PublicOrRootPublic(p) {
|
||||
return PUBLIC_DIR;
|
||||
}
|
||||
const projectRoot = resolve(ROOT_DIR, '../..'); // Repo root (apps/docs/../..)
|
||||
|
||||
// --- Replacements ---
|
||||
|
||||
// 1. Imports: import ... from '...'
|
||||
// We capture the path: group 2
|
||||
const importRegex = /(import\s+.*?\s+from\s+['"])([^'"]+)(['"])/g;
|
||||
content = content.replace(importRegex, (match, p1, p2, p3) => {
|
||||
if (!p2.startsWith('.')) return match; // Only relative
|
||||
const rewritten = rewritePath(p2);
|
||||
if (rewritten && rewritten !== p2) {
|
||||
changed = true;
|
||||
return `${p1}${rewritten}${p3}`;
|
||||
}
|
||||
|
||||
// External file handling (legacy logic preserved/adapted)
|
||||
// If rewritePath returned null, maybe it is the "external download" case?
|
||||
// Let's implement the external download check here if rewritePath didn't handle it.
|
||||
// ... (The original logic specifically looked for `../../../../../cmd` etc)
|
||||
// We can incorporate it into rewritePath or do it here.
|
||||
|
||||
// Let's check for the cmd/defaults.yaml case explicitly if rewritePath failed
|
||||
if (p2.includes('/cmd/')) {
|
||||
// Re-use logic for downloading external files?
|
||||
// It's cleaner to put it in rewritePath, but I need async. replace does not support async.
|
||||
// This function 'fixRelativeImports' is async. I can collect matches first.
|
||||
}
|
||||
|
||||
return match;
|
||||
});
|
||||
|
||||
// 2. Markdown Images: 
|
||||
const mdImgRegex = /(!\[.*?\]\()([^\)]+)(\))/g;
|
||||
content = content.replace(mdImgRegex, (match, p1, p2, p3) => {
|
||||
if (!p2.startsWith('.')) return match;
|
||||
const rewritten = rewritePath(p2);
|
||||
if (rewritten && rewritten !== p2) {
|
||||
changed = true;
|
||||
return `${p1}${rewritten}${p3}`;
|
||||
}
|
||||
return match;
|
||||
});
|
||||
// 2. Determine where this target lives now
|
||||
// Case A: It's in 'public' -> We moved public assets to 'public/<version>'
|
||||
// Check if target starts with ROOT_DIR/public
|
||||
if (absoluteTarget.startsWith(PUBLIC_DIR)) {
|
||||
// It refers to a public asset.
|
||||
// The NEW location of this asset is PUBLIC_DIR/<version>/<rest of path>
|
||||
// But wait, PUBLIC_DIR is 'docs/public'.
|
||||
// In downloadVersion, we move 'zitadel-xxx/docs/public' to 'docs/public/<version>'.
|
||||
// So if original path was 'docs/public/img/foo.png', new path is 'docs/public/<version>/img/foo.png'.
|
||||
|
||||
// 3. HTML Attributes: src="..." or href="..."
|
||||
// Naive regex, but likely sufficient for MDX
|
||||
const htmlAttrRegex = /(src|href)=['"]([^'"]+)['"]/g;
|
||||
content = content.replace(htmlAttrRegex, (match, attr, val) => {
|
||||
if (!val.startsWith('.')) return match;
|
||||
const rewritten = rewritePath(val);
|
||||
if (rewritten && rewritten !== val) {
|
||||
changed = true;
|
||||
// reconstruct match
|
||||
const quote = match.includes("'") ? "'" : '"';
|
||||
return `${attr}=${quote}${rewritten}${quote}`;
|
||||
}
|
||||
return match;
|
||||
});
|
||||
|
||||
// --- Special handling for the "download external file" case ---
|
||||
// The previous regex replacement structure prevents async operations.
|
||||
// We have to scan for external files separately or before replacing, OR use a synchronous download (not ideal)
|
||||
// OR rely on the existing synchronous logic for rewriting if we pre-download them.
|
||||
|
||||
// Let's perform a SCAN for strictly external files (like ../cmd/defaults.yaml) first
|
||||
// Reuse original logic for downloading
|
||||
const importRegexForDownload = /import\s+.*\s+from\s+['"](\.\.\/(\.\.\/)+[^'"]+)['"]/g;
|
||||
let match;
|
||||
while ((match = importRegexForDownload.exec(content)) !== null) {
|
||||
const relPath = match[1];
|
||||
const versionFolder = path.basename(versionDir);
|
||||
const relativePathInContent = filePath.split(join('content', versionFolder))[1];
|
||||
const originalFilePath = join(CONTENT_LATEST_DIR, relativePathInContent);
|
||||
const absoluteImportTarget = resolve(dirname(originalFilePath), relPath);
|
||||
const projectRoot = resolve(ROOT_DIR, '../..');
|
||||
|
||||
// If it points to cmd/ or similar external
|
||||
if (absoluteImportTarget.startsWith(projectRoot) && !absoluteImportTarget.startsWith(CONTENT_LATEST_DIR) && !absoluteImportTarget.startsWith(PUBLIC_DIR)) {
|
||||
const repoRoot = resolve(ROOT_DIR, '../..');
|
||||
|
||||
// The original imports were relative to docs/content, not apps/docs/content.
|
||||
// We need to account for the extra 'apps/' level.
|
||||
let relativeToRepoRoot;
|
||||
if (absoluteImportTarget.startsWith(join(repoRoot, 'apps'))) {
|
||||
// If it resolved to apps/cmd/defaults.yaml, it should be cmd/defaults.yaml
|
||||
relativeToRepoRoot = absoluteImportTarget.replace(join(repoRoot, 'apps') + '/', '');
|
||||
} else {
|
||||
relativeToRepoRoot = absoluteImportTarget.replace(repoRoot + '/', '');
|
||||
}
|
||||
|
||||
const localPathInVersion = join(versionDir, '_external', relativeToRepoRoot);
|
||||
|
||||
// Download if missing
|
||||
if (!fs.existsSync(localPathInVersion)) {
|
||||
console.log(`[fix-imports] Downloading external: ${relativeToRepoRoot}`);
|
||||
const fileContent = await downloadFileContent(tagOrBranch, relativeToRepoRoot); // existing function
|
||||
if (fileContent) {
|
||||
fs.mkdirSync(dirname(localPathInVersion), { recursive: true });
|
||||
fs.writeFileSync(localPathInVersion, fileContent);
|
||||
}
|
||||
}
|
||||
|
||||
// Rewrite to local path
|
||||
const newRelPath = path.relative(dirname(filePath), localPathInVersion).split(path.sep).join('/');
|
||||
const finalPath = newRelPath.startsWith('.') ? newRelPath : './' + newRelPath;
|
||||
// Perform replacement
|
||||
const newImport = match[0].replace(relPath, finalPath);
|
||||
content = content.replace(match[0], newImport);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
console.log(`[fix-relative] Updated ${file}`);
|
||||
fs.writeFileSync(filePath, content);
|
||||
}
|
||||
const relToPublic = absoluteTarget.slice(PUBLIC_DIR.length + 1); // 'img/foo.png'
|
||||
const newTargetAbs = join(PUBLIC_DIR, versionFolder, relToPublic);
|
||||
|
||||
// Now calculate relative path from the NEW file location to this NEW target
|
||||
// New file is at 'filePath'
|
||||
const newRelPath = path.relative(dirname(filePath), newTargetAbs);
|
||||
return newRelPath.split(path.sep).join('/'); // Normalize to forward slashes
|
||||
}
|
||||
|
||||
// Case B: It's in 'docs/content' (linking to another doc)
|
||||
// If it's a relative link to another doc, we usually want to keep it relative
|
||||
// content/<ver>/a.md -> content/<ver>/b.md is same relative relationship as content/a.md -> content/b.md
|
||||
// UNLESS it crosses out of content?
|
||||
|
||||
// Case C: It's external (e.g. cmd/defaults.yaml)
|
||||
// This is what the original code was handling.
|
||||
// If it is NOT in docs/content, and NOT in docs/public, we treat it as external file to download.
|
||||
if (absoluteTarget.startsWith(projectRoot) && !absoluteTarget.startsWith(CONTENT_LATEST_DIR) && !absoluteTarget.startsWith(PublicOrRootPublic(absoluteTarget))) {
|
||||
// External file logic...
|
||||
// For now, let's keep the original logic for downloading defaults.yaml
|
||||
return null; // We handle this in the regex pass below specially or reuse this logic?
|
||||
}
|
||||
|
||||
// Handle references to defaults.yaml or setup/steps.yaml specially
|
||||
if (originalRelPath.includes('cmd/defaults.yaml') || originalRelPath.includes('cmd/setup/steps.yaml')) {
|
||||
return null; // Let the external file handlers deal with it
|
||||
}
|
||||
|
||||
// Case D: It's a relative link to, say, components/ or other things in docs/
|
||||
// Original: docs/content/foo.md -> ../components/Bar
|
||||
// New: docs/content/<ver>/foo.md -> ../../components/Bar
|
||||
// We just need to recalculate relative path from NEW file to ORIGINAL target
|
||||
|
||||
// If it's pointing to something in docs/ (but not content/ or public/), like components/
|
||||
if (absoluteTarget.startsWith(ROOT_DIR) && !absoluteTarget.startsWith(CONTENT_LATEST_DIR)) {
|
||||
const newRelPath = path.relative(dirname(filePath), absoluteTarget);
|
||||
return newRelPath.split(path.sep).join('/');
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// Helper for detecting public dir properly
|
||||
// The PUBLIC_DIR variable is 'docs/public'. But absoluteTarget might be resolved via 'src'
|
||||
function PublicOrRootPublic(p) {
|
||||
return PUBLIC_DIR;
|
||||
}
|
||||
|
||||
// --- Replacements ---
|
||||
|
||||
// 1. Imports: import ... from '...'
|
||||
// We capture the path: group 2
|
||||
const importRegex = /(import\s+.*?\s+from\s+['"])([^'"]+)(['"])/g;
|
||||
content = content.replace(importRegex, (match, p1, p2, p3) => {
|
||||
if (!p2.startsWith('.')) return match; // Only relative
|
||||
const rewritten = rewritePath(p2);
|
||||
if (rewritten && rewritten !== p2) {
|
||||
changed = true;
|
||||
return `${p1}${rewritten}${p3}`;
|
||||
}
|
||||
return match;
|
||||
});
|
||||
|
||||
// 2. Markdown Images: 
|
||||
const mdImgRegex = /(!\[.*?\]\()([^\)]+)(\))/g;
|
||||
content = content.replace(mdImgRegex, (match, p1, p2, p3) => {
|
||||
if (!p2.startsWith('.')) return match;
|
||||
const rewritten = rewritePath(p2);
|
||||
if (rewritten && rewritten !== p2) {
|
||||
changed = true;
|
||||
return `${p1}${rewritten}${p3}`;
|
||||
}
|
||||
return match;
|
||||
});
|
||||
|
||||
// 3. HTML Attributes: src="..." or href="..."
|
||||
// Naive regex, but likely sufficient for MDX
|
||||
const htmlAttrRegex = /(src|href)=['"]([^'"]+)['"]/g;
|
||||
content = content.replace(htmlAttrRegex, (match, attr, val) => {
|
||||
if (!val.startsWith('.')) return match;
|
||||
const rewritten = rewritePath(val);
|
||||
if (rewritten && rewritten !== val) {
|
||||
changed = true;
|
||||
// reconstruct match
|
||||
const quote = match.includes("'") ? "'" : '"';
|
||||
return `${attr}=${quote}${rewritten}${quote}`;
|
||||
}
|
||||
return match;
|
||||
});
|
||||
|
||||
// --- Special handling for the "download external file" case ---
|
||||
// The previous regex replacement structure prevents async operations.
|
||||
// We have to scan for external files separately or before replacing, OR use a synchronous download (not ideal)
|
||||
// OR rely on the existing synchronous logic for rewriting if we pre-download them.
|
||||
|
||||
// Let's perform a SCAN for strictly external files (like ../cmd/defaults.yaml) first
|
||||
// Reuse original logic for downloading
|
||||
const importRegexForDownload = /import\s+.*\s+from\s+['"](\.\.\/(\.\.\/)+[^'"]+)['"]/g;
|
||||
let match;
|
||||
while ((match = importRegexForDownload.exec(content)) !== null) {
|
||||
const relPath = match[1];
|
||||
const versionFolder = path.basename(versionDir);
|
||||
const relativePathInContent = filePath.split(join('content', versionFolder))[1];
|
||||
const originalFilePath = join(CONTENT_LATEST_DIR, relativePathInContent);
|
||||
const absoluteImportTarget = resolve(dirname(originalFilePath), relPath);
|
||||
const projectRoot = resolve(ROOT_DIR, '../..');
|
||||
|
||||
// If it points to cmd/ or similar external
|
||||
if (absoluteImportTarget.startsWith(projectRoot) && !absoluteImportTarget.startsWith(CONTENT_LATEST_DIR) && !absoluteImportTarget.startsWith(PUBLIC_DIR)) {
|
||||
const repoRoot = resolve(ROOT_DIR, '../..');
|
||||
|
||||
// The original imports were relative to docs/content, not apps/docs/content.
|
||||
// We need to account for the extra 'apps/' level.
|
||||
let relativeToRepoRoot;
|
||||
if (absoluteImportTarget.startsWith(join(repoRoot, 'apps'))) {
|
||||
// If it resolved to apps/cmd/defaults.yaml, it should be cmd/defaults.yaml
|
||||
relativeToRepoRoot = absoluteImportTarget.replace(join(repoRoot, 'apps') + '/', '');
|
||||
} else {
|
||||
relativeToRepoRoot = absoluteImportTarget.replace(repoRoot + '/', '');
|
||||
}
|
||||
|
||||
const localPathInVersion = join(versionDir, '_external', relativeToRepoRoot);
|
||||
|
||||
// Download if missing
|
||||
if (!fs.existsSync(localPathInVersion)) {
|
||||
console.log(`[fix-imports] Downloading external: ${relativeToRepoRoot}`);
|
||||
const fileContent = await downloadFileContent(tagOrBranch, relativeToRepoRoot); // existing function
|
||||
if (fileContent) {
|
||||
fs.mkdirSync(dirname(localPathInVersion), { recursive: true });
|
||||
fs.writeFileSync(localPathInVersion, fileContent);
|
||||
}
|
||||
}
|
||||
|
||||
// Rewrite to local path
|
||||
const newRelPath = path.relative(dirname(filePath), localPathInVersion).split(path.sep).join('/');
|
||||
const finalPath = newRelPath.startsWith('.') ? newRelPath : './' + newRelPath;
|
||||
// Perform replacement
|
||||
const newImport = match[0].replace(relPath, finalPath);
|
||||
content = content.replace(match[0], newImport);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
console.log(`[fix-relative] Updated ${file}`);
|
||||
fs.writeFileSync(filePath, content);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getLocalVersion() {
|
||||
const isVercel = process.env.VERCEL === '1';
|
||||
const vercelBranch = process.env.VERCEL_GIT_COMMIT_REF;
|
||||
|
||||
let branch = vercelBranch;
|
||||
if (!branch) {
|
||||
try {
|
||||
branch = execSync('git branch --show-current').toString().trim();
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
if (branch && branch !== 'main' && branch !== 'master') {
|
||||
return { label: branch, isUnreleased: true };
|
||||
}
|
||||
|
||||
if (branch === 'main' || branch === 'master') {
|
||||
return { label: 'ZITADEL Docs', isUnreleased: false };
|
||||
}
|
||||
const isVercel = process.env.VERCEL === '1';
|
||||
const vercelBranch = process.env.VERCEL_GIT_COMMIT_REF;
|
||||
|
||||
let branch = vercelBranch;
|
||||
if (!branch) {
|
||||
try {
|
||||
const tag = execSync('git describe --tags --abbrev=0').toString().trim();
|
||||
if (semver.valid(tag) && semver.gt(tag, CUTOFF)) {
|
||||
return { label: tag, isUnreleased: false };
|
||||
}
|
||||
} catch (e) {}
|
||||
branch = execSync('git branch --show-current').toString().trim();
|
||||
} catch (e) { }
|
||||
}
|
||||
|
||||
return { label: 'v4.11.0', isUnreleased: true };
|
||||
if (branch && branch !== 'main' && branch !== 'master') {
|
||||
return { label: branch, isUnreleased: true };
|
||||
}
|
||||
|
||||
if (branch === 'main' || branch === 'master') {
|
||||
return { label: 'ZITADEL Docs', isUnreleased: false };
|
||||
}
|
||||
|
||||
try {
|
||||
const tag = execSync('git describe --tags --abbrev=0').toString().trim();
|
||||
if (semver.valid(tag) && semver.gt(tag, CUTOFF)) {
|
||||
return { label: tag, isUnreleased: false };
|
||||
}
|
||||
} catch (e) { }
|
||||
|
||||
return { label: 'v4.11.0', isUnreleased: true };
|
||||
}
|
||||
|
||||
async function run() {
|
||||
console.log('Starting version discovery...');
|
||||
const tags = await fetchTags();
|
||||
const selectedTags = filterVersions(tags);
|
||||
|
||||
|
||||
let localVer = getLocalVersion();
|
||||
let others = selectedTags; // In our case, if local is latest, all filtered tags are others
|
||||
|
||||
console.log(`Latest version (Local): ${localVer.label} (Unreleased: ${localVer.isUnreleased})`);
|
||||
|
||||
|
||||
// Conditional Fallback: If no versions found > 4.10.0, inject v4.10.0
|
||||
if (others.length === 0) {
|
||||
console.log(`[fallback] No versions found strictly > ${CUTOFF}. Injecting ${FALLBACK_VERSION} as fallback.`);
|
||||
others.push(FALLBACK_VERSION);
|
||||
console.log(`[fallback] No versions found strictly > ${CUTOFF}. Injecting ${FALLBACK_VERSION} as fallback.`);
|
||||
others.push(FALLBACK_VERSION);
|
||||
}
|
||||
|
||||
console.log(`Older versions to fetch: ${others.join(', ') || 'None'}`);
|
||||
@@ -492,38 +432,40 @@ async function run() {
|
||||
// Parallelize download and processing
|
||||
await Promise.all(others.map(async (tag) => {
|
||||
let sourceRef = tag;
|
||||
|
||||
// Explicit logic for version 4.10.0 to use active branch
|
||||
if (tag === FALLBACK_VERSION || tag === '4.10.0') {
|
||||
sourceRef = getFallbackSource();
|
||||
|
||||
// Explicit logic for version 4.10.x to use active branch (Faking 4.10.x)
|
||||
// This prevents fetching incompatible legacy docs for 4.10.1 etc.
|
||||
if (tag === FALLBACK_VERSION || (semver.major(tag) === 4 && semver.minor(tag) === 10)) {
|
||||
console.log(`[fake-override] Version ${tag} matches 4.10.x. Using fallback source (main/current) instead of tag.`);
|
||||
sourceRef = getFallbackSource();
|
||||
}
|
||||
|
||||
|
||||
const versionSlug = `v${semver.major(tag)}.${semver.minor(tag)}`;
|
||||
const contentDest = join(CONTENT_DIR, versionSlug);
|
||||
|
||||
|
||||
// Simple cache check: if directory exists and looks populated, skip
|
||||
// We could check for a specific file like meta.json or similar if we wanted to be more robust
|
||||
if (fs.existsSync(contentDest)) {
|
||||
console.log(`[skip] Version ${versionSlug} already exists. Skipping download.`);
|
||||
console.log(`[skip] Version ${versionSlug} already exists. Skipping download.`);
|
||||
} else {
|
||||
await downloadVersion(tag, sourceRef);
|
||||
// Only fix imports if we just downloaded it (or maybe always run it? Safe to rerun)
|
||||
// Rerunning fixRelativeImports is relatively cheap compared to download + tar extraction
|
||||
// but let's stick to doing it only if we downloaded or if we force it.
|
||||
// For now: Always fix imports to be safe, or just on new download.
|
||||
// Let's doing it on new download for speed.
|
||||
await fixRelativeImports(contentDest, tag);
|
||||
await downloadVersion(tag, sourceRef);
|
||||
// Only fix imports if we just downloaded it (or maybe always run it? Safe to rerun)
|
||||
// Rerunning fixRelativeImports is relatively cheap compared to download + tar extraction
|
||||
// but let's stick to doing it only if we downloaded or if we force it.
|
||||
// For now: Always fix imports to be safe, or just on new download.
|
||||
// Let's doing it on new download for speed.
|
||||
await fixRelativeImports(contentDest, tag);
|
||||
}
|
||||
}));
|
||||
|
||||
// Generate versions.json
|
||||
const versionsJson = [
|
||||
{
|
||||
param: 'latest',
|
||||
label: localVer.isUnreleased ? `${localVer.label} (Unreleased)` : `${localVer.label} (Latest)`,
|
||||
url: '/docs',
|
||||
ref: 'local',
|
||||
refType: 'local'
|
||||
{
|
||||
param: 'latest',
|
||||
label: localVer.isUnreleased ? `${localVer.label} (Unreleased)` : `${localVer.label} (Latest)`,
|
||||
url: '/docs',
|
||||
ref: 'local',
|
||||
refType: 'local'
|
||||
}
|
||||
];
|
||||
|
||||
@@ -531,13 +473,13 @@ async function run() {
|
||||
const v = `v${semver.major(tag)}.${semver.minor(tag)}`;
|
||||
const versionSlug = `v${semver.major(tag)}${semver.minor(tag)}x`;
|
||||
const targetUrl = `https://docs-git-${versionSlug}-zitadel.vercel.app/docs`;
|
||||
versionsJson.push({
|
||||
param: v,
|
||||
label: v,
|
||||
url: `/docs/${v}`,
|
||||
ref: tag,
|
||||
refType: 'tag',
|
||||
target: targetUrl
|
||||
versionsJson.push({
|
||||
param: v,
|
||||
label: v,
|
||||
url: `/docs/${v}`,
|
||||
ref: tag,
|
||||
refType: 'tag',
|
||||
target: targetUrl
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user