add generic multi-user cluster onboarding system
Data-driven user onboarding: add a JSON entry to Vault KV k8s_users, apply vault + platform + woodpecker stacks, and everything is auto-generated. Vault stack: namespace creation, per-user Vault policies with secret isolation via identity entities/aliases, K8s deployer roles, CI policy update. Platform stack: domains field in k8s_users type, TLS secrets per user namespace, user domains merged into Cloudflare DNS, user-roles ConfigMap mounted in portal. Woodpecker stack: admin list auto-generated from k8s_users, WOODPECKER_OPEN=true. K8s-portal: dual-track onboarding (general/namespace-owner), namespace-owner dashboard with Vault/kubectl commands, setup script adds Vault+Terraform+Terragrunt, contributing page with CI pipeline template, versioned image tags in CI pipeline. New: stacks/_template/ with copyable stack template for namespace-owners.
This commit is contained in:
parent
5bc50af99e
commit
0610ea30d4
13 changed files with 530 additions and 40 deletions
|
|
@ -24,23 +24,23 @@ steps:
|
||||||
context: stacks/platform/modules/k8s-portal/files
|
context: stacks/platform/modules/k8s-portal/files
|
||||||
platforms:
|
platforms:
|
||||||
- linux/amd64
|
- linux/amd64
|
||||||
auto_tag: true
|
tag: ["${CI_PIPELINE_NUMBER}", "latest"]
|
||||||
cache_from: "viktorbarzin/k8s-portal:latest"
|
cache_from: "viktorbarzin/k8s-portal:latest"
|
||||||
cache_to: "type=inline"
|
cache_to: "type=inline"
|
||||||
|
|
||||||
- name: deploy
|
- name: deploy
|
||||||
image: bitnami/kubectl:latest
|
image: bitnami/kubectl:latest
|
||||||
commands:
|
commands:
|
||||||
- "kubectl rollout restart deployment/k8s-portal -n k8s-portal"
|
- "kubectl set image deployment/k8s-portal portal=viktorbarzin/k8s-portal:${CI_PIPELINE_NUMBER} -n k8s-portal"
|
||||||
- "kubectl rollout status deployment/k8s-portal -n k8s-portal --timeout=120s"
|
- "kubectl rollout status deployment/k8s-portal -n k8s-portal --timeout=120s"
|
||||||
- "echo 'k8s-portal deployed successfully'"
|
- "echo 'k8s-portal deployed successfully (build ${CI_PIPELINE_NUMBER})'"
|
||||||
|
|
||||||
- name: slack
|
- name: slack
|
||||||
image: curlimages/curl
|
image: curlimages/curl
|
||||||
commands:
|
commands:
|
||||||
- |
|
- |
|
||||||
curl -s -X POST -H 'Content-type: application/json' \
|
curl -s -X POST -H 'Content-type: application/json' \
|
||||||
--data "{\"text\":\"K8s Portal: build + deploy ${CI_PIPELINE_STATUS}\"}" \
|
--data "{\"text\":\"K8s Portal: build #${CI_PIPELINE_NUMBER} ${CI_PIPELINE_STATUS}\"}" \
|
||||||
"$SLACK_WEBHOOK" || true
|
"$SLACK_WEBHOOK" || true
|
||||||
environment:
|
environment:
|
||||||
SLACK_WEBHOOK:
|
SLACK_WEBHOOK:
|
||||||
|
|
|
||||||
90
stacks/_template/main.tf.example
Normal file
90
stacks/_template/main.tf.example
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
# =============================================================================
|
||||||
|
# Stack Template — Copy this directory to stacks/<your-app>/ and customize.
|
||||||
|
# Then submit a PR to the infra repo.
|
||||||
|
# =============================================================================
|
||||||
|
#
|
||||||
|
# Prerequisites:
|
||||||
|
# 1. You are a namespace-owner in k8s_users (Vault KV secret/platform)
|
||||||
|
# 2. Your namespace already exists (created by vault stack)
|
||||||
|
# 3. You have Vault CLI access: vault login -method=oidc
|
||||||
|
#
|
||||||
|
# Steps:
|
||||||
|
# 1. cp -r stacks/_template stacks/myapp
|
||||||
|
# 2. mv stacks/myapp/main.tf.example stacks/myapp/main.tf
|
||||||
|
# 3. Search-replace <placeholders> below
|
||||||
|
# 4. Store secrets: vault kv put secret/<your-username>/myapp KEY=value
|
||||||
|
# 5. git checkout -b feat/myapp && git push
|
||||||
|
# 6. Open PR, get reviewed, merge
|
||||||
|
# 7. Admin runs: cd stacks/myapp && terragrunt apply
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
variable "tls_secret_name" {
|
||||||
|
type = string
|
||||||
|
sensitive = true
|
||||||
|
}
|
||||||
|
|
||||||
|
# NOTE: Your namespace is auto-created by the vault stack from k8s_users.
|
||||||
|
# Only add a kubernetes_namespace resource if you need a SEPARATE namespace
|
||||||
|
# for this specific app (not your user namespace).
|
||||||
|
|
||||||
|
module "tls_secret" {
|
||||||
|
source = "../../modules/kubernetes/setup_tls_secret"
|
||||||
|
namespace = "<your-namespace>" # e.g., "anca"
|
||||||
|
tls_secret_name = var.tls_secret_name
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "kubernetes_deployment" "app" {
|
||||||
|
metadata {
|
||||||
|
name = "<app-name>"
|
||||||
|
namespace = "<your-namespace>"
|
||||||
|
}
|
||||||
|
spec {
|
||||||
|
replicas = 1
|
||||||
|
selector {
|
||||||
|
match_labels = { app = "<app-name>" }
|
||||||
|
}
|
||||||
|
template {
|
||||||
|
metadata {
|
||||||
|
labels = { app = "<app-name>" }
|
||||||
|
}
|
||||||
|
spec {
|
||||||
|
container {
|
||||||
|
name = "<app-name>"
|
||||||
|
image = "<dockerhub-user>/<app-name>:<tag>"
|
||||||
|
port {
|
||||||
|
container_port = 8080 # Change to your app's port
|
||||||
|
}
|
||||||
|
resources {
|
||||||
|
requests = { cpu = "10m", memory = "128Mi" }
|
||||||
|
limits = { memory = "128Mi" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lifecycle {
|
||||||
|
ignore_changes = [spec[0].template[0].spec[0].dns_config]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "kubernetes_service" "app" {
|
||||||
|
metadata {
|
||||||
|
name = "<app-name>"
|
||||||
|
namespace = "<your-namespace>"
|
||||||
|
}
|
||||||
|
spec {
|
||||||
|
selector = { app = "<app-name>" }
|
||||||
|
port {
|
||||||
|
port = 80
|
||||||
|
target_port = 8080 # Match container_port above
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module "ingress" {
|
||||||
|
source = "../../modules/kubernetes/ingress_factory"
|
||||||
|
namespace = "<your-namespace>"
|
||||||
|
name = "<app-name>"
|
||||||
|
tls_secret_name = var.tls_secret_name
|
||||||
|
protected = false # Set true to require Authentik login
|
||||||
|
}
|
||||||
8
stacks/_template/terragrunt.hcl
Normal file
8
stacks/_template/terragrunt.hcl
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
include "root" {
|
||||||
|
path = find_in_parent_folders()
|
||||||
|
}
|
||||||
|
|
||||||
|
dependency "platform" {
|
||||||
|
config_path = "../platform"
|
||||||
|
skip_outputs = true
|
||||||
|
}
|
||||||
|
|
@ -68,6 +68,12 @@ locals {
|
||||||
mailserver_aliases = jsondecode(data.vault_kv_secret_v2.secrets.data["mailserver_aliases"])
|
mailserver_aliases = jsondecode(data.vault_kv_secret_v2.secrets.data["mailserver_aliases"])
|
||||||
mailserver_opendkim_key = jsondecode(data.vault_kv_secret_v2.secrets.data["mailserver_opendkim_key"])
|
mailserver_opendkim_key = jsondecode(data.vault_kv_secret_v2.secrets.data["mailserver_opendkim_key"])
|
||||||
mailserver_sasl_passwd = jsondecode(data.vault_kv_secret_v2.secrets.data["mailserver_sasl_passwd"])
|
mailserver_sasl_passwd = jsondecode(data.vault_kv_secret_v2.secrets.data["mailserver_sasl_passwd"])
|
||||||
|
|
||||||
|
# User domains from namespace-owners for DNS/Cloudflare
|
||||||
|
user_domains = flatten([
|
||||||
|
for name, user in local.k8s_users : user.domains
|
||||||
|
if user.role == "namespace-owner"
|
||||||
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
@ -377,7 +383,7 @@ module "cloudflared" {
|
||||||
cloudflare_zone_id = var.cloudflare_zone_id
|
cloudflare_zone_id = var.cloudflare_zone_id
|
||||||
cloudflare_tunnel_id = var.cloudflare_tunnel_id
|
cloudflare_tunnel_id = var.cloudflare_tunnel_id
|
||||||
public_ip = var.public_ip
|
public_ip = var.public_ip
|
||||||
cloudflare_proxied_names = var.cloudflare_proxied_names
|
cloudflare_proxied_names = concat(var.cloudflare_proxied_names, local.user_domains)
|
||||||
cloudflare_non_proxied_names = var.cloudflare_non_proxied_names
|
cloudflare_non_proxied_names = var.cloudflare_non_proxied_names
|
||||||
cloudflare_tunnel_token = data.vault_kv_secret_v2.secrets.data["cloudflare_tunnel_token"]
|
cloudflare_tunnel_token = data.vault_kv_secret_v2.secrets.data["cloudflare_tunnel_token"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,10 +20,39 @@
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{#if data.role === 'namespace-owner'}
|
||||||
|
<section>
|
||||||
|
<h2>Your Namespace</h2>
|
||||||
|
<p><strong>Assigned namespaces:</strong> {data.namespaces.join(', ')}</p>
|
||||||
|
|
||||||
|
<h3>Quick Commands</h3>
|
||||||
|
<pre>
|
||||||
|
# Check your pods
|
||||||
|
kubectl get pods -n {data.namespaces[0]}
|
||||||
|
|
||||||
|
# View quota usage
|
||||||
|
kubectl describe resourcequota -n {data.namespaces[0]}
|
||||||
|
|
||||||
|
# Log into Vault
|
||||||
|
vault login -method=oidc
|
||||||
|
|
||||||
|
# Store a secret
|
||||||
|
vault kv put secret/{data.username}/myapp KEY=value
|
||||||
|
|
||||||
|
# Get K8s deploy token
|
||||||
|
vault write kubernetes/creds/{data.namespaces[0]}-deployer \
|
||||||
|
kubernetes_namespace={data.namespaces[0]}</pre>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2>Get Started</h2>
|
<h2>Get Started</h2>
|
||||||
<ol>
|
<ol>
|
||||||
<li><a href="/onboarding">Complete the onboarding guide</a> (VPN, kubectl, git)</li>
|
{#if data.role === 'namespace-owner'}
|
||||||
|
<li><a href="/onboarding?role=namespace-owner">Complete the namespace-owner onboarding guide</a></li>
|
||||||
|
{:else}
|
||||||
|
<li><a href="/onboarding">Complete the onboarding guide</a> (VPN, kubectl, git)</li>
|
||||||
|
{/if}
|
||||||
<li><a href="/setup">Install kubectl and kubelogin</a></li>
|
<li><a href="/setup">Install kubectl and kubelogin</a></li>
|
||||||
<li><a href="/download">Download your kubeconfig</a></li>
|
<li><a href="/download">Download your kubeconfig</a></li>
|
||||||
<li>Run <code>kubectl get namespaces</code> to verify access</li>
|
<li>Run <code>kubectl get namespaces</code> to verify access</li>
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,59 @@
|
||||||
<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>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>
|
<p>Then reference it in your Terraform: <code>var.my_service_db_password</code></p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Namespace Owner Workflow</h2>
|
||||||
|
<p>If you are a namespace owner, you can deploy your own apps:</p>
|
||||||
|
<ol>
|
||||||
|
<li>Clone the infra repo: <code>git clone https://github.com/ViktorBarzin/infra.git</code></li>
|
||||||
|
<li>Copy the template: <code>cp -r stacks/_template stacks/your-app</code></li>
|
||||||
|
<li>Rename: <code>mv stacks/your-app/main.tf.example stacks/your-app/main.tf</code></li>
|
||||||
|
<li>Edit <code>main.tf</code> — replace all <code><placeholders></code></li>
|
||||||
|
<li>Store secrets in Vault: <code>vault kv put secret/your-username/your-app KEY=value</code></li>
|
||||||
|
<li>Add your app domain to your <code>domains</code> list in Vault KV</li>
|
||||||
|
<li>Submit a PR, get it reviewed</li>
|
||||||
|
<li>After merge, admin runs <code>terragrunt apply</code></li>
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>CI Pipeline Template</h2>
|
||||||
|
<p>Create a <code>.woodpecker.yml</code> in your app's Forgejo repo:</p>
|
||||||
|
<pre>{`steps:
|
||||||
|
- name: build
|
||||||
|
image: woodpeckerci/plugin-docker-buildx
|
||||||
|
settings:
|
||||||
|
repo: your-dockerhub-user/myapp
|
||||||
|
tag: ["\${CI_PIPELINE_NUMBER}", "latest"]
|
||||||
|
username:
|
||||||
|
from_secret: dockerhub-username
|
||||||
|
password:
|
||||||
|
from_secret: dockerhub-token
|
||||||
|
platforms: linux/amd64
|
||||||
|
|
||||||
|
- name: deploy
|
||||||
|
image: hashicorp/vault:1.18.1
|
||||||
|
commands:
|
||||||
|
- export VAULT_ADDR=http://vault-active.vault.svc.cluster.local:8200
|
||||||
|
- export VAULT_TOKEN=$(vault write -field=token auth/kubernetes/login
|
||||||
|
role=ci jwt=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token))
|
||||||
|
- KUBE_TOKEN=$(vault write -field=service_account_token
|
||||||
|
kubernetes/creds/YOUR_NAMESPACE-deployer
|
||||||
|
kubernetes_namespace=YOUR_NAMESPACE)
|
||||||
|
- kubectl --server=https://kubernetes.default.svc
|
||||||
|
--token=$KUBE_TOKEN
|
||||||
|
--certificate-authority=/var/run/secrets/kubernetes.io/serviceaccount/ca.crt
|
||||||
|
-n YOUR_NAMESPACE set image deployment/myapp
|
||||||
|
myapp=your-dockerhub-user/myapp:\${CI_PIPELINE_NUMBER}`}</pre>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Need a secret for your app?</h2>
|
||||||
|
<p>As a namespace owner, you manage your own secrets in Vault:</p>
|
||||||
|
<pre>vault kv put secret/your-username/your-app DB_PASSWORD=mysecret API_KEY=abc123</pre>
|
||||||
|
<p>Then reference them in your Terraform using a <code>data "vault_kv_secret_v2"</code> block.</p>
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,17 @@
|
||||||
|
<script>
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
let showNamespaceOwner = $derived($page.url.searchParams.get('role') === 'namespace-owner');
|
||||||
|
</script>
|
||||||
|
|
||||||
<main class="content">
|
<main class="content">
|
||||||
<h1>Getting Started</h1>
|
<h1>Getting Started</h1>
|
||||||
<p>Welcome! Follow these steps to get access to the home Kubernetes cluster.</p>
|
<p>Welcome! Follow these steps to get access to the home Kubernetes cluster.</p>
|
||||||
|
|
||||||
|
<div class="role-tabs">
|
||||||
|
<a href="/onboarding" class:active={!showNamespaceOwner}>General User</a>
|
||||||
|
<a href="/onboarding?role=namespace-owner" class:active={showNamespaceOwner}>Namespace Owner</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2>Step 0 — Join the VPN</h2>
|
<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>
|
<p>The cluster is on a private network (<code>10.0.20.0/24</code>). You need VPN access first.</p>
|
||||||
|
|
@ -35,45 +45,89 @@
|
||||||
<p>Use <a href="https://learn.microsoft.com/en-us/windows/wsl/install" target="_blank">WSL2</a> and follow the Linux instructions.</p>
|
<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>
|
||||||
|
|
||||||
<section>
|
{#if showNamespaceOwner}
|
||||||
<h2>Step 3 — Verify access</h2>
|
<section>
|
||||||
<p>Run this command. It will open your browser for login the first time:</p>
|
<h2>Step 3 — Log into Vault</h2>
|
||||||
<pre>kubectl get namespaces</pre>
|
<p>Vault manages your secrets and issues dynamic Kubernetes credentials.</p>
|
||||||
<p>You should see output like:</p>
|
<pre>vault login -method=oidc</pre>
|
||||||
<pre class="output">NAME STATUS AGE
|
<p>This opens your browser for Authentik SSO. After login, your token is saved to <code>~/.vault-token</code>.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Step 4 — Verify kubectl access</h2>
|
||||||
|
<p>Run this command. It will open your browser for OIDC login the first time:</p>
|
||||||
|
<pre>kubectl get pods -n YOUR_NAMESPACE</pre>
|
||||||
|
<p>You should see an empty list (no resources) or your running pods.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Step 5 — Clone the infra 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 6 — Create your first app stack</h2>
|
||||||
|
<ol>
|
||||||
|
<li>Copy the template: <pre>cp -r stacks/_template stacks/myapp
|
||||||
|
mv stacks/myapp/main.tf.example stacks/myapp/main.tf</pre></li>
|
||||||
|
<li>Edit <code>stacks/myapp/main.tf</code> — replace all <code><placeholders></code></li>
|
||||||
|
<li>Store secrets in Vault:
|
||||||
|
<pre>vault kv put secret/YOUR_USERNAME/myapp DB_PASSWORD=secret123</pre>
|
||||||
|
</li>
|
||||||
|
<li>Add your app domain to <code>domains</code> list in Vault KV <code>k8s_users</code></li>
|
||||||
|
<li>Submit a PR:
|
||||||
|
<pre>git checkout -b feat/myapp
|
||||||
|
git add stacks/myapp/
|
||||||
|
git commit -m "add myapp stack"
|
||||||
|
git push -u origin feat/myapp</pre>
|
||||||
|
</li>
|
||||||
|
<li>Viktor reviews and merges</li>
|
||||||
|
<li>After merge: <code>cd stacks/myapp && terragrunt apply</code></li>
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
{:else}
|
||||||
|
<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
|
default Active 200d
|
||||||
kube-system Active 200d
|
kube-system Active 200d
|
||||||
monitoring Active 200d
|
monitoring Active 200d
|
||||||
...</pre>
|
...</pre>
|
||||||
<p>If you get a connection error, make sure your VPN is connected (<code>tailscale status</code>).</p>
|
<p>If you get a connection error, make sure your VPN is connected (<code>tailscale status</code>).</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2>Step 4 — Clone the repo</h2>
|
<h2>Step 4 — Clone the repo</h2>
|
||||||
<pre>git clone https://github.com/ViktorBarzin/infra.git
|
<pre>git clone https://github.com/ViktorBarzin/infra.git
|
||||||
cd infra</pre>
|
cd infra</pre>
|
||||||
<p>This is where all the infrastructure configuration lives.</p>
|
<p>This is where all the infrastructure configuration lives.</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2>Step 5 — Install your AI assistant (optional)</h2>
|
<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>
|
<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>
|
<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>
|
<p>Codex reads the <code>AGENTS.md</code> file in the repo and knows how to work with the cluster.</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2>Step 6 — Your first change</h2>
|
<h2>Step 6 — Your first change</h2>
|
||||||
<ol>
|
<ol>
|
||||||
<li>Create a branch: <pre>git checkout -b my-first-change</pre></li>
|
<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>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>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>Open a Pull Request on GitHub</li>
|
||||||
<li>Viktor reviews and merges</li>
|
<li>Viktor reviews and merges</li>
|
||||||
<li>Woodpecker CI automatically applies the change to the cluster</li>
|
<li>Woodpecker CI automatically applies the change to the cluster</li>
|
||||||
<li>Slack notification confirms it worked</li>
|
<li>Slack notification confirms it worked</li>
|
||||||
</ol>
|
</ol>
|
||||||
</section>
|
</section>
|
||||||
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|
@ -86,4 +140,7 @@ cd infra</pre>
|
||||||
.content code { background: #f0f0f0; padding: 2px 6px; border-radius: 3px; }
|
.content code { background: #f0f0f0; padding: 2px 6px; border-radius: 3px; }
|
||||||
.content .prereq { font-size: 0.9rem; color: #666; font-style: italic; }
|
.content .prereq { font-size: 0.9rem; color: #666; font-style: italic; }
|
||||||
section { margin: 2rem 0; }
|
section { margin: 2rem 0; }
|
||||||
|
.role-tabs { display: flex; gap: 0; margin: 1.5rem 0; border-bottom: 2px solid #e0e0e0; }
|
||||||
|
.role-tabs a { padding: 0.5rem 1.5rem; text-decoration: none; color: #666; border-bottom: 2px solid transparent; margin-bottom: -2px; }
|
||||||
|
.role-tabs a.active { color: #333; border-bottom-color: #333; font-weight: 600; }
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,44 @@ else
|
||||||
echo "[OK] kubeseal installed"
|
echo "[OK] kubeseal installed"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Install Vault CLI
|
||||||
|
if command -v vault &>/dev/null; then
|
||||||
|
echo "[OK] vault already installed"
|
||||||
|
else
|
||||||
|
echo "[..] Installing Vault CLI..."
|
||||||
|
VAULT_VERSION="1.18.1"
|
||||||
|
curl -fsSLO "https://releases.hashicorp.com/vault/\${VAULT_VERSION}/vault_\${VAULT_VERSION}_linux_amd64.zip"
|
||||||
|
unzip -o "vault_\${VAULT_VERSION}_linux_amd64.zip" vault -d /tmp
|
||||||
|
\$SUDO mv /tmp/vault "\$INSTALL_DIR/"
|
||||||
|
rm -f "vault_\${VAULT_VERSION}_linux_amd64.zip"
|
||||||
|
echo "[OK] vault installed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Install Terragrunt
|
||||||
|
if command -v terragrunt &>/dev/null; then
|
||||||
|
echo "[OK] terragrunt already installed"
|
||||||
|
else
|
||||||
|
echo "[..] Installing terragrunt..."
|
||||||
|
TG_VERSION=\$(curl -fsSL -o /dev/null -w "%{url_effective}" https://github.com/gruntwork-io/terragrunt/releases/latest | grep -o '[^/]*\$')
|
||||||
|
curl -fsSLO "https://github.com/gruntwork-io/terragrunt/releases/download/\${TG_VERSION}/terragrunt_linux_amd64"
|
||||||
|
chmod +x terragrunt_linux_amd64
|
||||||
|
\$SUDO mv terragrunt_linux_amd64 "\$INSTALL_DIR/terragrunt"
|
||||||
|
echo "[OK] terragrunt installed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Install Terraform
|
||||||
|
if command -v terraform &>/dev/null; then
|
||||||
|
echo "[OK] terraform already installed"
|
||||||
|
else
|
||||||
|
echo "[..] Installing terraform..."
|
||||||
|
TF_VERSION="1.9.8"
|
||||||
|
curl -fsSLO "https://releases.hashicorp.com/terraform/\${TF_VERSION}/terraform_\${TF_VERSION}_linux_amd64.zip"
|
||||||
|
unzip -o "terraform_\${TF_VERSION}_linux_amd64.zip" terraform -d /tmp
|
||||||
|
\$SUDO mv /tmp/terraform "\$INSTALL_DIR/"
|
||||||
|
rm -f "terraform_\${TF_VERSION}_linux_amd64.zip"
|
||||||
|
echo "[OK] terraform installed"
|
||||||
|
fi
|
||||||
|
|
||||||
# Write kubeconfig
|
# Write kubeconfig
|
||||||
mkdir -p ~/.kube
|
mkdir -p ~/.kube
|
||||||
cat > ~/.kube/config-home << 'KUBECONFIG_EOF'
|
cat > ~/.kube/config-home << 'KUBECONFIG_EOF'
|
||||||
|
|
@ -168,6 +206,34 @@ else
|
||||||
echo "[OK] kubeseal installed"
|
echo "[OK] kubeseal installed"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Install Vault CLI
|
||||||
|
if command -v vault &>/dev/null; then
|
||||||
|
echo "[OK] vault already installed"
|
||||||
|
else
|
||||||
|
echo "[..] Installing Vault CLI..."
|
||||||
|
brew tap hashicorp/tap
|
||||||
|
brew install hashicorp/tap/vault
|
||||||
|
echo "[OK] vault installed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Install Terragrunt
|
||||||
|
if command -v terragrunt &>/dev/null; then
|
||||||
|
echo "[OK] terragrunt already installed"
|
||||||
|
else
|
||||||
|
echo "[..] Installing terragrunt..."
|
||||||
|
brew install terragrunt
|
||||||
|
echo "[OK] terragrunt installed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Install Terraform
|
||||||
|
if command -v terraform &>/dev/null; then
|
||||||
|
echo "[OK] terraform already installed"
|
||||||
|
else
|
||||||
|
echo "[..] Installing terraform..."
|
||||||
|
brew install hashicorp/tap/terraform
|
||||||
|
echo "[OK] terraform installed"
|
||||||
|
fi
|
||||||
|
|
||||||
# Write kubeconfig
|
# Write kubeconfig
|
||||||
mkdir -p ~/.kube
|
mkdir -p ~/.kube
|
||||||
cat > ~/.kube/config-home << 'KUBECONFIG_EOF'
|
cat > ~/.kube/config-home << 'KUBECONFIG_EOF'
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,14 @@ resource "kubernetes_deployment" "k8s_portal" {
|
||||||
|
|
||||||
volume_mount {
|
volume_mount {
|
||||||
name = "config"
|
name = "config"
|
||||||
mount_path = "/config"
|
mount_path = "/config/ca.crt"
|
||||||
|
sub_path = "ca.crt"
|
||||||
|
read_only = true
|
||||||
|
}
|
||||||
|
volume_mount {
|
||||||
|
name = "user-roles"
|
||||||
|
mount_path = "/config/users.json"
|
||||||
|
sub_path = "users.json"
|
||||||
read_only = true
|
read_only = true
|
||||||
}
|
}
|
||||||
resources {
|
resources {
|
||||||
|
|
@ -86,6 +93,12 @@ resource "kubernetes_deployment" "k8s_portal" {
|
||||||
name = kubernetes_config_map.k8s_portal_config.metadata[0].name
|
name = kubernetes_config_map.k8s_portal_config.metadata[0].name
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
volume {
|
||||||
|
name = "user-roles"
|
||||||
|
config_map {
|
||||||
|
name = "k8s-user-roles"
|
||||||
|
}
|
||||||
|
}
|
||||||
dns_config {
|
dns_config {
|
||||||
option {
|
option {
|
||||||
name = "ndots"
|
name = "ndots"
|
||||||
|
|
@ -95,6 +108,12 @@ resource "kubernetes_deployment" "k8s_portal" {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
lifecycle {
|
||||||
|
ignore_changes = [
|
||||||
|
spec[0].template[0].spec[0].dns_config,
|
||||||
|
spec[0].template[0].spec[0].container[0].image, # CI updates image tag
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
resource "kubernetes_service" "k8s_portal" {
|
resource "kubernetes_service" "k8s_portal" {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ variable "k8s_users" {
|
||||||
role = string # "admin", "power-user", "namespace-owner"
|
role = string # "admin", "power-user", "namespace-owner"
|
||||||
email = string # OIDC email claim
|
email = string # OIDC email claim
|
||||||
namespaces = optional(list(string), []) # for namespace-owners
|
namespaces = optional(list(string), []) # for namespace-owners
|
||||||
|
domains = optional(list(string), []) # subdomains for user apps
|
||||||
quota = optional(object({
|
quota = optional(object({
|
||||||
cpu_requests = optional(string, "2")
|
cpu_requests = optional(string, "2")
|
||||||
memory_requests = optional(string, "4Gi")
|
memory_requests = optional(string, "4Gi")
|
||||||
|
|
@ -248,3 +249,15 @@ resource "kubernetes_config_map" "user_roles" {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# TLS secret in each user namespace (so they can create HTTPS ingresses)
|
||||||
|
module "user_namespace_tls" {
|
||||||
|
for_each = nonsensitive(toset(flatten([
|
||||||
|
for name, user in var.k8s_users : user.namespaces
|
||||||
|
if user.role == "namespace-owner"
|
||||||
|
])))
|
||||||
|
|
||||||
|
source = "../../../../modules/kubernetes/setup_tls_secret"
|
||||||
|
namespace = each.value
|
||||||
|
tls_secret_name = var.tls_secret_name
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -317,6 +317,10 @@ resource "vault_policy" "ci" {
|
||||||
path "secret/metadata/*" {
|
path "secret/metadata/*" {
|
||||||
capabilities = ["list"]
|
capabilities = ["list"]
|
||||||
}
|
}
|
||||||
|
# Allow CI to get dynamic K8s deploy tokens for user namespaces
|
||||||
|
path "kubernetes/creds/*-deployer" {
|
||||||
|
capabilities = ["read"]
|
||||||
|
}
|
||||||
EOT
|
EOT
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -654,3 +658,133 @@ resource "vault_kubernetes_secret_backend_role" "local_admin" {
|
||||||
kubernetes_role_type = "ClusterRole"
|
kubernetes_role_type = "ClusterRole"
|
||||||
kubernetes_role_name = "cluster-admin"
|
kubernetes_role_name = "cluster-admin"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Multi-User Namespace Onboarding
|
||||||
|
# =============================================================================
|
||||||
|
# All resources below are auto-generated from the k8s_users map in Vault KV.
|
||||||
|
# Adding a new user requires only a JSON entry in secret/platform → k8s_users.
|
||||||
|
|
||||||
|
data "vault_kv_secret_v2" "platform" {
|
||||||
|
mount = "secret"
|
||||||
|
name = "platform"
|
||||||
|
depends_on = [helm_release.vault]
|
||||||
|
}
|
||||||
|
|
||||||
|
locals {
|
||||||
|
k8s_users = jsondecode(data.vault_kv_secret_v2.platform.data["k8s_users"])
|
||||||
|
|
||||||
|
# Flatten user -> namespace pairs for namespace-owners
|
||||||
|
namespace_owner_namespaces = flatten([
|
||||||
|
for name, user in local.k8s_users : [
|
||||||
|
for ns in user.namespaces : {
|
||||||
|
user_key = name
|
||||||
|
namespace = ns
|
||||||
|
email = user.email
|
||||||
|
}
|
||||||
|
] if user.role == "namespace-owner"
|
||||||
|
])
|
||||||
|
|
||||||
|
# Unique namespaces across all namespace-owners
|
||||||
|
user_namespaces = toset(flatten([
|
||||||
|
for name, user in local.k8s_users : user.namespaces
|
||||||
|
if user.role == "namespace-owner"
|
||||||
|
]))
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "kubernetes_namespace" "user_namespace" {
|
||||||
|
for_each = local.user_namespaces
|
||||||
|
|
||||||
|
metadata {
|
||||||
|
name = each.value
|
||||||
|
labels = {
|
||||||
|
tier = "4-aux"
|
||||||
|
"resource-governance/custom-quota" = "true"
|
||||||
|
"managed-by" = "vault-user-onboarding"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "vault_policy" "namespace_owner" {
|
||||||
|
for_each = nonsensitive({
|
||||||
|
for name, user in local.k8s_users : name => user
|
||||||
|
if user.role == "namespace-owner"
|
||||||
|
})
|
||||||
|
|
||||||
|
name = "namespace-owner-${each.key}"
|
||||||
|
policy = <<-EOT
|
||||||
|
# Read/write own secrets
|
||||||
|
path "secret/data/${each.key}" {
|
||||||
|
capabilities = ["create", "read", "update", "delete", "list"]
|
||||||
|
}
|
||||||
|
path "secret/data/${each.key}/*" {
|
||||||
|
capabilities = ["create", "read", "update", "delete", "list"]
|
||||||
|
}
|
||||||
|
path "secret/metadata/${each.key}" {
|
||||||
|
capabilities = ["list", "read", "delete"]
|
||||||
|
}
|
||||||
|
path "secret/metadata/${each.key}/*" {
|
||||||
|
capabilities = ["list", "read", "delete"]
|
||||||
|
}
|
||||||
|
%{for ns in each.value.namespaces}
|
||||||
|
# Dynamic K8s credentials for ${ns} namespace
|
||||||
|
path "kubernetes/creds/${ns}-deployer" {
|
||||||
|
capabilities = ["read"]
|
||||||
|
}
|
||||||
|
%{endfor}
|
||||||
|
EOT
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "vault_identity_entity" "namespace_owner" {
|
||||||
|
for_each = nonsensitive({
|
||||||
|
for name, user in local.k8s_users : name => user
|
||||||
|
if user.role == "namespace-owner"
|
||||||
|
})
|
||||||
|
|
||||||
|
name = each.key
|
||||||
|
policies = [vault_policy.namespace_owner[each.key].name]
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "vault_identity_entity_alias" "namespace_owner" {
|
||||||
|
for_each = nonsensitive({
|
||||||
|
for name, user in local.k8s_users : name => user
|
||||||
|
if user.role == "namespace-owner"
|
||||||
|
})
|
||||||
|
|
||||||
|
name = each.value.email
|
||||||
|
mount_accessor = vault_jwt_auth_backend.oidc.accessor
|
||||||
|
canonical_id = vault_identity_entity.namespace_owner[each.key].id
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "kubernetes_role" "user_deployer" {
|
||||||
|
for_each = local.user_namespaces
|
||||||
|
|
||||||
|
metadata {
|
||||||
|
name = "${each.value}-deployer"
|
||||||
|
namespace = each.value
|
||||||
|
}
|
||||||
|
rule {
|
||||||
|
api_groups = ["apps"]
|
||||||
|
resources = ["deployments"]
|
||||||
|
verbs = ["get", "list", "patch", "update"]
|
||||||
|
}
|
||||||
|
rule {
|
||||||
|
api_groups = [""]
|
||||||
|
resources = ["pods"]
|
||||||
|
verbs = ["get", "list"]
|
||||||
|
}
|
||||||
|
|
||||||
|
depends_on = [kubernetes_namespace.user_namespace]
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "vault_kubernetes_secret_backend_role" "user_deployer" {
|
||||||
|
for_each = local.user_namespaces
|
||||||
|
|
||||||
|
backend = vault_kubernetes_secret_backend.k8s.path
|
||||||
|
name = "${each.value}-deployer"
|
||||||
|
allowed_kubernetes_namespaces = [each.value]
|
||||||
|
token_default_ttl = 1800
|
||||||
|
token_max_ttl = 3600
|
||||||
|
kubernetes_role_type = "Role"
|
||||||
|
kubernetes_role_name = kubernetes_role.user_deployer[each.key].metadata[0].name
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,20 @@ data "vault_kv_secret_v2" "secrets" {
|
||||||
name = "woodpecker"
|
name = "woodpecker"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data "vault_kv_secret_v2" "platform" {
|
||||||
|
mount = "secret"
|
||||||
|
name = "platform"
|
||||||
|
}
|
||||||
|
|
||||||
|
locals {
|
||||||
|
k8s_users = jsondecode(data.vault_kv_secret_v2.platform.data["k8s_users"])
|
||||||
|
|
||||||
|
# Build admin list: existing admin + all namespace-owner usernames
|
||||||
|
woodpecker_admins = join(",", concat(
|
||||||
|
["ViktorBarzin"],
|
||||||
|
[for name, user in local.k8s_users : name if user.role == "namespace-owner"]
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
resource "kubernetes_namespace" "woodpecker" {
|
resource "kubernetes_namespace" "woodpecker" {
|
||||||
metadata {
|
metadata {
|
||||||
|
|
@ -206,6 +220,7 @@ resource "helm_release" "woodpecker" {
|
||||||
forgejo_client_id = data.vault_kv_secret_v2.secrets.data["forgejo_client_id"]
|
forgejo_client_id = data.vault_kv_secret_v2.secrets.data["forgejo_client_id"]
|
||||||
forgejo_client_secret = data.vault_kv_secret_v2.secrets.data["forgejo_client_secret"]
|
forgejo_client_secret = data.vault_kv_secret_v2.secrets.data["forgejo_client_secret"]
|
||||||
forgejo_url = var.woodpecker_forgejo_url
|
forgejo_url = var.woodpecker_forgejo_url
|
||||||
|
woodpecker_admins = local.woodpecker_admins
|
||||||
})
|
})
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,8 @@ server:
|
||||||
tag: "v3.13.0"
|
tag: "v3.13.0"
|
||||||
env:
|
env:
|
||||||
WOODPECKER_HOST: "https://ci.viktorbarzin.me"
|
WOODPECKER_HOST: "https://ci.viktorbarzin.me"
|
||||||
WOODPECKER_ADMIN: "ViktorBarzin"
|
WOODPECKER_ADMIN: "${woodpecker_admins}"
|
||||||
WOODPECKER_OPEN: "false"
|
WOODPECKER_OPEN: "true"
|
||||||
WOODPECKER_GITHUB: "true"
|
WOODPECKER_GITHUB: "true"
|
||||||
WOODPECKER_GITHUB_URL: "https://github.com"
|
WOODPECKER_GITHUB_URL: "https://github.com"
|
||||||
WOODPECKER_GITHUB_CLIENT: "${github_client_id}"
|
WOODPECKER_GITHUB_CLIENT: "${github_client_id}"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue