[ci skip] k8s portal: fix setup script + add onboarding hub (5 new pages)

Bug fixes:
- CA cert now populated in ConfigMap (was empty → TLS failures)
- Remove useless heredoc quote escaping in setup script
- Fix homepage: VPN callout, correct verification command (get namespaces)
- Fix false-positive sensitive=true on ingress_path, tls_secret_name,
  truenas_host, ollama_host, client_certificate_secret_name

New pages (direct Svelte, no mdsvex dependency):
- /onboarding: step-by-step guide (VPN, kubectl, git, first PR)
- /architecture: cluster topology, storage, networking, tiers
- /services: catalog of 70+ services with URLs
- /contributing: PR workflow, what you can/can't change, NEVER list
- /troubleshooting: common issues and fixes

Navigation bar added to layout. All pages use consistent docs styling.

Requires Docker image rebuild: cd stacks/platform/modules/k8s-portal/files
&& docker build -t viktorbarzin/k8s-portal:latest . && docker push
This commit is contained in:
Viktor Barzin 2026-03-07 15:06:26 +00:00
parent 5907e50fda
commit 6f8b48a73c
15 changed files with 648 additions and 14 deletions

Binary file not shown.

View file

@ -0,0 +1,210 @@
# K8s Portal Onboarding Hub — Implementation Plan (v2)
## Goals
1. Fix broken kubeconfig/OIDC setup script (users can't connect)
2. Add markdown-driven onboarding hub for non-technical users
3. Complete contributor onboarding (git, PR workflow, Codex setup)
---
## Part 1: Fix Setup Script Bugs
### Bug 1 — Empty CA cert (CRITICAL)
**Root cause**: ConfigMap `k8s-portal-config` has `ca.crt = ""`. The kubeconfig gets empty `certificate-authority-data`, causing TLS failures.
**Fix**:
1. Extract K8s API CA cert: `kubectl get configmap -n kube-system kube-root-ca.crt -o jsonpath='{.data.ca\.crt}'`
2. Verify it matches the API server cert: `openssl s_client -connect 10.0.20.100:6443 -showcerts 2>/dev/null | openssl x509 -issuer -noout` — compare issuer with CA cert subject
3. Add `variable "k8s_ca_cert" { type = string }` to `main.tf`
4. Add the cert value to `config.tfvars` (it's public, not a secret)
5. Use in ConfigMap: `"ca.crt" = var.k8s_ca_cert`
6. Pass through `stacks/platform/main.tf` module call
**Double-base64 risk**: The Node.js code does `Buffer.from(caCert).toString('base64')` on the PEM text. This creates base64-of-PEM, which kubectl accepts (kubectl handles both base64(PEM) and base64(DER)). Verified: this is the standard kubeconfig format used by `kubectl config set-cluster --certificate-authority`.
### Bug 2 — Missing VPN prerequisite
**Root cause**: Kubeconfig points to `https://10.0.20.100:6443` (internal IP). No VPN = no connection.
**Fix**: Add VPN setup as step 0 in both:
- The existing homepage (`+page.svelte`) — prominent callout box
- The new onboarding page — full enrollment instructions
### Bug 3 — Headscale enrollment is admin-gated
**Fix**: Document the complete flow:
1. User installs Tailscale app
2. User runs `tailscale login --login-server https://headscale.viktorbarzin.me`
3. User sends the registration URL to Viktor (via Slack/email — provide contact)
4. Viktor approves on Headscale
5. User is now on the VPN
### Bug 4 — `kubectl get pods` vs `kubectl get namespaces`
**Fix**: Change homepage `+page.svelte` to say `kubectl get namespaces` (consistent with setup script).
### Bug 5 — Unused `openid` scope fix
**NOT a bug**: kubelogin always adds `openid` automatically. Remove from the plan. The real investigation is: verify Authentik's `kubernetes` OIDC provider returns `groups` claim in the ID token.
### Bug 6 — Heredoc quoting no-op
**Fix**: Remove the useless `escapedKubeconfig` replace on line 49 of `script/+server.ts` — the quoted heredoc delimiter makes it irrelevant.
### Files to Modify
- `stacks/platform/modules/k8s-portal/main.tf` — add `k8s_ca_cert` variable, update ConfigMap
- `stacks/platform/main.tf` — pass `k8s_ca_cert` to module
- `config.tfvars` — add the CA cert value
- `files/src/routes/setup/script/+server.ts` — remove useless quote escaping
- `files/src/routes/download/+server.ts` — same CA cert fix applies here (identical code)
- `files/src/routes/+page.svelte` — add VPN callout, fix verification command
---
## Part 2: Content System — Skip mdsvex, Use Direct Svelte
### Why NOT mdsvex
- Svelte 5.53.0 broke mdsvex (unresolved as of today)
- Requires pinning Svelte to <5.53, which conflicts with security updates
- Runes mode in layouts is broken in mdsvex
- The content is 5 small pages authored by one person — mdsvex is overkill
- Build complexity and image size increase for minimal benefit
### Alternative: Write content directly in Svelte components
Each content page is a Svelte component with inline HTML/text:
```svelte
<!-- src/routes/onboarding/+page.svelte -->
<article class="content">
<h1>Getting Started</h1>
<p>Welcome! Follow these steps...</p>
...
</article>
```
**Advantages**:
- Zero new dependencies
- Works with any Svelte 5 version
- Content is still just HTML/text in clearly named files
- Can add Svelte interactivity later (copy buttons, progress tracking)
**Trade-off**: Content edits require touching `.svelte` files instead of `.md`. For 5 pages maintained by one person (or an AI), this is fine. If content grows significantly, revisit mdsvex later when Svelte 5 compatibility is stable.
### Shared Content Styling
Create `src/lib/content.css` with the docs-style layout:
```css
.content { max-width: 768px; margin: 2rem auto; font-family: system-ui; line-height: 1.6; }
.content h1 { border-bottom: 1px solid #e0e0e0; padding-bottom: 0.5rem; }
.content pre { background: #1e1e1e; color: #d4d4d4; padding: 1rem; border-radius: 6px; }
.content code { background: #f0f0f0; padding: 2px 6px; border-radius: 3px; }
.content .callout { background: #fff3cd; border-left: 4px solid #ffc107; padding: 1rem; margin: 1rem 0; }
.content .danger { background: #f8d7da; border-left: 4px solid #dc3545; }
```
---
## Part 3: Route Structure
```
src/routes/
├── +layout.svelte ← Nav bar (Home, Onboarding, Architecture, Services, Contributing, Troubleshooting)
├── +page.svelte ← Identity + VPN callout + Get Started (UPDATED)
├── onboarding/+page.svelte ← Step-by-step guide
├── architecture/+page.svelte ← How the cluster works
├── services/+page.svelte ← Service catalog
├── contributing/+page.svelte ← PR workflow
├── troubleshooting/+page.svelte ← Common issues
├── setup/+page.svelte ← Existing kubectl install
├── setup/script/+server.ts ← Existing auto-setup (FIXED)
└── download/+server.ts ← Existing kubeconfig download (FIXED)
```
### Navigation Layout (`+layout.svelte`)
Simple horizontal nav, active page highlighted:
```svelte
<nav>
<a href="/">Home</a>
<a href="/onboarding">Getting Started</a>
<a href="/architecture">Architecture</a>
<a href="/services">Services</a>
<a href="/contributing">Contributing</a>
<a href="/troubleshooting">Help</a>
</nav>
<slot />
```
---
## Part 4: Page Content
### `/onboarding` — Getting Started (non-technical, step-by-step)
**Step 0 — Join the VPN**
- "The cluster is on a private network. You need VPN access first."
- Install Tailscale: link to tailscale.com/download
- Run: `tailscale login --login-server https://headscale.viktorbarzin.me`
- "This will open a browser with a registration URL. Send that URL to Viktor via [Slack/email]. He'll approve your device within a few hours."
- "Once approved, you're connected! Test: `ping 10.0.20.100`"
**Step 1 — Log in to the portal**
- "Visit https://k8s-portal.viktorbarzin.me and sign in with your Authentik account"
- "If you don't have an account, ask Viktor to create one"
**Step 2 — Set up kubectl**
- macOS: `bash <(curl -fsSL https://k8s-portal.viktorbarzin.me/setup/script?os=mac)`
- Linux: `bash <(curl -fsSL https://k8s-portal.viktorbarzin.me/setup/script?os=linux)`
- Windows: "Use WSL2 and follow the Linux instructions"
- macOS prerequisite: "Requires Homebrew. Install it first if you don't have it: [link]"
**Step 3 — Verify access**
- Run: `kubectl get namespaces`
- "This will open a browser for you to log in. After login, you should see a list of namespaces."
- Show expected output example
**Step 4 — Clone the repo**
- `git clone https://github.com/ViktorBarzin/infra.git`
**Step 5 — Install your AI assistant (optional)**
- Install Codex: `npm install -g @openai/codex`
- "Codex reads AGENTS.md from the repo and knows how to work with the cluster"
**Step 6 — Your first change**
- Walk-through: create branch, edit a file, push, open PR, watch CI
### `/architecture` — How It Works
- Simplified: "Proxmox runs VMs → VMs form a K8s cluster → services run as pods"
- Storage, networking, DNS in plain English
- Tier system: "critical services restart first, optional services restart last"
### `/services` — What's Running
- Table: service name, URL, what it does
- Top services highlighted (Nextcloud, Grafana, Uptime Kuma, etc.)
### `/contributing` — How to Contribute
- Branch → edit → PR → review → CI applies
- "What you CAN change" vs "what needs Viktor's review"
- The NEVER list (kubectl apply, secrets in plaintext, NFS restart)
### `/troubleshooting` — Common Issues
- "Can't connect to the cluster" → VPN + KUBECONFIG
- "Permission denied on kubectl" → namespace access
- "Pod is crashing" → check logs
- "PR CI failed" → read Woodpecker logs
- "Need a new secret" → ask Viktor
---
## Part 5: Build & Deploy
1. Make code changes (bug fixes + new pages)
2. Build locally: `cd files && npm install && npm run dev` — verify all pages
3. Test kubeconfig: verify CA cert is present and valid
4. Build Docker image: `docker build -t viktorbarzin/k8s-portal:latest .`
5. Push to registry
6. `terragrunt apply` to deploy
7. End-to-end test on a fresh machine
---
## Implementation Order
1. Fix CA cert (immediate — unblocks setup script)
2. Fix homepage (VPN callout, correct verification command)
3. Remove useless heredoc escaping
4. Add nav layout
5. Create 5 content pages (onboarding, architecture, services, contributing, troubleshooting)
6. Build, push, deploy
7. End-to-end test

View file

@ -4,7 +4,6 @@ variable "hackmd_db_password" {
}
variable "tls_secret_name" {
type = string
sensitive = true
}
variable "nfs_server" { type = string }
variable "mysql_host" { type = string }

View file

@ -4,7 +4,6 @@ variable "tls_secret_name" {
}
variable "client_certificate_secret_name" {
type = string
sensitive = true
}

View file

@ -24,7 +24,6 @@
# --- Core ---
variable "tls_secret_name" {
type = string
sensitive = true
}
variable "nfs_server" { type = string }
variable "redis_host" { type = string }
@ -75,6 +74,10 @@ variable "homepage_credentials" {
# --- headscale ---
variable "headscale_config" { type = string }
variable "headscale_acl" { type = string }
variable "k8s_ca_cert" {
type = string
default = ""
}
# --- authentik / rbac / k8s-portal ---
variable "authentik_secret_key" {
@ -317,6 +320,7 @@ module "k8s-portal" {
source = "./modules/k8s-portal"
tier = local.tiers.edge
tls_secret_name = var.tls_secret_name
k8s_ca_cert = var.k8s_ca_cert
}
# -----------------------------------------------------------------------------

View file

@ -1,5 +1,6 @@
<script lang="ts">
import favicon from '$lib/assets/favicon.svg';
import { page } from '$app/stores';
let { children } = $props();
</script>
@ -8,4 +9,56 @@
<link rel="icon" href={favicon} />
</svelte:head>
<nav>
<div class="nav-inner">
<a href="/" class="brand">K8s Portal</a>
<div class="links">
<a href="/onboarding" class:active={$page.url.pathname === '/onboarding'}>Getting Started</a>
<a href="/architecture" class:active={$page.url.pathname === '/architecture'}>Architecture</a>
<a href="/services" class:active={$page.url.pathname === '/services'}>Services</a>
<a href="/contributing" class:active={$page.url.pathname === '/contributing'}>Contributing</a>
<a href="/troubleshooting" class:active={$page.url.pathname === '/troubleshooting'}>Help</a>
</div>
</div>
</nav>
{@render children()}
<style>
nav {
background: #1a1a2e;
padding: 0.75rem 1rem;
position: sticky;
top: 0;
z-index: 100;
}
.nav-inner {
max-width: 768px;
margin: 0 auto;
display: flex;
align-items: center;
gap: 1.5rem;
flex-wrap: wrap;
}
.brand {
color: #e0e0e0;
text-decoration: none;
font-weight: 700;
font-size: 1.1rem;
}
.links {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.links a {
color: #a0a0c0;
text-decoration: none;
font-size: 0.9rem;
padding: 0.25rem 0;
}
.links a:hover, .links a.active {
color: #ffffff;
border-bottom: 2px solid #4fc3f7;
}
</style>

View file

@ -5,6 +5,11 @@
<main>
<h1>Kubernetes Access Portal</h1>
<div class="callout warning">
<strong>VPN Required</strong> — The cluster is on a private network. You need Headscale VPN access before kubectl will work.
<a href="/onboarding">See the Getting Started guide</a> for VPN setup instructions.
</div>
<section>
<h2>Your Identity</h2>
<p><strong>Username:</strong> {data.username}</p>
@ -18,18 +23,31 @@
<section>
<h2>Get Started</h2>
<ol>
<li><a href="/onboarding">Complete the onboarding guide</a> (VPN, kubectl, git)</li>
<li><a href="/setup">Install kubectl and kubelogin</a></li>
<li><a href="/download">Download your kubeconfig</a></li>
<li>Run <code>kubectl get pods</code> to verify access</li>
<li>Run <code>kubectl get namespaces</code> to verify access</li>
</ol>
</section>
<section>
<h2>Resources</h2>
<ul>
<li><a href="/architecture">Architecture overview</a></li>
<li><a href="/services">Service catalog</a></li>
<li><a href="/contributing">How to contribute</a></li>
<li><a href="/troubleshooting">Troubleshooting</a></li>
</ul>
</section>
</main>
<style>
main {
max-width: 640px;
max-width: 768px;
margin: 2rem auto;
font-family: system-ui;
padding: 0 1rem;
font-family: system-ui, -apple-system, sans-serif;
line-height: 1.6;
}
code {
background: #f0f0f0;
@ -39,4 +57,17 @@
section {
margin: 2rem 0;
}
.callout {
padding: 1rem;
border-radius: 6px;
margin: 1rem 0;
}
.callout.warning {
background: #fff3cd;
border-left: 4px solid #ffc107;
}
.callout a {
color: #856404;
font-weight: 600;
}
</style>

View file

@ -0,0 +1,73 @@
<main class="content">
<h1>Architecture</h1>
<section>
<h2>Overview</h2>
<p>The infrastructure runs on a single Dell R730 server (22 CPU cores, 142GB RAM) using Proxmox to manage virtual machines. Five of those VMs form a Kubernetes cluster that runs 70+ services.</p>
<pre class="output">
Proxmox (Dell R730)
├── k8s-master (10.0.20.100) — control plane
├── k8s-node1 (10.0.20.101) — GPU node (Tesla T4)
├── k8s-node2 (10.0.20.102) — worker
├── k8s-node3 (10.0.20.103) — worker
├── k8s-node4 (10.0.20.104) — worker
├── TrueNAS (10.0.10.15) — storage (NFS + iSCSI)
└── pfSense (10.0.20.1) — firewall + gateway</pre>
</section>
<section>
<h2>Networking</h2>
<ul>
<li><strong>Public domain</strong>: <code>viktorbarzin.me</code> — managed by Cloudflare</li>
<li><strong>Internal domain</strong>: <code>viktorbarzin.lan</code> — managed by Technitium DNS</li>
<li><strong>Ingress</strong>: Cloudflare → Traefik → services</li>
<li><strong>VPN</strong>: Headscale (self-hosted Tailscale)</li>
</ul>
</section>
<section>
<h2>Storage</h2>
<ul>
<li><strong>NFS</strong> (<code>nfs-truenas</code>) — for app data (files, configs, media). Stored on TrueNAS.</li>
<li><strong>iSCSI</strong> (<code>iscsi-truenas</code>) — for databases (PostgreSQL, MySQL). Block storage.</li>
</ul>
</section>
<section>
<h2>Service Tiers</h2>
<p>Services are organized into tiers that control resource limits and restart priority:</p>
<table>
<tr><th>Tier</th><th>Examples</th><th>Priority</th></tr>
<tr><td><strong>0-core</strong></td><td>Traefik, DNS, VPN, Auth</td><td>Highest — never evicted</td></tr>
<tr><td><strong>1-cluster</strong></td><td>Redis, Prometheus, CrowdSec</td><td>High</td></tr>
<tr><td><strong>2-gpu</strong></td><td>Ollama, Immich ML, Whisper</td><td>Medium</td></tr>
<tr><td><strong>3-edge</strong></td><td>Nextcloud, Paperless, Grafana</td><td>Normal</td></tr>
<tr><td><strong>4-aux</strong></td><td>Dashy, PrivateBin, CyberChef</td><td>Low — evicted first under pressure</td></tr>
</table>
</section>
<section>
<h2>Infrastructure as Code</h2>
<p>Everything is managed with <strong>Terraform</strong> (via <strong>Terragrunt</strong>). Each service has its own stack:</p>
<pre class="output">stacks/
├── platform/ ← core infra (22 modules)
├── url/ ← URL shortener (Shlink)
├── immich/ ← photo library
├── nextcloud/ ← file storage
└── ... (70+ more)</pre>
<p>Changes go through git: branch → PR → review → merge → CI applies automatically.</p>
</section>
</main>
<style>
.content { max-width: 768px; margin: 2rem auto; padding: 0 1rem; font-family: system-ui, -apple-system, sans-serif; line-height: 1.6; }
.content h1 { border-bottom: 1px solid #e0e0e0; padding-bottom: 0.5rem; }
.content h2 { margin-top: 2rem; color: #333; }
.content pre { background: #1e1e1e; color: #d4d4d4; padding: 1rem; border-radius: 6px; overflow-x: auto; }
.content pre.output { background: #f5f5f5; color: #333; }
.content code { background: #f0f0f0; padding: 2px 6px; border-radius: 3px; }
section { margin: 2rem 0; }
table { border-collapse: collapse; width: 100%; }
th, td { border: 1px solid #ddd; padding: 0.5rem; text-align: left; }
th { background: #f5f5f5; }
</style>

View file

@ -0,0 +1,62 @@
<main class="content">
<h1>How to Contribute</h1>
<section>
<h2>Workflow</h2>
<ol>
<li><strong>Create a branch</strong>: <code>git checkout -b fix/my-change</code></li>
<li><strong>Make your changes</strong> in <code>stacks/&lt;service&gt;/main.tf</code></li>
<li><strong>Push and open a PR</strong>: <code>git push -u origin fix/my-change</code></li>
<li><strong>Viktor reviews</strong> and merges</li>
<li><strong>CI applies</strong> automatically — Slack notification when done</li>
</ol>
</section>
<section>
<h2>What you CAN change</h2>
<ul>
<li>Service configurations (image tags, environment variables, resource limits)</li>
<li>New services (add a new stack under <code>stacks/</code>)</li>
<li>Ingress routes, health probes, replica counts</li>
</ul>
</section>
<section>
<h2>What needs Viktor's review</h2>
<ul>
<li>CI pipeline changes (<code>.woodpecker/</code>)</li>
<li>Terragrunt configuration (<code>terragrunt.hcl</code>)</li>
<li>Secrets configuration (<code>.sops.yaml</code>)</li>
<li>Core platform modules (<code>stacks/platform/</code>)</li>
</ul>
</section>
<section>
<h2 class="danger-header">NEVER do these</h2>
<div class="callout danger">
<ul>
<li><strong>Never <code>kubectl apply/edit/patch</code></strong> — all changes go through Terraform</li>
<li><strong>Never put secrets in code</strong> — ask Viktor to add them to the encrypted secrets file</li>
<li><strong>Never restart NFS on TrueNAS</strong> — causes cluster-wide mount failures</li>
<li><strong>Never push directly to master</strong> — always use a PR</li>
</ul>
</div>
</section>
<section>
<h2>Need a new secret?</h2>
<p>Comment on your PR: "I need a database password for my-service." Viktor will add it to the encrypted secrets file and push to your branch.</p>
<p>Then reference it in your Terraform: <code>var.my_service_db_password</code></p>
</section>
</main>
<style>
.content { max-width: 768px; margin: 2rem auto; padding: 0 1rem; font-family: system-ui, -apple-system, sans-serif; line-height: 1.6; }
.content h1 { border-bottom: 1px solid #e0e0e0; padding-bottom: 0.5rem; }
.content h2 { margin-top: 2rem; color: #333; }
.content code { background: #f0f0f0; padding: 2px 6px; border-radius: 3px; }
section { margin: 2rem 0; }
.callout { padding: 1rem; border-radius: 6px; margin: 1rem 0; }
.callout.danger { background: #f8d7da; border-left: 4px solid #dc3545; }
.danger-header { color: #dc3545; }
</style>

View file

@ -0,0 +1,89 @@
<main class="content">
<h1>Getting Started</h1>
<p>Welcome! Follow these steps to get access to the home Kubernetes cluster.</p>
<section>
<h2>Step 0 — Join the VPN</h2>
<p>The cluster is on a private network (<code>10.0.20.0/24</code>). You need VPN access first.</p>
<ol>
<li>Install <a href="https://tailscale.com/download" target="_blank">Tailscale</a> for your OS</li>
<li>Run this in your terminal:
<pre>tailscale login --login-server https://headscale.viktorbarzin.me</pre>
</li>
<li>A browser window will open with a registration URL</li>
<li>Send that URL to Viktor via email (<a href="mailto:vbarzin@gmail.com">vbarzin@gmail.com</a>) or Slack</li>
<li>Wait for approval (usually within a few hours)</li>
<li>Once approved, test: <pre>ping 10.0.20.100</pre></li>
</ol>
</section>
<section>
<h2>Step 1 — Log in to the portal</h2>
<p>Visit <a href="https://k8s-portal.viktorbarzin.me">k8s-portal.viktorbarzin.me</a> and sign in with your Authentik account.</p>
<p>If you don't have an account yet, ask Viktor to create one.</p>
</section>
<section>
<h2>Step 2 — Set up kubectl</h2>
<p>Run one of these commands in your terminal to install everything automatically:</p>
<h3>macOS</h3>
<p class="prereq">Requires <a href="https://brew.sh" target="_blank">Homebrew</a>. Install it first if you don't have it.</p>
<pre>bash &lt;(curl -fsSL https://k8s-portal.viktorbarzin.me/setup/script?os=mac)</pre>
<h3>Linux</h3>
<pre>bash &lt;(curl -fsSL https://k8s-portal.viktorbarzin.me/setup/script?os=linux)</pre>
<h3>Windows</h3>
<p>Use <a href="https://learn.microsoft.com/en-us/windows/wsl/install" target="_blank">WSL2</a> and follow the Linux instructions.</p>
</section>
<section>
<h2>Step 3 — Verify access</h2>
<p>Run this command. It will open your browser for login the first time:</p>
<pre>kubectl get namespaces</pre>
<p>You should see output like:</p>
<pre class="output">NAME STATUS AGE
default Active 200d
kube-system Active 200d
monitoring Active 200d
...</pre>
<p>If you get a connection error, make sure your VPN is connected (<code>tailscale status</code>).</p>
</section>
<section>
<h2>Step 4 — Clone the repo</h2>
<pre>git clone https://github.com/ViktorBarzin/infra.git
cd infra</pre>
<p>This is where all the infrastructure configuration lives.</p>
</section>
<section>
<h2>Step 5 — Install your AI assistant (optional)</h2>
<p>Install <a href="https://github.com/openai/codex" target="_blank">Codex CLI</a> for AI-assisted cluster management:</p>
<pre>npm install -g @openai/codex</pre>
<p>Codex reads the <code>AGENTS.md</code> file in the repo and knows how to work with the cluster.</p>
</section>
<section>
<h2>Step 6 — Your first change</h2>
<ol>
<li>Create a branch: <pre>git checkout -b my-first-change</pre></li>
<li>Edit a service file (e.g., change an image tag in <code>stacks/echo/main.tf</code>)</li>
<li>Commit and push: <pre>git add . && git commit -m "my first change" && git push -u origin my-first-change</pre></li>
<li>Open a Pull Request on GitHub</li>
<li>Viktor reviews and merges</li>
<li>Woodpecker CI automatically applies the change to the cluster</li>
<li>Slack notification confirms it worked</li>
</ol>
</section>
</main>
<style>
.content { max-width: 768px; margin: 2rem auto; padding: 0 1rem; font-family: system-ui, -apple-system, sans-serif; line-height: 1.6; }
.content h1 { border-bottom: 1px solid #e0e0e0; padding-bottom: 0.5rem; }
.content h2 { margin-top: 2rem; color: #333; }
.content h3 { color: #666; margin: 1rem 0 0.25rem; }
.content pre { background: #1e1e1e; color: #d4d4d4; padding: 1rem; border-radius: 6px; overflow-x: auto; }
.content pre.output { background: #f5f5f5; color: #333; }
.content code { background: #f0f0f0; padding: 2px 6px; border-radius: 3px; }
.content .prereq { font-size: 0.9rem; color: #666; font-style: italic; }
section { margin: 2rem 0; }
</style>

View file

@ -0,0 +1,52 @@
<main class="content">
<h1>Service Catalog</h1>
<p>70+ services running on the cluster. Here are the most commonly used:</p>
<section>
<h2>Core Services</h2>
<table>
<tr><th>Service</th><th>URL</th><th>Description</th></tr>
<tr><td>Grafana</td><td><a href="https://grafana.viktorbarzin.me">grafana.viktorbarzin.me</a></td><td>Monitoring dashboards</td></tr>
<tr><td>Uptime Kuma</td><td><a href="https://uptime.viktorbarzin.me">uptime.viktorbarzin.me</a></td><td>Service uptime monitoring</td></tr>
<tr><td>Authentik</td><td><a href="https://authentik.viktorbarzin.me">authentik.viktorbarzin.me</a></td><td>Identity provider (SSO)</td></tr>
<tr><td>Woodpecker CI</td><td><a href="https://ci.viktorbarzin.me">ci.viktorbarzin.me</a></td><td>CI/CD pipeline</td></tr>
</table>
</section>
<section>
<h2>User-Facing Services</h2>
<table>
<tr><th>Service</th><th>URL</th><th>Description</th></tr>
<tr><td>Nextcloud</td><td><a href="https://nextcloud.viktorbarzin.me">nextcloud.viktorbarzin.me</a></td><td>File storage, calendar, contacts</td></tr>
<tr><td>Immich</td><td><a href="https://immich.viktorbarzin.me">immich.viktorbarzin.me</a></td><td>Photo library (Google Photos alternative)</td></tr>
<tr><td>Vaultwarden</td><td><a href="https://vault.viktorbarzin.me">vault.viktorbarzin.me</a></td><td>Password manager</td></tr>
<tr><td>Paperless-ngx</td><td><a href="https://pdf.viktorbarzin.me">pdf.viktorbarzin.me</a></td><td>Document management</td></tr>
<tr><td>Navidrome</td><td><a href="https://music.viktorbarzin.me">music.viktorbarzin.me</a></td><td>Music streaming</td></tr>
<tr><td>Tandoor</td><td><a href="https://recipes.viktorbarzin.me">recipes.viktorbarzin.me</a></td><td>Recipe manager</td></tr>
<tr><td>Linkwarden</td><td><a href="https://bookmarks.viktorbarzin.me">bookmarks.viktorbarzin.me</a></td><td>Bookmark manager</td></tr>
</table>
</section>
<section>
<h2>Developer Tools</h2>
<table>
<tr><th>Service</th><th>URL</th><th>Description</th></tr>
<tr><td>Forgejo</td><td><a href="https://forgejo.viktorbarzin.me">forgejo.viktorbarzin.me</a></td><td>Git server (Gitea fork)</td></tr>
<tr><td>CyberChef</td><td><a href="https://cyberchef.viktorbarzin.me">cyberchef.viktorbarzin.me</a></td><td>Data transformation tool</td></tr>
<tr><td>Excalidraw</td><td><a href="https://draw.viktorbarzin.me">draw.viktorbarzin.me</a></td><td>Whiteboard drawing</td></tr>
<tr><td>PrivateBin</td><td><a href="https://paste.viktorbarzin.me">paste.viktorbarzin.me</a></td><td>Encrypted paste bin</td></tr>
<tr><td>JSON Crack</td><td><a href="https://jsoncrack.viktorbarzin.me">jsoncrack.viktorbarzin.me</a></td><td>JSON visualizer</td></tr>
</table>
</section>
</main>
<style>
.content { max-width: 768px; margin: 2rem auto; padding: 0 1rem; font-family: system-ui, -apple-system, sans-serif; line-height: 1.6; }
.content h1 { border-bottom: 1px solid #e0e0e0; padding-bottom: 0.5rem; }
.content h2 { margin-top: 2rem; color: #333; }
section { margin: 2rem 0; }
table { border-collapse: collapse; width: 100%; }
th, td { border: 1px solid #ddd; padding: 0.5rem; text-align: left; }
th { background: #f5f5f5; }
a { color: #1a73e8; }
</style>

View file

@ -4,7 +4,6 @@ import { readFileSync } from 'fs';
const CLUSTER_SERVER = 'https://10.0.20.100:6443';
const OIDC_ISSUER = 'https://authentik.viktorbarzin.me/application/o/kubernetes/';
const OIDC_CLIENT_ID = 'kubernetes';
const PORTAL_URL = 'https://k8s-portal.viktorbarzin.me';
export const GET: RequestHandler = async ({ url }) => {
const os = url.searchParams.get('os') || 'mac';
@ -46,8 +45,6 @@ users:
- --oidc-extra-scope=groups
interactiveMode: IfAvailable`;
const escapedKubeconfig = kubeconfigContent.replace(/'/g, "'\\''");
let script: string;
if (os === 'linux') {
@ -98,7 +95,7 @@ fi
# Write kubeconfig
mkdir -p ~/.kube
cat > ~/.kube/config-home << 'KUBECONFIG_EOF'
${escapedKubeconfig}
${kubeconfigContent}
KUBECONFIG_EOF
echo "[OK] Kubeconfig written to ~/.kube/config-home"
@ -152,7 +149,7 @@ fi
# Write kubeconfig
mkdir -p ~/.kube
cat > ~/.kube/config-home << 'KUBECONFIG_EOF'
${escapedKubeconfig}
${kubeconfigContent}
KUBECONFIG_EOF
echo "[OK] Kubeconfig written to ~/.kube/config-home"

View file

@ -0,0 +1,63 @@
<main class="content">
<h1>Troubleshooting</h1>
<section>
<h2>"kubectl can't connect to the server"</h2>
<ol>
<li>Check your VPN: <code>tailscale status</code> — should show "connected"</li>
<li>Check KUBECONFIG: <code>echo $KUBECONFIG</code> — should be <code>~/.kube/config-home</code></li>
<li>Test connectivity: <code>ping 10.0.20.100</code></li>
<li>If ping works but kubectl doesn't, re-run the <a href="/setup">setup script</a></li>
</ol>
</section>
<section>
<h2>"Forbidden" or "Permission denied"</h2>
<p>You may not have access to that namespace. Your access is scoped to specific namespaces.</p>
<p>Try: <code>kubectl get namespaces</code> to see which namespaces you can access.</p>
<p>Need access to another namespace? Ask Viktor.</p>
</section>
<section>
<h2>"Pod is CrashLoopBackOff"</h2>
<ol>
<li>Check pod logs: <code>kubectl logs -n &lt;namespace&gt; &lt;pod-name&gt; --tail=50</code></li>
<li>Check previous crash: <code>kubectl logs -n &lt;namespace&gt; &lt;pod-name&gt; --previous</code></li>
<li>Check events: <code>kubectl describe pod -n &lt;namespace&gt; &lt;pod-name&gt;</code></li>
<li>Common causes: OOMKilled (need more memory), bad config, database connection failure</li>
</ol>
</section>
<section>
<h2>"PR CI failed"</h2>
<ol>
<li>Check the Woodpecker CI dashboard: <a href="https://ci.viktorbarzin.me">ci.viktorbarzin.me</a></li>
<li>Read the build logs — the error is usually at the bottom</li>
<li>Fix the issue, commit, and push — CI will re-run</li>
</ol>
</section>
<section>
<h2>"I need a new secret / database password"</h2>
<p>Secrets are managed by Viktor in an encrypted file. You cannot add them yourself.</p>
<ol>
<li>Comment on your PR: "Need DB password for &lt;service&gt;"</li>
<li>Viktor adds the secret and pushes to your branch</li>
<li>Reference it as <code>var.&lt;service&gt;_db_password</code> in your Terraform</li>
</ol>
</section>
<section>
<h2>Still stuck?</h2>
<p>Email Viktor at <a href="mailto:vbarzin@gmail.com">vbarzin@gmail.com</a> or message on Slack.</p>
</section>
</main>
<style>
.content { max-width: 768px; margin: 2rem auto; padding: 0 1rem; font-family: system-ui, -apple-system, sans-serif; line-height: 1.6; }
.content h1 { border-bottom: 1px solid #e0e0e0; padding-bottom: 0.5rem; }
.content h2 { margin-top: 2rem; color: #333; }
.content pre { background: #1e1e1e; color: #d4d4d4; padding: 1rem; border-radius: 6px; overflow-x: auto; }
.content code { background: #f0f0f0; padding: 2px 6px; border-radius: 3px; }
section { margin: 2rem 0; }
</style>

View file

@ -1,5 +1,9 @@
variable "tls_secret_name" {}
variable "tier" { type = string }
variable "k8s_ca_cert" {
type = string
default = ""
}
resource "kubernetes_namespace" "k8s_portal" {
metadata {
@ -23,8 +27,7 @@ resource "kubernetes_config_map" "k8s_portal_config" {
}
data = {
# CA cert extracted from kubeconfig will be populated with cluster CA cert
"ca.crt" = ""
"ca.crt" = var.k8s_ca_cert
}
}

View file

@ -17,7 +17,6 @@ variable "protected" {
variable "ingress_path" {
type = list(string)
default = ["/"]
sensitive = true
}
variable "max_body_size" {
type = string