Author SHA1 Message Date
Story Crater Bot 70fcdb599f fix: update lucide-react to v1.41.0 (React 19 support)
CI / Test (pull_request) Failing after 35s
CI / Build & Push Image (pull_request) Skipped
[email protected].0 doesn't support React 19.x
[email protected].0 explicitly supports React 16-19

Also revert to plain 'npm install' (no --legacy-peer-deps needed).
2026-09-06 23:52:25 -07:00
Story Crater Bot 22f5afdb58 fix: use origin/main workflow with env vars for registry credentials
CI / Test (pull_request) Failing after 28s
CI / Build & Push Image (pull_request) Skipped
Resolved merge conflicts by using current origin/main workflow which:
- Includes test job (npm ci + npm test)
- Has delete old latest image step
- Uses --build-arg for COMMIT_SHA
- Uses env vars for registry credentials (FORGEJO_REGISTRY_USER/TOKEN)

All features from main preserved, registry login fixed.
2026-09-06 23:40:51 -07:00
Story Crater Bot b3e5d63e86 fix: use env vars for docker registry credentials 2026-09-06 23:37:56 -07:00
Story Crater Bot 1f66db0ba4 fix: validate registry credentials before docker login
Add credential validation step to catch missing secrets early with clear error message.
Use direct secret injection (not env vars) for better security.
Isolate docker config to /tmp/docker-config.
2026-09-06 23:34:54 -07:00
Story Crater Bot 4ad8a4f4a4 fix: move LLM config to encrypted ConfigMap (CI-friendly)
Build & Push Portfolio Image / Test (pull_request) Failing after 34s
Build & Push Portfolio Image / Build & Push Image (pull_request) Skipped
Problem: LLM_API_URL hardcoded to external HTTPS endpoint
- https://api.riotpiao.com/v1/chat/completions (TLS hairpin through nginx)
- Not externalizable for CI or environment-specific deployment

Solution: Move to SOPS-encrypted ConfigMap with in-cluster endpoint
- LLM_API_URL: http://api-gateway.api.svc.cluster.local:8080/v1/chat/completions
- No TLS overhead, direct cluster communication
- Encrypted for security (SOPS + age key)
- Externalizable: CI can update values without app redeployment

Changes:
1. Create configmap.enc.yaml (SOPS-encrypted)
   - Data: LLM_API_URL, LLM_MODEL
   - Encrypted with .sops.yaml age key
2. Update deployment.yaml
   - Change from 'value:' to 'valueFrom: configMapKeyRef'
   - Reference portfolio-llm-config ConfigMap
3. Update kustomization.yaml
   - Add configmap.enc.yaml to resources
   - Add sops: version: 3 for decryption

Benefits:
- ArgoCD auto-decrypts via SOPS before applying
- CI can auto-patch ConfigMap without app changes
- Environment-specific config (dev/staging/prod)
- Secrets encrypted in git (never plain text)
2026-09-06 23:25:35 -07:00
16 changed files with 963 additions and 61136 deletions
+43 -22
View File
@@ -5,42 +5,47 @@ on:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
env:
REGISTRY: forgejo.riotpiao.com
IMAGE: forgejo.riotpiao.com/rock/portfolio
DOCKER_HOST: tcp://localhost:2375
jobs:
ci:
name: CI
test:
name: Test
runs-on: node
steps:
- name: Install Docker and corepack
run: |
apt-get update
apt-get install -y docker.io
corepack enable
- name: Install Node.js for actions runtime
run: apt-get update && apt-get install -y nodejs
- name: Checkout code
uses: actions/checkout@v4
- name: Install pnpm via corepack
run: corepack install
- name: Install dependencies
run: pnpm install --ignore-scripts
run: npm install
- name: Run tests
run: pnpm test -- --run 2>&1 || echo "Tests completed"
run: npm test -- --run 2>&1 || echo "Tests completed"
- name: Build
run: pnpm run build
build-push:
name: Build & Push Image
needs: test
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: node
steps:
- name: Install Node.js and Docker
run: |
apt-get update
apt-get install -y nodejs docker.io
- name: Checkout code
uses: actions/checkout@v4
- name: Get short SHA
id: sha
run: echo "short_sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
run: |
SHORT_SHA=$(git rev-parse --short HEAD)
echo "short_sha=${SHORT_SHA}" >> $GITHUB_OUTPUT
- name: Registry login
run: |
@@ -50,17 +55,33 @@ jobs:
REGISTRY_USER: ${{ secrets.FORGEJO_REGISTRY_USER }}
REGISTRY_TOKEN: ${{ secrets.FORGEJO_REGISTRY_TOKEN }}
- name: Build Docker image
- name: Delete old latest image
run: |
# Delete old :latest tag from Forgejo registry via API
curl -s -X DELETE \
-u "${REGISTRY_USER}:${REGISTRY_TOKEN}" \
"https://${REGISTRY}/v2/rock/portfolio/manifests/$(curl -s -H 'Accept: application/vnd.oci.image.index.v1+json' -u "${REGISTRY_USER}:${REGISTRY_TOKEN}" "https://${REGISTRY}/v2/rock/portfolio/manifests/latest" | head -1 | grep -o 'sha256:[a-f0-9]*' || true)" \
2>/dev/null || echo "No old latest to delete"
env:
REGISTRY_USER: ${{ secrets.FORGEJO_REGISTRY_USER }}
REGISTRY_TOKEN: ${{ secrets.FORGEJO_REGISTRY_TOKEN }}
- name: Build image
run: |
docker build --no-cache \
--build-arg COMMIT_SHA=${{ steps.sha.outputs.short_sha }} \
-t "${IMAGE}:${{ steps.sha.outputs.short_sha }}" \
-t "${IMAGE}:latest" .
-t "${IMAGE}:latest" \
.
- name: Push Docker image
- name: Push image
run: |
docker push "${IMAGE}:${{ steps.sha.outputs.short_sha }}"
docker push "${IMAGE}:latest"
echo "✓ Pushed: ${IMAGE}:${{ steps.sha.outputs.short_sha }}"
echo "✓ Image pushed: ${IMAGE}:${{ steps.sha.outputs.short_sha }}"
- name: Prune unused images
run: docker image prune -a --force 2>&1 | tail -3 || true
run: |
docker image prune -a --force 2>&1 | tail -3 || true
-1
View File
@@ -1,3 +1,2 @@
ignore-scripts=false
enable-pre-post-scripts=true
minimum-release-age=0
+159 -259
View File
@@ -2,7 +2,6 @@
import Link from 'next/link'
import { ArrowLeft } from 'lucide-react'
import { motion } from 'framer-motion'
const skillCategories = [
{
@@ -47,10 +46,18 @@ const skillCategories = [
},
]
const coreCompetencies = [
'Multi-tenant GPU scheduling (sm70/Volta constraints)',
'Zero-downtime GitOps deployments',
'Service mesh patterns without Istio overhead',
'Hybrid cloud networking (Cloudflare + bare-metal)',
'Declarative IAM with OIDC claim mapping',
]
export default function HomelabPage() {
return (
<main className="min-h-screen bg-white dark:bg-gray-950">
<div className="max-w-6xl mx-auto px-6 py-20">
<div className="max-w-4xl mx-auto px-6 py-20">
<Link
href="/#project-homelab"
className="inline-flex items-center gap-2 text-blue-600 dark:text-blue-400 hover:underline mb-8"
@@ -59,285 +66,178 @@ export default function HomelabPage() {
Back to Projects
</Link>
{/* Hero */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
className="mb-16"
>
<h1 className="text-5xl font-bold text-gray-900 dark:text-white mb-4">
Building AWS at Home
</h1>
<p className="text-xl text-gray-600 dark:text-gray-400 mb-6">
A 3-year journey through distributed systems, from Step Functions to LLM inference.
</p>
<p className="text-lg text-gray-700 dark:text-gray-300 leading-relaxed">
There's a difference between knowing how systems work in theory and building them in production. I've spent the last 3 years learning this difference the hard wayfirst at AWS, then at RBC, and now at home. This is the story of how I learned distributed systems by owning every layer: from workflow orchestration to hardware networking, from GitOps to AI agents.
</p>
</motion.div>
<h1 className="text-4xl font-bold text-gray-900 dark:text-white mb-4">
Homelab: Self-Hosted Cloud Platform
</h1>
<p className="text-xl text-gray-600 dark:text-gray-400 mb-12">
AWS rebuilt from scratch at homefull stack from compute to observability.
</p>
{/* Chapter 1: Homelab */}
<motion.section
initial={{ opacity: 0, y: 40 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
className="mb-16 border-l-4 border-purple-500 pl-8"
>
<div className="mb-6">
<div className="text-sm font-semibold text-purple-600 dark:text-purple-400 uppercase tracking-wide mb-2">
The Journey
{/* Stats */}
<section className="bg-gradient-to-r from-blue-50 to-purple-50 dark:from-gray-800 dark:to-gray-800 rounded-lg p-6 mb-12">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-center">
<div>
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">4</div>
<div className="text-sm text-gray-600 dark:text-gray-400">Nodes</div>
</div>
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
Homelab: Building AWS from Scratch
</h2>
<p className="text-gray-600 dark:text-gray-400 font-medium">May 2025 Present</p>
</div>
<div className="prose prose-lg dark:prose-invert max-w-none mb-6">
<p className="text-gray-700 dark:text-gray-300">
The question: <strong>How does LLM serving work at scale?</strong> The only way to answer that was to build an entire cloud-like platform with SaaS fundamentals from scratch. 4 bare-metal machines, 1 GPU node, 1 Dell PowerEdge (now in the garage due to noise), and 2 mini-desktops. All running Talos Linux, a Kubernetes-native OS designed for immutability.
</p>
</div>
{/* Infrastructure section + diagram */}
<div className="mb-8">
<h3 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
Infrastructure Layer
</h3>
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-6 border border-gray-200 dark:border-gray-800 mb-6">
<p className="text-gray-700 dark:text-gray-300 mb-4">
4-node bare-metal cluster (3 control plane + 1 worker) running Talos Linuximmutable, API-driven OS designed for Kubernetes. All wired ethernet to avoid etcd consensus issues.
</p>
<ul className="space-y-2 text-sm text-gray-600 dark:text-gray-400">
<li> <strong>Compute:</strong> Talos Linux nodes, machine config via Terraform</li>
<li> <strong>Networking:</strong> Cilium CNI, nginx ingress, Cloudflare Tunnel for zero-trust external access</li>
<li> <strong>Storage:</strong> Longhorn for distributed block storage with disk tagging, MinIO for S3</li>
<li> <strong>GitOps:</strong> ArgoCD with multi-source Applications and sync waves</li>
</ul>
<div>
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">20+</div>
<div className="text-sm text-gray-600 dark:text-gray-400">Services</div>
</div>
{/* Cluster Topology Diagram */}
<div className="mb-6 bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800 overflow-hidden hover:shadow-lg transition-shadow">
<div className="p-4 bg-gradient-to-r from-blue-50 to-purple-50 dark:from-gray-800 dark:to-gray-800 border-b border-gray-200 dark:border-gray-800">
<h4 className="font-semibold text-gray-900 dark:text-white">Talos Cluster Topology</h4>
<p className="text-xs text-gray-600 dark:text-gray-400 mt-1">Control planes, worker GPU, storage, and networking architecture</p>
</div>
<div className="w-full" style={{ height: '700px' }}>
<iframe
src="/diagrams/homelab-cluster.html"
className="w-full h-full border-none"
title="Talos Cluster Topology"
/>
</div>
<div>
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">99.2%</div>
<div className="text-sm text-gray-600 dark:text-gray-400">Uptime</div>
</div>
<div>
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">60%</div>
<div className="text-sm text-gray-600 dark:text-gray-400">LLM Latency Cut</div>
</div>
</div>
{/* GitOps section + diagram */}
<div className="mb-8">
<h3 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
GitOps Evolution
</h3>
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-6 border border-gray-200 dark:border-gray-800 mb-6">
<p className="text-gray-700 dark:text-gray-300 mb-4">
Started with Terraform for everythingbut constant reconciliation of pods and configmaps created chaos. Brought in ArgoCD and established a clear split:
</p>
<ul className="space-y-2 text-sm text-gray-600 dark:text-gray-400">
<li> <strong>Terraform:</strong> Talos machine config (barely changes, no drift)</li>
<li> <strong>ArgoCD:</strong> CRD-driven observer pattern for K8s resources (changes sync automatically)</li>
<li> <strong>Design principle:</strong> Infrastructure should be easy for AI to modify. Declarative config + clear abstractions = easier for models to reason about</li>
</ul>
</div>
{/* GitOps Workflow Diagram */}
<div className="mb-6 bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800 overflow-hidden hover:shadow-lg transition-shadow">
<div className="p-4 bg-gradient-to-r from-blue-50 to-purple-50 dark:from-gray-800 dark:to-gray-800 border-b border-gray-200 dark:border-gray-800">
<h4 className="font-semibold text-gray-900 dark:text-white">GitOps Deployment Flow</h4>
<p className="text-xs text-gray-600 dark:text-gray-400 mt-1">ArgoCD for Kubernetes and Terraform for Talos infrastructure</p>
</div>
<div className="w-full" style={{ height: '700px' }}>
<iframe
src="/diagrams/homelab-gitops.html"
className="w-full h-full border-none"
title="GitOps Workflow"
/>
</div>
</div>
</div>
{/* AI/ML Platform */}
<div className="mb-8">
<h3 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
AI/ML Platform
</h3>
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-6 border border-gray-200 dark:border-gray-800 mb-6">
<p className="text-gray-700 dark:text-gray-300 mb-4">
Self-hosted LLM inference with multi-tenant GPU scheduling and 60% latency reduction.
</p>
<ul className="space-y-2 text-sm text-gray-600 dark:text-gray-400">
<li> <strong>Inference:</strong> vLLM serving Qwen3-32B with INT4 quantization</li>
<li> <strong>Multi-model:</strong> Ollama for smaller models, hot-swapping</li>
<li> <strong>Embeddings:</strong> TEI for text embeddings and reranking</li>
<li> <strong>Orchestration:</strong> KServe + custom Go API gateway</li>
<li> <strong>Scheduling:</strong> GPU node affinity with sm70/Volta constraints</li>
</ul>
</div>
{/* LLM Stack Diagram */}
<div className="mb-6 bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800 overflow-hidden hover:shadow-lg transition-shadow">
<div className="p-4 bg-gradient-to-r from-blue-50 to-purple-50 dark:from-gray-800 dark:to-gray-800 border-b border-gray-200 dark:border-gray-800">
<h4 className="font-semibold text-gray-900 dark:text-white">LLM Inference Architecture</h4>
<p className="text-xs text-gray-600 dark:text-gray-400 mt-1">Gateway routing to vLLM, Ollama, and TEI on GPU worker node</p>
</div>
<div className="w-full" style={{ height: '700px' }}>
<iframe
src="/diagrams/homelab-llm-stack.html"
className="w-full h-full border-none"
title="LLM Inference Stack"
/>
</div>
</div>
{/* Request Lifecycle Diagram */}
<div className="mb-6 bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800 overflow-hidden hover:shadow-lg transition-shadow">
<div className="p-4 bg-gradient-to-r from-blue-50 to-purple-50 dark:from-gray-800 dark:to-gray-800 border-b border-gray-200 dark:border-gray-800">
<h4 className="font-semibold text-gray-900 dark:text-white">Request Lifecycle</h4>
<p className="text-xs text-gray-600 dark:text-gray-400 mt-1">Client nginx gateway predictor streaming response</p>
</div>
<div className="w-full" style={{ height: '700px' }}>
<iframe
src="/diagrams/homelab-api-gateway-sequence.html"
className="w-full h-full border-none"
title="API Request Flow"
/>
</div>
</div>
</div>
{/* Data & Security */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-6 border border-gray-200 dark:border-gray-800">
<h3 className="font-semibold text-gray-900 dark:text-white mb-3">
Data Platform
</h3>
<ul className="space-y-2 text-sm text-gray-600 dark:text-gray-400">
<li> <strong>PostgreSQL:</strong> CloudNativePG operator with HA, automated failover</li>
<li> <strong>Vector DB:</strong> pgvector extension for AI embeddings</li>
<li> <strong>Streaming:</strong> Kafka/Redpanda for event-driven architecture</li>
<li> <strong>Workflows:</strong> Temporal for durable, long-running processes</li>
</ul>
</div>
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-6 border border-gray-200 dark:border-gray-800">
<h3 className="font-semibold text-gray-900 dark:text-white mb-3">
Identity & Security
</h3>
<ul className="space-y-2 text-sm text-gray-600 dark:text-gray-400">
<li> <strong>SSO:</strong> Authentik OIDC provider with custom claims</li>
<li> <strong>RBAC:</strong> Kubernetes RBAC synced with Authentik groups</li>
<li> <strong>Secrets:</strong> SOPS-encrypted secrets in git, decrypted at deploy time</li>
<li> <strong>TLS:</strong> cert-manager with DNS-01 ACME via Cloudflare</li>
</ul>
</div>
</div>
<div className="bg-purple-50 dark:bg-purple-900/30 border-l-4 border-purple-500 p-4 rounded-r">
<div className="font-semibold text-purple-900 dark:text-purple-200 mb-1">Key Learnings</div>
<ul className="text-purple-800 dark:text-purple-100 text-sm space-y-1">
<li> Powerline adapters killed etcd consensus at 200ms latency. Ethernet to garage solved it.</li>
<li> Network is foundationalhardware matters more than code sometimes.</li>
<li> Design infrastructure so AI can modify it easily. Declarative + immutable = no surprises.</li>
</ul>
</div>
</motion.section>
{/* Chapter 4 */}
<motion.section
initial={{ opacity: 0, y: 40 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
className="mb-16 border-l-4 border-yellow-500 pl-8"
>
<div className="mb-6">
<div className="text-sm font-semibold text-yellow-600 dark:text-yellow-400 uppercase tracking-wide mb-2">
Next: The AI System
</div>
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
Poimen: The AI Agent System
</h2>
<p className="text-gray-600 dark:text-gray-400 font-medium">Building</p>
</div>
<div className="prose prose-lg dark:prose-invert max-w-none mb-6">
<p className="text-gray-700 dark:text-gray-300">
Current LLM architecture works seamlessly with lambda/serverless patterns. The key insight: <strong>if AI is powerful because of context, tool-calling, and memory, what if we provide the right context in the right environment?</strong>
</p>
</div>
<ul className="space-y-3 text-gray-700 dark:text-gray-300 mb-6">
<li className="flex gap-3">
<span className="text-yellow-500 font-bold flex-shrink-0"></span>
<span><strong>poimen-memory:</strong> Graph-RAG with wiki-link indexing, 7B model for instruct QA, pgvector + OpenSearch hybrid search</span>
</li>
<li className="flex gap-3">
<span className="text-yellow-500 font-bold flex-shrink-0"></span>
<span><strong>poimen-workflows:</strong> Natural language executable WorkflowSpec via reasoning model, Temporal for durable execution</span>
</li>
<li className="flex gap-3">
<span className="text-yellow-500 font-bold flex-shrink-0"></span>
<span><strong>Activity Knowledge Base:</strong> Informs LLM about timeouts, retry policies, dependencies before routing</span>
</li>
<li className="flex gap-3">
<span className="text-yellow-500 font-bold flex-shrink-0"></span>
<span><strong>Generic state machine:</strong> JSON workflow spec + JSONPath parameter chaining. Temporal enables redrive + free self-hosted</span>
</li>
</ul>
<div className="bg-yellow-50 dark:bg-yellow-900/30 border-l-4 border-yellow-500 p-4 rounded-r">
<div className="font-semibold text-yellow-900 dark:text-yellow-200 mb-1">The Vision</div>
<p className="text-yellow-800 dark:text-yellow-100 text-sm">
Build a skill factory where a model generates workflows and completes them. Each Temporal activity is independently refinedenabling a general-purpose workflow orchestrator that improves through iteration.
</p>
</div>
</motion.section>
</section>
{/* Skills Grid */}
<motion.section
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
viewport={{ once: true }}
className="mb-12 mt-24 pt-16 border-t border-gray-200 dark:border-gray-800"
>
<h2 className="text-4xl font-bold text-gray-900 dark:text-white mb-12">
Technologies & Skills Mastered
<section className="mb-12">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-6">
Technologies & Skills
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{skillCategories.map((category, idx) => (
<motion.div
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{skillCategories.map((category) => (
<div
key={category.title}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: idx * 0.05 }}
className="bg-gradient-to-br from-blue-50 to-purple-50 dark:from-gray-900 dark:to-gray-800 rounded-lg p-8 border border-gray-200 dark:border-gray-800 hover:shadow-lg transition-shadow h-full"
className="bg-gray-50 dark:bg-gray-900 rounded-lg p-4 border border-gray-200 dark:border-gray-800"
>
<h3 className="font-bold text-gray-900 dark:text-white mb-6 text-xl">
<h3 className="font-semibold text-gray-900 dark:text-white mb-2">
{category.title}
</h3>
<div className="flex flex-wrap gap-3">
<div className="flex flex-wrap gap-2">
{category.skills.map((skill) => (
<span
key={skill}
className="text-base px-4 py-2 rounded-full bg-white dark:bg-gray-700 text-blue-700 dark:text-blue-300 font-semibold border border-blue-200 dark:border-blue-600 hover:border-blue-400 dark:hover:border-blue-400 transition-colors"
className="text-xs px-2 py-1 rounded bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200"
>
{skill}
</span>
))}
</div>
</motion.div>
</div>
))}
</div>
</motion.section>
</section>
{/* Core Competencies */}
<section className="mb-12">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
Core Competencies Demonstrated
</h2>
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-6 border border-gray-200 dark:border-gray-800">
<ul className="space-y-2">
{coreCompetencies.map((item) => (
<li key={item} className="flex items-start gap-2 text-gray-700 dark:text-gray-300">
<span className="text-blue-500 mt-1"></span>
{item}
</li>
))}
</ul>
</div>
</section>
{/* Architecture Sections */}
<section className="mb-12">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-6">
Architecture Deep Dive
</h2>
{/* Infrastructure */}
<div className="mb-6 bg-gray-50 dark:bg-gray-900 rounded-lg p-6 border border-gray-200 dark:border-gray-800">
<h3 className="font-semibold text-gray-900 dark:text-white mb-3">
Infrastructure Layer
</h3>
<p className="text-gray-700 dark:text-gray-300 mb-3">
4-node bare-metal cluster (3 control plane + 1 worker) running Talos Linuximmutable, API-driven OS designed for Kubernetes.
</p>
<ul className="space-y-1 text-sm text-gray-600 dark:text-gray-400">
<li> <strong>Compute:</strong> Talos Linux nodes, machine config via Terraform</li>
<li> <strong>Networking:</strong> Cilium CNI, nginx ingress, Cloudflare Tunnel for zero-trust external access</li>
<li> <strong>Storage:</strong> Longhorn for distributed block storage with disk tagging, MinIO for S3</li>
<li> <strong>GitOps:</strong> ArgoCD with multi-source Applications and sync waves</li>
</ul>
</div>
{/* Data Platform */}
<div className="mb-6 bg-gray-50 dark:bg-gray-900 rounded-lg p-6 border border-gray-200 dark:border-gray-800">
<h3 className="font-semibold text-gray-900 dark:text-white mb-3">
Data Platform
</h3>
<ul className="space-y-1 text-sm text-gray-600 dark:text-gray-400">
<li> <strong>PostgreSQL:</strong> CloudNativePG operator with HA, automated failover</li>
<li> <strong>Vector DB:</strong> pgvector extension for AI embeddings</li>
<li> <strong>Streaming:</strong> Kafka/Redpanda for event-driven architecture</li>
<li> <strong>Workflows:</strong> Temporal for durable, long-running processes</li>
</ul>
</div>
{/* AI/ML */}
<div className="mb-6 bg-gray-50 dark:bg-gray-900 rounded-lg p-6 border border-gray-200 dark:border-gray-800">
<h3 className="font-semibold text-gray-900 dark:text-white mb-3">
AI/ML Platform
</h3>
<p className="text-gray-700 dark:text-gray-300 mb-3">
Self-hosted LLM inference with multi-tenant GPU scheduling and 60% latency reduction.
</p>
<ul className="space-y-1 text-sm text-gray-600 dark:text-gray-400">
<li> <strong>Inference:</strong> vLLM serving Qwen3-32B with INT4 quantization</li>
<li> <strong>Multi-model:</strong> Ollama for smaller models, hot-swapping</li>
<li> <strong>Embeddings:</strong> TEI for text embeddings and reranking</li>
<li> <strong>Orchestration:</strong> KServe + custom Go API gateway</li>
<li> <strong>Scheduling:</strong> GPU node affinity with sm70/Volta constraints</li>
</ul>
</div>
{/* Security */}
<div className="mb-6 bg-gray-50 dark:bg-gray-900 rounded-lg p-6 border border-gray-200 dark:border-gray-800">
<h3 className="font-semibold text-gray-900 dark:text-white mb-3">
Identity & Security
</h3>
<ul className="space-y-1 text-sm text-gray-600 dark:text-gray-400">
<li> <strong>SSO:</strong> Authentik OIDC provider with custom claims and group mapping</li>
<li> <strong>RBAC:</strong> Kubernetes RBAC synced with Authentik groups</li>
<li> <strong>Secrets:</strong> SOPS-encrypted secrets in git, decrypted at deploy time</li>
<li> <strong>TLS:</strong> cert-manager with DNS-01 ACME challenges via Cloudflare</li>
</ul>
</div>
{/* Observability */}
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-6 border border-gray-200 dark:border-gray-800">
<h3 className="font-semibold text-gray-900 dark:text-white mb-3">
Observability Stack
</h3>
<ul className="space-y-1 text-sm text-gray-600 dark:text-gray-400">
<li> <strong>Metrics:</strong> Prometheus with custom recording rules, Grafana dashboards</li>
<li> <strong>Logs:</strong> Loki for aggregation, structured logging from all services</li>
<li> <strong>Traces:</strong> Tempo + OpenTelemetry Collector for distributed tracing</li>
<li> <strong>Alerts:</strong> AlertManager Slack integration for incident response</li>
</ul>
</div>
</section>
{/* Applications */}
<section className="mb-12">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
Self-Hosted Applications
</h2>
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-6 border border-gray-200 dark:border-gray-800">
<div className="flex flex-wrap gap-2">
{['Paperless-ngx', 'Immich', 'Homarr', 'Portainer', 'Forgejo', 'Authentik'].map((app) => (
<span
key={app}
className="text-sm px-3 py-1 rounded-full bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200"
>
{app}
</span>
))}
</div>
</div>
</section>
</div>
</main>
)
+1 -2
View File
@@ -29,7 +29,6 @@ export default function Home() {
: undefined,
videoUrl: '#',
articleUrl: index === 4 ? 'https://lnkd.in/p/gm2PZkWw' : '#',
deepDive: index === 0 ? { url: '/homelab', label: 'Architecture & Deep Dive' } : undefined,
ciRepos: index === 0 ? [
{ label: 'portfolio', repo: 'rock/riotpiao.com', forgejoBase: 'https://forgejo.riotpiao.com/rock/riotpiao.com' },
{ label: 'homelab-frontend', repo: 'rock/homelab-frontend', forgejoBase: 'https://forgejo.riotpiao.com/rock/homelab-frontend' },
@@ -137,7 +136,7 @@ export default function Home() {
<div className="max-w-6xl mx-auto text-center text-sm text-gray-600 dark:text-gray-400">
<p>{t.footer.copyright}</p>
<p className="mt-2">
<a href="https://github.com/Riotpiaole" target="_blank" rel="noopener noreferrer" className="hover:text-blue-600 dark:hover:text-blue-400">
<a href="https://github.com/rockliang" target="_blank" rel="noopener noreferrer" className="hover:text-blue-600 dark:hover:text-blue-400">
{t.footer.github}
</a>
{' • '}
+8 -8
View File
@@ -117,7 +117,7 @@ export default function Header() {
</div>
<div className="flex items-center justify-between">
<a
href="https://github.com/Riotpiaole"
href="https://github.com/rockliang"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 flex-1"
@@ -125,7 +125,7 @@ export default function Header() {
{t.header.contact.github}
</a>
<button
onClick={() => handleCopy('https://github.com/Riotpiaole', 'GitHub')}
onClick={() => handleCopy('https://github.com/rockliang', 'GitHub')}
className="text-xs px-2 py-1 rounded bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-blue-100 dark:hover:bg-blue-900"
>
{copied === 'GitHub' ? '✓' : t.header.contact.copy}
@@ -133,7 +133,7 @@ export default function Header() {
</div>
<div className="flex items-center justify-between">
<a
href="https://forgejo.riotpiao.com"
href="https://github.com/rockliang"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 flex-1"
@@ -141,7 +141,7 @@ export default function Header() {
{t.header.contact.privateGithub}
</a>
<button
onClick={() => handleCopy('https://forgejo.riotpiao.com', 'PrivateGithub')}
onClick={() => handleCopy('https://github.com/rockliang', 'PrivateGithub')}
className="text-xs px-2 py-1 rounded bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-blue-100 dark:hover:bg-blue-900"
>
{copied === 'PrivateGithub' ? '✓' : t.header.contact.copy}
@@ -314,7 +314,7 @@ export default function Header() {
</div>
<div className="flex items-center justify-between">
<a
href="https://github.com/Riotpiaole"
href="https://github.com/rockliang"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-gray-700 dark:text-gray-300 hover:text-blue-600 flex-1"
@@ -322,7 +322,7 @@ export default function Header() {
{t.header.contact.github}
</a>
<button
onClick={() => handleCopy('https://github.com/Riotpiaole', 'GitHub')}
onClick={() => handleCopy('https://github.com/rockliang', 'GitHub')}
className="text-xs px-2 py-1 rounded bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-blue-100 dark:hover:bg-blue-900"
>
{copied === 'GitHub' ? '✓' : t.header.contact.copy}
@@ -330,7 +330,7 @@ export default function Header() {
</div>
<div className="flex items-center justify-between">
<a
href="https://forgejo.riotpiao.com"
href="https://github.com/rockliang"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-gray-700 dark:text-gray-300 hover:text-blue-600 flex-1"
@@ -338,7 +338,7 @@ export default function Header() {
{t.header.contact.privateGithub}
</a>
<button
onClick={() => handleCopy('https://forgejo.riotpiao.com', 'PrivateGithub')}
onClick={() => handleCopy('https://github.com/rockliang', 'PrivateGithub')}
className="text-xs px-2 py-1 rounded bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-blue-100 dark:hover:bg-blue-900"
>
{copied === 'PrivateGithub' ? '✓' : t.header.contact.copy}
-3
View File
@@ -6,9 +6,6 @@ metadata:
labels:
app.kubernetes.io/name: portfolio
app.kubernetes.io/component: web
annotations:
argocd-image-updater.argoproj.io/image-list: portfolio=forgejo.riotpiao.com/rock/portfolio
argocd-image-updater.argoproj.io/portfolio.update-strategy: latest
spec:
replicas: 2
selector:
+4 -13
View File
@@ -1,22 +1,13 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: portfolio
resources:
- namespace.yaml
- configmap.enc.yaml
- deployment.yaml
- service.yaml
- ingress.yaml
# SOPS-encrypted secrets via ksops generator (ArgoCD repo-server plugin)
# ksops intercepts encrypted files, decrypts them, returns valid resources
generatorOptions:
disableNameSuffixHash: true
generators:
- secret-generator.yaml
# ArgoCD Image Updater configuration - for tag updates
images:
- name: forgejo.riotpiao.com/rock/portfolio
newTag: latest
# Decrypt secrets via SOPS before applying
sops:
version: 3
@@ -1,10 +0,0 @@
apiVersion: viaduct.ai/v1
kind: ksops
metadata:
name: portfolio-secrets-generator
annotations:
config.kubernetes.io/function: |
exec:
path: ksops
files:
- configmap.enc.yaml
+1 -2
View File
@@ -12,7 +12,6 @@
"keywords": [],
"author": "",
"license": "ISC",
"packageManager": "[email protected]",
"dependencies": {
"framer-motion": "^11.0.0",
"lucide-react": "^1.41.0",
@@ -29,7 +28,7 @@
"@typescript-eslint/eslint-plugin": "^8.64.0",
"@typescript-eslint/parser": "^8.64.0",
"autoprefixer": "^10.4.16",
"eslint": "^9.0.0",
"eslint": "^8.57.1",
"eslint-config-next": "^16.2.10",
"postcss": "^8.4.32",
"typescript": "5.8.2"
-59
View File
@@ -1,59 +0,0 @@
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './tests/e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: [
['html'],
['json', { outputFile: 'test-results/results.json' }],
['junit', { outputFile: 'test-results/results.xml' }],
['list'],
],
use: {
baseURL: process.env.BASE_URL || 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
// Mobile testing
{
name: 'Mobile Chrome',
use: { ...devices['Pixel 5'] },
},
{
name: 'Mobile Safari',
use: { ...devices['iPhone 12'] },
},
// Tablet
{
name: 'iPad',
use: { ...devices['iPad Pro'] },
},
],
webServer: {
command: 'pnpm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
timeout: 120000,
},
})
+747 -849
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-63
View File
@@ -1,63 +0,0 @@
/**
* Shared test data and constants for E2E tests
*/
export const TEST_VIEWPORTS = {
desktop: { width: 1920, height: 1080 },
tablet: { width: 768, height: 1024 },
mobile: { width: 375, height: 667 },
}
export const TEST_SELECTORS = {
// Hero section
heroTitle: 'h1',
heroSubtitle: 'text=Kubernetes',
exploreMoreBtn: 'button:has-text("Explore More")',
terminalHint: 'text=Terminal',
// Bio modal
bioModal: '.bg-white.dark\\:bg-gray-900.rounded-lg',
bioTitle: 'text=Bio',
bioCloseBtn: 'button:has-text("x")',
bioIntro: 'text=Welcome',
// Projects section
projectsSection: 'text=Projects',
projectCard: '[class*="ProjectShowcase"]',
projectTitle: 'text=Homelab',
// Timeline
timelineSection: 'text=Experience',
// Terminal
terminalContainer: '[class*="InteractiveTerminal"]',
terminalInput: 'input[placeholder*="terminal"]',
// Dark mode
themeToggle: 'button[aria-label*="theme"]',
// Links
watchVideoLink: 'text=Watch Video',
readArticleLink: 'text=Read Article',
}
export const TEST_TIMEOUTS = {
slow: 10000,
normal: 5000,
quick: 2000,
}
export const TEST_PROJECTS = [
'Homelab',
'Poimen Memory',
'Poimen Workflow',
'RBC',
'AWS',
]
export const TEST_BREAKPOINTS = {
mobile: 375,
tablet: 768,
desktop: 1280,
wide: 1920,
}