diff --git a/apps/docs/content/self-hosting/deploy/kubernetes.mdx b/apps/docs/content/self-hosting/deploy/kubernetes.mdx deleted file mode 100644 index a3814e4f4..000000000 --- a/apps/docs/content/self-hosting/deploy/kubernetes.mdx +++ /dev/null @@ -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). diff --git a/apps/docs/content/self-hosting/deploy/kubernetes/configuration.mdx b/apps/docs/content/self-hosting/deploy/kubernetes/configuration.mdx new file mode 100644 index 000000000..5c5710f7d --- /dev/null +++ b/apps/docs/content/self-hosting/deploy/kubernetes/configuration.mdx @@ -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: "admin@zitadel.example.com" + 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 +``` + +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 diff --git a/apps/docs/content/self-hosting/deploy/kubernetes/uninstalling.mdx b/apps/docs/content/self-hosting/deploy/kubernetes/uninstalling.mdx new file mode 100644 index 000000000..b990c0dc5 --- /dev/null +++ b/apps/docs/content/self-hosting/deploy/kubernetes/uninstalling.mdx @@ -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 +``` diff --git a/apps/docs/lib/sidebar-data.ts b/apps/docs/lib/sidebar-data.ts index 5ee952c2f..dc2e7ac16 100644 --- a/apps/docs/lib/sidebar-data.ts +++ b/apps/docs/lib/sidebar-data.ts @@ -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", diff --git a/apps/docs/scripts/fetch-remote-content.mjs b/apps/docs/scripts/fetch-remote-content.mjs index 7f3c8792e..d24080ba6 100644 --- a/apps/docs/scripts/fetch-remote-content.mjs +++ b/apps/docs/scripts/fetch-remote-content.mjs @@ -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-` - // Tag: `zitadel-` (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/' - // 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// - // But wait, PUBLIC_DIR is 'docs/public'. - // In downloadVersion, we move 'zitadel-xxx/docs/public' to 'docs/public/'. - // So if original path was 'docs/public/img/foo.png', new path is 'docs/public//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//a.md -> content//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//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: ![alt](src) - 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/' + // 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// + // But wait, PUBLIC_DIR is 'docs/public'. + // In downloadVersion, we move 'zitadel-xxx/docs/public' to 'docs/public/'. + // So if original path was 'docs/public/img/foo.png', new path is 'docs/public//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//a.md -> content//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//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: ![alt](src) + 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 }); }