I Built a Football Tournament Simulator with Vue 3 ⚽

I Built a Football Tournament Simulator with Vue 3 ⚽

I recently built a browser-based football tournament simulator using Vue 3 and TypeScript.

What started as a very small bracket app slowly turned into a much bigger project with features like:

  • Tournament brackets
  • Match simulation
  • Penalty shootouts
  • Team power system
  • Seasons
  • Seeded draws
  • Match history
  • Responsive design
  • Import/export system

You can create your own tournaments, simulate matches, and watch the bracket progress until a champion is crowned.

Why I Built It

I enjoy football management and simulation games, so I wanted to build something similar in the browser while also improving my Vue and TypeScript skills.

At first, the app was extremely simple:

  • create teams
  • simulate matches
  • show a bracket

But after adding more systems and improving the simulation logic, the project became much more fun to work on.

One of the Most Fun Parts

The most interesting part was definitely the match engine.

I tried to make matches feel:

  • random enough to stay exciting
  • but realistic enough so stronger teams usually perform better

Later, I also added penalty shootouts for knockout matches.

Small changes in the algorithm completely changed how the tournaments felt.

Tech Stack

  • Vue 3
  • TypeScript
  • Vite
  • Pinia
  • Vue Router

Features Added Over Time

Some of my favorite additions were:

  • Fullscreen bracket mode
  • Team form/history pages
  • Responsive layout support
  • Dark mode improvements
  • Team-colored confetti 🎉
  • Dataset import/export
  • Sample tournament datasets

I also refactored many parts into composables to keep the project cleaner as it grew.

Links

GitHub Repository:
GitHub Repo

Live Demo:
Vercel Demo

Final Thoughts

This project started as a tiny MVP and became one of my favorite frontend projects so far.

Building simulation systems is surprisingly addictive because every small tweak changes the entire experience.

I still plan to add more features in the future like:

  • group stages
  • league systems
  • player stats
  • better AI simulation

Thanks for reading ⚽

Shopify ShieldScan

Your Shopify store isn’t just a storefront.

For many enterprises, it’s a revenue system — connected to payments, customers, and brand trust.

But here’s what we keep seeing: the platform is solid. The apps and themes around it often aren’t.

Old permissions. Forgotten scripts. Keys in theme files. “It was installed by the agency two years ago.”

That’s why i built ShieldScan — security auditing built for Shopify, not generic website scanners.

Scan in minutes. Get an A–F grade. Know what to fix before customers or attackers do.

Curious — who owns “security” for your Shopify stack today? IT, e‑commerce, or… nobody?

shopify #store #security #privacy

https://apps.shopify.com/shieldscan

Running Gemma 4 Inside a Docker Container with GPU Passthrough

This is a submission for the Gemma 4 Challenge: Write About Gemma 4

Here’s a situation every ML team eventually hits.

Your intern gets Gemma 4 running locally. Beautiful. Fast. Impresses the standup. Then they leave for the semester. Three weeks later, nobody can reproduce the setup. The model was in a path that no longer exists. The Python version was wrong. The CUDA libraries conflicted with PyTorch. Someone pip installed something globally and broke everything.

This is why Docker exists. And yet, every Gemma 4 setup guide I’ve found uses either the Ollama GUI, LM Studio’s drag-and-drop interface, or bare ollama run commands that vanish when the terminal closes. None of them answer the actual question a dev team has:

How do we version-control this, ship it to staging, and have it work the same way every time?

This guide answers that. By the end, you’ll have:

  • A Dockerfile your team can pin, review, and modify in a PR
  • A docker-compose.yml that brings up the full stack with one command
  • A running Gemma 4 endpoint that speaks OpenAI’s API format — meaning zero changes to any code that already calls GPT-4o

Let’s build it.

Why Docker + GPU Is Annoying (and How It Actually Works)

Before the files, a quick mental model — because this is where most people get confused.

CUDA is not inside your container. The NVIDIA driver lives on the host machine. The container runtime (Docker) needs a way to reach through the container wall and talk to those drivers. That bridge is the NVIDIA Container Toolkit.

Here’s the flow:

Your Container
    └── CUDA libraries (inside the image)
          └── NVIDIA Container Runtime  ← the bridge
                └── Host NVIDIA Driver  ← never inside the container
                      └── Physical GPU

This means:

  1. You do NOT need to install CUDA on your host. Only the NVIDIA driver.
  2. You do NOT bundle the driver inside your Docker image. Only CUDA libraries.
  3. The container toolkit version on your host must be compatible with your driver version.

A common gotcha: if your host driver is old (e.g., 470.x), it cannot run CUDA 12.x containers. The CUDA version inside your image must be less than or equal to what your driver supports. Check first:

nvidia-smi
# Look for: "CUDA Version: XX.X" in the top-right corner
# That's the maximum CUDA your driver supports

Prerequisites Checklist

Before touching any Docker file, verify these four things on your host machine:

# 1. Confirm NVIDIA GPU is visible
lspci | grep -i nvidia

# 2. Check driver is installed and working
nvidia-smi

# 3. Check Docker version (need 19.03+ for native --gpus support)
docker --version

# 4. Check NVIDIA Container Toolkit is installed
nvidia-ctk --version

If step 4 fails, install the toolkit:

# Ubuntu/Debian
curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey | 
  sudo gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg

curl -s -L https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list | 
  sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g' | 
  sudo tee /etc/apt/sources.list.d/nvidia-container-toolkit.list

sudo apt-get update && sudo apt-get install -y nvidia-container-toolkit

# Wire it into Docker
sudo nvidia-ctk runtime configure --runtime=docker
sudo systemctl restart docker

# Test — you should see nvidia-smi output from inside a container
docker run --rm --gpus all nvidia/cuda:12.3.0-base-ubuntu22.04 nvidia-smi

If that last command shows your GPU, you’re ready. If it errors, stop here and fix the driver — no amount of clever Dockerfiles will help.

The Setup We’re Building

We’re going to run three containers in a single Compose stack:

Container What it does Port
gemma4 Ollama serving Gemma 4 (with GPU) 11434 (internal)
litellm OpenAI-compatible proxy in front of Ollama 8000 (external)
model-puller One-shot container that pulls the model on first boot —

The LiteLLM proxy is the key piece most guides skip. It translates OpenAI API calls (/v1/chat/completions) into Ollama’s native format, adds API key auth, rate limiting, and request logging — all without touching your application code.

Project Structure

Create this directory layout before writing any files:

gemma4-stack/
├── .env                    # Secrets and config — never commit this
├── .env.example            # Template for new team members
├── docker-compose.yml      # The full stack
├── Dockerfile.ollama       # Custom Ollama image with health checks
├── litellm/
│   └── config.yaml         # LiteLLM model routing config
├── scripts/
│   └── pull-model.sh       # Idempotent model pull script
└── tests/
    └── smoke_test.py       # Verify the stack works end-to-end

Step 1: The .env File

# .env — copy from .env.example and fill in your values
# !! Never commit this file — add .env to .gitignore

# Which Gemma 4 variant to run
# Options: gemma4:e2b | gemma4:e4b | gemma4:26b | gemma4:31b
GEMMA_MODEL=gemma4:26b

# API key for LiteLLM (clients use this to authenticate)
# Generate a random one: openssl rand -hex 32
LITELLM_MASTER_KEY=sk-your-secret-key-here

# How long Ollama keeps the model in VRAM after the last request
# Increase this if you have spare VRAM and want faster cold starts
OLLAMA_KEEP_ALIVE=10m

# Max parallel inference requests (tune to your VRAM)
OLLAMA_NUM_PARALLEL=2

# HuggingFace token — needed only if you pull gated models
HF_TOKEN=
# .env.example — commit this
GEMMA_MODEL=gemma4:26b
LITELLM_MASTER_KEY=sk-CHANGE-ME
OLLAMA_KEEP_ALIVE=10m
OLLAMA_NUM_PARALLEL=2
HF_TOKEN=

Step 2: The Dockerfile

This is where we diverge from every “just use the ollama image” guide. Our custom image adds:

  • A proper health check endpoint (so Compose knows when Ollama is actually ready)
  • A non-root user (security baseline for any production-adjacent setup)
  • Pinned Ollama version (reproducibility)
# Dockerfile.ollama
# Pin to a specific Ollama release — update deliberately, not accidentally
FROM ollama/ollama:0.9.3

# Metadata
LABEL maintainer="[email protected]"
LABEL description="Ollama serving Gemma 4 with GPU passthrough"
LABEL gemma.version="gemma4"

# GPU access configuration
# NVIDIA_VISIBLE_DEVICES and NVIDIA_DRIVER_CAPABILITIES tell the
# container runtime which GPU capabilities to expose.
# "compute" = CUDA, "utility" = nvidia-smi
ENV NVIDIA_VISIBLE_DEVICES=all
ENV NVIDIA_DRIVER_CAPABILITIES=compute,utility

# Ollama configuration
ENV OLLAMA_HOST=0.0.0.0
ENV OLLAMA_ORIGINS=*

# Keep the model warm in VRAM (overridable via docker-compose env)
ENV OLLAMA_KEEP_ALIVE=10m

# Number of parallel inference slots
# Each slot uses roughly model_size / 2 additional VRAM
ENV OLLAMA_NUM_PARALLEL=2

# Flash attention — reduces memory for long contexts
ENV OLLAMA_FLASH_ATTENTION=1

# Model storage location inside the container
# We'll mount a volume here to persist across container restarts
ENV OLLAMA_MODELS=/models

# Create the models directory and set ownership
RUN mkdir -p /models && chmod 755 /models

# Expose Ollama's API port
EXPOSE 11434

# Health check: ask Ollama if it's ready
# --start-period gives it 60s to load before health checks count
HEALTHCHECK --interval=15s --timeout=10s --retries=5 --start-period=60s 
  CMD curl -sf http://localhost:11434/api/tags || exit 1

# Default command — starts Ollama server
CMD ["ollama", "serve"]

Why pin ollama:0.9.3 and not ollama:latest?

Because latest on a production service means “surprise breaking changes on every docker pull.” Pin it. Update it intentionally via PR. When a new Ollama release drops and the team wants to update, the diff is one line and the change is reviewable.

Step 3: The Model Pull Script

This script runs once on startup and is idempotent — running it twice does nothing harmful:

#!/usr/bin/env bash
# scripts/pull-model.sh
# Pulls the Gemma 4 model if not already present
# Safe to run multiple times — checks before pulling

set -euo pipefail

OLLAMA_HOST="${OLLAMA_HOST:-http://gemma4:11434}"
MODEL="${GEMMA_MODEL:-gemma4:26b}"

echo "==> Waiting for Ollama to be ready at ${OLLAMA_HOST}..."

# Wait up to 5 minutes for Ollama to respond
for i in $(seq 1 60); do
  if curl -sf "${OLLAMA_HOST}/api/tags" > /dev/null 2>&1; then
    echo "==> Ollama is up."
    break
  fi
  echo "    Attempt ${i}/60 — retrying in 5s..."
  sleep 5
done

# Check if model already exists
if curl -sf "${OLLAMA_HOST}/api/tags" | grep -q ""${MODEL}""; then
  echo "==> Model '${MODEL}' already present. Skipping pull."
  exit 0
fi

echo "==> Pulling model: ${MODEL}"
echo "    This can take 5–20 minutes depending on your connection."
echo "    The 26B model is ~15GB. Get a coffee."

curl -sf -X POST "${OLLAMA_HOST}/api/pull" 
  -H "Content-Type: application/json" 
  -d "{"name": "${MODEL}", "stream": false}"

echo "==> Pull complete. Model '${MODEL}' is ready."
chmod +x scripts/pull-model.sh

Step 4: LiteLLM Config

LiteLLM is the OpenAI-compatible gateway. This config tells it that “gemma-4” (what your app calls) maps to “ollama_chat/gemma4:26b” (what Ollama actually serves):

# litellm/config.yaml
model_list:
  # Gemma 4 26B MoE — best balance of speed and quality
  - model_name: gemma-4
    litellm_params:
      model: ollama_chat/gemma4:26b
      api_base: http://gemma4:11434

  # Gemma 4 E4B — fast, low VRAM, good for quick tasks
  - model_name: gemma-4-fast
    litellm_params:
      model: ollama_chat/gemma4:e4b
      api_base: http://gemma4:11434

  # Gemma 4 31B Dense — maximum quality, needs 24GB+ VRAM
  - model_name: gemma-4-max
    litellm_params:
      model: ollama_chat/gemma4:31b
      api_base: http://gemma4:11434

litellm_settings:
  # Log all requests (set to false in prod if you handle this elsewhere)
  set_verbose: false

  # Drop unsupported params instead of erroring
  # Gemma doesn't support every OpenAI parameter (e.g., logprobs)
  drop_params: true

  # Request timeout in seconds
  request_timeout: 120

general_settings:
  # Master key for authentication
  # Clients send: Authorization: Bearer sk-your-secret-key-here
  master_key: "os.environ/LITELLM_MASTER_KEY"

Step 5: The docker-compose.yml

This is the centrepiece. Read it carefully — every decision has a comment explaining why:

# docker-compose.yml
# Gemma 4 production-grade local inference stack
# Usage: docker compose up -d
# First run: docker compose up -d && docker compose logs -f model-puller

name: gemma4-stack

services:

  # ─────────────────────────────────────────────
  # OLLAMA: The inference engine with GPU access
  # ─────────────────────────────────────────────
  gemma4:
    build:
      context: .
      dockerfile: Dockerfile.ollama
    container_name: gemma4-ollama

    # GPU passthrough — this is the magic line
    # "count: all" exposes every GPU on the host
    # Change to "count: 1" or "device_ids: ['0']" to pin a specific GPU
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              count: all
              capabilities: [gpu]

    # Model storage: named volume persists across container recreations
    # Without this, you re-download 15GB every time you update the image
    volumes:
      - gemma4-models:/models

    environment:
      OLLAMA_KEEP_ALIVE: ${OLLAMA_KEEP_ALIVE:-10m}
      OLLAMA_NUM_PARALLEL: ${OLLAMA_NUM_PARALLEL:-2}
      OLLAMA_FLASH_ATTENTION: "1"
      OLLAMA_MODELS: /models

    # Do NOT expose port 11434 to the host
    # All external traffic goes through LiteLLM on port 8000
    # This prevents clients from bypassing auth
    expose:
      - "11434"

    restart: unless-stopped

    healthcheck:
      test: ["CMD", "curl", "-sf", "http://localhost:11434/api/tags"]
      interval: 15s
      timeout: 10s
      retries: 5
      start_period: 60s

  # ─────────────────────────────────────────────
  # MODEL PULLER: Downloads Gemma 4 on first boot
  # Exits cleanly after pulling — not a long-running service
  # ─────────────────────────────────────────────
  model-puller:
    image: curlimages/curl:latest
    container_name: gemma4-model-puller

    volumes:
      - ./scripts/pull-model.sh:/pull-model.sh:ro

    environment:
      OLLAMA_HOST: http://gemma4:11434
      GEMMA_MODEL: ${GEMMA_MODEL:-gemma4:26b}

    entrypoint: ["/bin/sh", "/pull-model.sh"]

    depends_on:
      gemma4:
        condition: service_healthy

    # This container should exit 0 after pulling the model
    restart: "no"

  # ─────────────────────────────────────────────
  # LITELLM: OpenAI-compatible API gateway
  # Your apps connect to THIS, not to Ollama directly
  # ─────────────────────────────────────────────
  litellm:
    image: ghcr.io/berriai/litellm:main-stable
    container_name: gemma4-litellm

    volumes:
      - ./litellm/config.yaml:/app/config.yaml:ro

    environment:
      LITELLM_MASTER_KEY: ${LITELLM_MASTER_KEY}

    command: ["--config", "/app/config.yaml", "--port", "8000"]

    ports:
      # THIS is the only port exposed to your host machine
      # http://localhost:8000/v1 — drop-in replacement for OpenAI
      - "8000:8000"

    depends_on:
      gemma4:
        condition: service_healthy

    restart: unless-stopped

    healthcheck:
      test: ["CMD", "curl", "-sf", "http://localhost:8000/health"]
      interval: 20s
      timeout: 10s
      retries: 3

# ─────────────────────────────────────────────
# VOLUMES
# ─────────────────────────────────────────────
volumes:
  gemma4-models:
    driver: local
    # Uncomment to bind to a specific host path instead:
    # driver_opts:
    #   type: none
    #   o: bind
    #   device: /data/gemma4-models

networks:
  default:
    name: gemma4-net

Step 6: Boot It Up

# Clone or create the project directory
cd gemma4-stack

# Copy the env template and fill in your master key
cp .env.example .env
# Edit .env — at minimum, set LITELLM_MASTER_KEY

# Build the Ollama image and start the stack
docker compose up -d

# Watch the model download (first run only — this takes a while)
docker compose logs -f model-puller

# Once model-puller exits, check everything is healthy
docker compose ps

Expected output from docker compose ps once everything is running:

NAME                   IMAGE                          STATUS
gemma4-ollama          gemma4-stack-gemma4            Up (healthy)
gemma4-litellm         ghcr.io/berriai/litellm:...   Up (healthy)
gemma4-model-puller    curlimages/curl:latest         Exited (0)

The model-puller showing Exited (0) is correct — it finished its job and stopped cleanly.

Step 7: Test the OpenAI-Compatible Endpoint

Your stack is now running a drop-in OpenAI replacement at http://localhost:8000/v1. Test it:

# Check available models
curl http://localhost:8000/v1/models 
  -H "Authorization: Bearer ${LITELLM_MASTER_KEY}"

# Send a chat completion — same format as OpenAI
curl http://localhost:8000/v1/chat/completions 
  -H "Content-Type: application/json" 
  -H "Authorization: Bearer ${LITELLM_MASTER_KEY}" 
  -d '{
    "model": "gemma-4",
    "messages": [
      {
        "role": "system",
        "content": "You are a senior backend engineer doing code review."
      },
      {
        "role": "user",
        "content": "Review this Python function and identify any issues:nndef get_user(id):n    return db.query(f"SELECT * FROM users WHERE id={id}")"
      }
    ],
    "temperature": 0.3,
    "max_tokens": 512
  }'

Expected response — Gemma 4 should immediately flag the SQL injection:

{
  "id": "chatcmpl-...",
  "object": "chat.completion",
  "model": "gemma-4",
  "choices": [{
    "message": {
      "role": "assistant",
      "content": "Critical issue: SQL injection vulnerability on line 2. The `id` parameter is interpolated directly into the query string with an f-string. An attacker could pass `id = '1 OR 1=1 --'` and retrieve all users. Fix: use parameterized queries:nn```

pythonndef get_user(user_id: int):n    return db.query(n        "SELECT * FROM users WHERE id = %s",n        (user_id,)n    )n

```nnAlso: rename the parameter from `id` to `user_id` — `id` shadows a Python built-in."
    },
    "finish_reason": "stop"
  }]
}

Step 8: Drop It Into Your Existing Code

Here’s the part that makes this setup valuable to a team. If you already have code calling OpenAI, you change two lines:

Python (openai SDK)

# Before — calling OpenAI
from openai import OpenAI

client = OpenAI(api_key="sk-your-openai-key")

# After — calling your local Gemma 4 stack
from openai import OpenAI
import os

client = OpenAI(
    base_url="http://localhost:8000/v1",  # ← changed
    api_key=os.environ["LITELLM_MASTER_KEY"],  # ← changed
)

# Everything below is identical — no other changes needed
response = client.chat.completions.create(
    model="gemma-4",  # or "gemma-4-fast" for E4B, "gemma-4-max" for 31B
    messages=[
        {"role": "system", "content": "You are a helpful assistant."},
        {"role": "user", "content": "Explain what a Docker volume is in one sentence."},
    ],
    temperature=0.7,
    max_tokens=200,
)

print(response.choices[0].message.content)

Node.js / TypeScript

import OpenAI from "openai";

// Same two-line swap
const client = new OpenAI({
  baseURL: "http://localhost:8000/v1",
  apiKey: process.env.LITELLM_MASTER_KEY,
});

const response = await client.chat.completions.create({
  model: "gemma-4",
  messages: [
    { role: "user", content: "Write a TypeScript type for a paginated API response." }
  ],
});

console.log(response.choices[0].message.content);

LangChain

from langchain_openai import ChatOpenAI

llm = ChatOpenAI(
    base_url="http://localhost:8000/v1",
    api_key="your-litellm-master-key",
    model="gemma-4",
    temperature=0,
)

response = llm.invoke("What are the SOLID principles? Give me one sentence per principle.")
print(response.content)

Step 9: The Smoke Test

Add this to your CI pipeline or run it after deployment to verify the stack is working:

# tests/smoke_test.py
"""
Smoke test for the Gemma 4 Docker stack.
Run with: python tests/smoke_test.py
Exit 0 = healthy. Exit 1 = something's broken.
"""

import os
import sys
import time
from openai import OpenAI

BASE_URL = os.environ.get("GEMMA_BASE_URL", "http://localhost:8000/v1")
API_KEY = os.environ.get("LITELLM_MASTER_KEY", "sk-test")
MODEL = os.environ.get("GEMMA_MODEL_ALIAS", "gemma-4")

def check(name: str, condition: bool, detail: str = ""):
    if condition:
        print(f"  ✓ {name}")
    else:
        print(f"  ✗ {name}{': ' + detail if detail else ''}")
        sys.exit(1)

print(f"nSmoke test — {BASE_URL}n")

client = OpenAI(base_url=BASE_URL, api_key=API_KEY)

# Test 1: Models endpoint
models = client.models.list()
model_ids = [m.id for m in models.data]
check("Models endpoint", len(model_ids) > 0, f"got: {model_ids}")
check(f"Model '{MODEL}' available", MODEL in model_ids)

# Test 2: Basic chat completion
start = time.time()
resp = client.chat.completions.create(
    model=MODEL,
    messages=[{"role": "user", "content": "Reply with exactly the word PONG and nothing else."}],
    max_tokens=10,
    temperature=0,
)
elapsed = time.time() - start

reply = resp.choices[0].message.content.strip().upper()
check("Chat completion returns", "PONG" in reply, f"got: {reply!r}")
check("Response time < 30s", elapsed < 30, f"took {elapsed:.1f}s")

# Test 3: Streaming
chunks = []
for chunk in client.chat.completions.create(
    model=MODEL,
    messages=[{"role": "user", "content": "Count to 3, one number per line."}],
    max_tokens=30,
    stream=True,
):
    if chunk.choices[0].delta.content:
        chunks.append(chunk.choices[0].delta.content)

check("Streaming works", len(chunks) > 0, f"got {len(chunks)} chunks")

print(f"nAll checks passed. Stack is healthy.n")

Useful Day-to-Day Commands

# Start the stack
docker compose up -d

# Tail logs from all containers
docker compose logs -f

# Tail logs from one container
docker compose logs -f gemma4

# Check GPU utilization inside the container
docker exec gemma4-ollama nvidia-smi

# List loaded models and their VRAM usage
docker exec gemma4-ollama ollama ps

# Pull a different model (e.g., try 31B if you have the VRAM)
docker exec gemma4-ollama ollama pull gemma4:31b

# Hard restart (keeps model volume intact)
docker compose restart gemma4

# Full teardown — preserves the model volume
docker compose down

# NUCLEAR: full teardown including the 15GB model volume
# Only do this if you want to re-download everything
docker compose down -v

# Update to a newer Ollama version (edit Dockerfile.ollama first)
docker compose build --no-cache gemma4
docker compose up -d gemma4

# Check container resource usage
docker stats gemma4-ollama

Hardware Decision Guide

Not sure which Gemma 4 variant to put in your .env? Here’s the honest breakdown:

Your hardware Set GEMMA_MODEL to Why
RTX 3090/4090 (24 GB VRAM) gemma4:31b Full quality, fits comfortably
RTX 3080/4080 (12–16 GB VRAM) gemma4:26b MoE activates only 3.8B — fits and runs fast
RTX 3060/4060 (8 GB VRAM) gemma4:e4b Edge model, good quality, 3.5 GB VRAM
Any GPU with 6 GB VRAM gemma4:e2b Lightweight, still genuinely useful
CPU only (no GPU) gemma4:e2b Slow but functional; ~3–5 tok/s on modern CPU
2× GPUs (any combo) gemma4:31b + tensor parallel See multi-GPU note below

For multi-GPU setups, add to your Ollama environment:

environment:
  OLLAMA_GPU_LAYERS: 999      # Offload all layers to GPU
  OLLAMA_SCHED_SPREAD: "1"    # Spread across multiple GPUs

Common Errors and Fixes

nvidia-smi: command not found inside container
Your NVIDIA Container Toolkit isn’t wired into Docker. Re-run sudo nvidia-ctk runtime configure --runtime=docker and restart Docker.

CUDA error: no kernel image is available for execution on the device
Your GPU is too old for the CUDA version in the Ollama image. Check nvidia-smi for your driver version and use an older Ollama base image with a compatible CUDA tag.

LiteLLM returns APIConnectionError
The litellm container can’t reach gemma4. Check they’re on the same network: docker network inspect gemma4-net. The service name gemma4 in the LiteLLM config must match the Compose service name exactly.

docker compose up says GPU not found on Linux
The Docker daemon didn’t get restarted after toolkit installation. Run sudo systemctl restart docker.

Model-puller keeps restarting instead of exiting
Ollama isn’t healthy yet. Increase start_period in the Ollama healthcheck from 60s to 120s — large models take longer to initialize on slow disks.

What This Unlocks for Your Team

Once this stack is running, here’s what your team gets for free:

Reproducibility. git clone + cp .env.example .env + docker compose up -d = working Gemma 4 in under 20 minutes for any new team member. No “it works on my machine.”

Version control. Every change to the model, Ollama version, or config goes through a PR. Your ML setup is as auditable as your application code.

OpenAI drop-in. Any code that calls OpenAI works immediately. Switch between gemma-4 (local) and gpt-4o (cloud) by changing one environment variable. Useful for cost comparisons and offline development.

Privacy by default. No token leaves the machine. Customer data, proprietary code, internal documents — all processed locally with no third-party API calls.

Zero cloud dependency. The stack works on a laptop, a workstation, an on-prem server, or an air-gapped environment. The only network requirement is the initial model download.

References

I vibe-coded a Postgres workspace because pgAdmin and DBeaver don’t fit how I work

Heads up before anything else: this is built with Claude Code. I don’t have time to write a Postgres admin tool from scratch, and the existing ones
just annoy me. I don’t want another desktop app to install on every machine. I don’t want to hand my prod creds to some SaaS. I want something I
can drop into my docker-compose, point at my dev DB, and forget exists.

So I made dbil. One container, around 20MB, React UI baked in. Add it as a service, slap a dbil.enable: “true” label on your Postgres container,

and it picks the connection up on its own. That’s the whole setup.

What it does — real ER diagram pulled from pg_catalog, no manual dragging boxes around. SQL editor with autocomplete. Paginated row browsing. Live
metrics for TPS, cache hit ratio, replication lag, slow queries, lock chains, unused indexes. The stuff you’d actually look at.

Safety is tied to a connection tag. Tag something production and DDL is just refused. DELETE or UPDATE without a WHERE gets blocked. Spot a session
blocking five others? Hit Kill and dbil sends pg_terminate_backend. No second-guessing.

State file is encrypted at the app layer — AES-256-GCM, per field, per row. Master key can come from KMS, OS keychain, a mounted secret file, an

env var, or a TTY prompt. Audit log is forward-chained SHA-256, so if someone tampers with a row directly the chain breaks and the verifier flags
it. Multi-arch images signed with cosign keyless OIDC, every release ships an SPDX SBOM.

Repo: https://github.com/unkabas/dbil

Not pretending this is enterprise-grade. It’s a personal tool that turned into something I want to keep building, and I’d rather do it in the open
than sit on it. Try it, break it, star it if it’s useful. Open issues with whatever feels off — that’s the only way it gets better.

Front Controller: The Pattern That Unifies Your Web Application’s Entry Point

Part of the series: Enterprise Application Architecture Patterns — Martin Fowler

The Problem You’ve Probably Already Faced

You’re building an API. You have /users, /products, /orders. Every endpoint needs to verify the token, log the request, handle unexpected errors. And before you know it, you end up with this:

# users.py
def handle(request):
    log(request)                  # again
    if not check_auth(request):   # again
        return 401
    try:
        # actual logic
    except Exception:
        return 500                # again
# products.py
def handle(request):
    log(request)                  # again
    if not check_auth(request):   # again
        return 401
    try:
        # actual logic
    except Exception:
        return 500                # again

Duplicated code everywhere. If tomorrow you change how authentication works, you touch 10 files. If you add rate limiting, another 10.

The Front Controller pattern solves exactly this.

What Is the Front Controller?

Fowler’s definition:

“A controller that handles all requests for a Web site.”

In practical terms: a single entry point that intercepts all HTTP requests, executes common logic (auth, logging, error handling), and then delegates to the appropriate handler.

This isn’t a new or exotic idea. When you use Django, FastAPI, Laravel, or Spring MVC, you’re already using a Front Controller. What this article does is show you what’s underneath, by implementing it from scratch.

Pattern Structure

The pattern has three pieces:

HTTP Request
      │
      ▼
┌─────────────────────┐
│   Front Controller  │  ← Single entry point
│  - Logging          │    Executes cross-cutting logic
│  - Authentication   │
│  - Error Handling   │
└──────────┬──────────┘
           │
           ▼
┌─────────────────────┐
│     Dispatcher      │  ← Maps URL → Handler
└──────────┬──────────┘
           │
    ┌──────┴───────┐
    ▼              ▼
[UsersHandler] [ProductsHandler] ...  ← Business logic only

Front Controller: Receives everything. Knows nothing about business, only about infrastructure.

Dispatcher: Knows which handler corresponds to which URL. Nothing more.

Handlers: Only know their own resource. They know nothing about auth or logging.

Python Implementation (No Frameworks)

The example uses only Python’s standard library (http.server, json, logging). No Django, no Flask, no FastAPI. This way the pattern is fully exposed — no magic.

It simulates a store API with three resources: users, products, and orders.

Project Structure

front_controller/
├── main.py
├── front_controller.py
├── dispatcher.py
└── handlers/
    ├── __init__.py
    ├── base_handler.py
    ├── users_handler.py
    ├── products_handler.py
    └── orders_handler.py

1. The Front Controller — the central piece

# front_controller.py
import logging
from http.server import BaseHTTPRequestHandler
from dispatcher import Dispatcher

VALID_TOKEN = "Bearer secret-token-123"

class FrontController(BaseHTTPRequestHandler):
    dispatcher = Dispatcher()

    def do_GET(self):    self._handle_request("GET")
    def do_POST(self):   self._handle_request("POST")
    def do_PUT(self):    self._handle_request("PUT")
    def do_DELETE(self): self._handle_request("DELETE")

    def _handle_request(self, method):
        self._log_request(method)

        if not self._authenticate():
            return

        try:
            self.dispatcher.dispatch(self, method, self.path)
        except Exception as e:
            logging.error(f"Unhandled exception: {e}")
            self._send_json(500, {"error": "Internal Server Error"})

    def _log_request(self, method):
        logging.info(f"[{method}] {self.path}")

    def _authenticate(self):
        token = self.headers.get("Authorization", "")
        if token != VALID_TOKEN:
            self._send_json(401, {"error": "Unauthorized"})
            return False
        return True

    def _send_json(self, status, data):
        import json
        body = json.dumps(data).encode()
        self.send_response(status)
        self.send_header("Content-Type", "application/json")
        self.end_headers()
        self.wfile.write(body)

    def log_message(self, format, *args):
        pass  # Silences BaseHTTPRequestHandler's default log

Notice what this file does and what it doesn’t do:

  • ✅ Logs every request
  • ✅ Validates the token
  • ✅ Catches any unhandled exception
  • ✅ Delegates to the dispatcher
  • ❌ Knows nothing about users, products, or orders

2. The Dispatcher — the routing map

# dispatcher.py
from handlers.users_handler import UsersHandler
from handlers.products_handler import ProductsHandler
from handlers.orders_handler import OrdersHandler

class Dispatcher:
    def __init__(self):
        self.routes = {
            "/users":    UsersHandler(),
            "/products": ProductsHandler(),
            "/orders":   OrdersHandler(),
        }

    def dispatch(self, controller, method, path):
        handler = self.routes.get(path)
        if handler:
            handler.handle(controller, method)
        else:
            controller._send_json(404, {"error": f"Path '{path}' not found"})

Simple and direct. A dictionary mapping paths to handlers. If the path doesn’t exist, it responds with 404. The dispatcher knows nothing about HTTP details or business logic.

3. The BaseHandler — shared behavior

# handlers/base_handler.py
import json

class BaseHandler:
    def handle(self, controller, method):
        if method == "GET":
            self.get(controller)
        elif method == "POST":
            self.post(controller)
        else:
            self.send_error(controller, 405, "Method Not Allowed")

    def get(self, controller):
        self.send_error(controller, 405, "Method Not Allowed")

    def post(self, controller):
        self.send_error(controller, 405, "Method Not Allowed")

    def send_json(self, controller, status, data):
        body = json.dumps(data).encode()
        controller.send_response(status)
        controller.send_header("Content-Type", "application/json")
        controller.end_headers()
        controller.wfile.write(body)

    def send_error(self, controller, status, message):
        self.send_json(controller, status, {"error": message})

4. The Handlers — business logic only

# handlers/users_handler.py
from handlers.base_handler import BaseHandler

class UsersHandler(BaseHandler):
    def get(self, controller):
        self.send_json(controller, 200, {
            "users": [
                {"id": 1, "name": "Alice"},
                {"id": 2, "name": "Bob"},
                {"id": 3, "name": "Charlie"},
            ]
        })

    def post(self, controller):
        self.send_json(controller, 201, {
            "message": "User created successfully",
            "resource": "/users"
        })
# handlers/products_handler.py
from handlers.base_handler import BaseHandler

class ProductsHandler(BaseHandler):
    def get(self, controller):
        self.send_json(controller, 200, {
            "products": [
                {"id": 1, "name": "Laptop",   "price": 999},
                {"id": 2, "name": "Mouse",    "price": 29},
                {"id": 3, "name": "Keyboard", "price": 79},
            ]
        })

    def post(self, controller):
        self.send_json(controller, 201, {
            "message": "Product created successfully",
            "resource": "/products"
        })
# handlers/orders_handler.py
from handlers.base_handler import BaseHandler

class OrdersHandler(BaseHandler):
    def get(self, controller):
        self.send_json(controller, 200, {
            "orders": [
                {"id": 1, "user_id": 1, "total": 1028},
                {"id": 2, "user_id": 2, "total": 79},
                {"id": 3, "user_id": 3, "total": 29},
            ]
        })

    def post(self, controller):
        self.send_json(controller, 201, {
            "message": "Order created successfully",
            "resource": "/orders"
        })

5. The Entry Point

# main.py
import logging
from http.server import HTTPServer
from front_controller import FrontController

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(message)s"
)

if __name__ == "__main__":
    server = HTTPServer(("localhost", 8000), FrontController)
    logging.info("Server running on http://localhost:8000")
    server.serve_forever()

How to Test It

Start the server:

python main.py

Successful GET — list of users:

curl -i -H "Authorization: Bearer secret-token-123" http://localhost:8000/users

Successful POST — create a product:

curl -i -X POST -H "Authorization: Bearer secret-token-123" http://localhost:8000/products

No token — 401 Unauthorized:

curl -i http://localhost:8000/users

Non-existent route — 404 Not Found:

curl -i -H "Authorization: Bearer secret-token-123" http://localhost:8000/invalid-path

Unsupported method — 405 Method Not Allowed:

curl -i -X PUT -H "Authorization: Bearer secret-token-123" http://localhost:8000/users

The Full Flow Visualized

curl GET /users  (with valid token)
         │
         ▼
  FrontController.do_GET()
         │
         ├── _log_request()     → INFO [GET] /users
         ├── _authenticate()    → token OK ✓
         └── dispatcher.dispatch()
                    │
                    ▼
             routes["/users"] → UsersHandler
                    │
                    ▼
             UsersHandler.get()
                    │
                    ▼
         HTTP 200 {"users": [...]}


curl GET /users  (no token)
         │
         ▼
  FrontController.do_GET()
         │
         ├── _log_request()     → INFO [GET] /users
         └── _authenticate()    → invalid token ✗
                    │
                    ▼
         HTTP 401 {"error": "Unauthorized"}
         (never reaches the dispatcher)

What This Teaches You About Frameworks

When you use FastAPI:

app = FastAPI()

@app.middleware("http")
async def log_requests(request, call_next):
    # This is the Front Controller
    ...

@app.get("/users")
def get_users():
    # This is the Handler
    ...

The FastAPI app is the Front Controller. The middleware is the cross-cutting logic. The @app.get() decorators register handlers in the dispatcher. Now you know exactly what’s going on under the hood.

When to Use It (and When Not To)

Use it when:

  • You have cross-cutting logic (auth, logging, rate limiting) that applies to all routes
  • You want a single place to change global behavior
  • You’re building a framework, an API Gateway, or a custom router

Don’t force it when:

  • Your app has 2 or 3 endpoints and won’t grow
  • You’re already using a framework that implements it (use it, don’t reinvent it)

Relationship With Other Fowler Patterns

Pattern Relationship
Page Controller The opposite: each page handles its own requests. Simpler, but doesn’t scale.
Application Controller A natural complement: handles screen navigation and application flow, not HTTP.
Dispatcher View / Service to Worker Variants of the same concept with different distribution of responsibilities.

Conclusion

Front Controller is one of those patterns that, once you understand it, you start seeing everywhere. Spring’s DispatcherServlet, Flask’s app, Express’s Router — they’re all Front Controllers.

The value isn’t in implementing it from scratch in production (that’s what frameworks are for). The value is understanding the why behind its design: centralize the cross-cutting concerns, free the business logic.

When your authentication code lives in one single place, when adding new middleware means changing one line, when your handlers are so clean they only speak business — that’s when the pattern is doing its job.

Full code available on GitHub: https://github.com/KrCrimson/front_controller.git

Beyong New and Delete: from auto_ptr to unique_ptr

This guide walks through a legacy pattern built around std::auto_ptr, something still found in many older C++ codebases. The pattern usually involves a helper like cdup that deep‑copies an object and returns it as an auto_ptr, and classes that store these copies and access them through get() checks and raw pointer calls. It worked in its time, but it no longer fits modern C++ and creates unnecessary ownership confusion.

Here, we break down how the pattern works, why it causes problems, and how to refactor it step by step using std::unique_ptr. The goal is not just to replace one smart pointer with another, but to make ownership explicit, improve readability, and align the code with modern C++ practices.

The Legacy Pattern

A typical example looks like this: a cdup utility that deep‑copies an object into an auto_ptr, and a class that stores those copies and uses get() for access and null checks.

namespace sys_Util {
    template <typename T>
    inline std::auto_ptr<T> cdup(T* t) {
        return t ? std::auto_ptr<T>(new T(*t))
                 : std::auto_ptr<T>(0);
    }
}

class WidgetOwner {
    std::auto_ptr<Widget> m_widget;
public:
    explicit WidgetOwner(Widget* w)
        : m_widget(sys_Util::cdup(w)) {}

    void process() {
        if (m_widget.get()) {
            m_widget.get()->doWork();
        }
    }

    Widget* getWidget() { return m_widget.get(); }
};

At first glance, this seems harmless. But once you look closer, the cracks show.

Evaluation of the Pattern

The pattern deep‑copies the object, wraps it in auto_ptr, and uses defensive get() checks before access.

The Problems of the Pattern

  1. auto_ptr is deprecated and removed

    It was deprecated in C++11 and removed entirely in C++17, so the code simply won’t compile on modern standards.

  2. Broken copy semantics

    Copying an auto_ptr silently transfers ownership, leaving the source null. This is one of the most dangerous behaviors in old C++ codebases.

std::auto_ptr<Widget> a(new Widget);
std::auto_ptr<Widget> b = a;  // a becomes null! (silent move)
  1. Confusing ownership
    Passing a raw pointer to the constructor looks like ownership transfer, but the class actually makes a deep copy. The caller must still delete the original pointer, which is easy to forget.
WidgetOwner owner(raw);  // Does it take ownership? No, it copies.
delete raw;              // Must remember to delete (if not leaked)
  1. Clunky access syntax
    m_widget.get()->method() is verbose and error‑prone compared to modern pointer semantics.
m_widget.get()->doWork();     // Too verbose
(*m_widget.get()).property;   // Ugly
  1. Awkward null checks

    Every access requires if (ptr.get()), which is harder to read than necessary.

  2. Raw pointer exposure

    Returning T* invites accidental deletion or dangling references.

Widget* getWidget() { return m_widget.get(); }
// Caller might delete it, or hold it past owner lifetime
  1. Exception safety issues
    new T(*t) can leak if the copy constructor throws an exception.
new T(*t)  // If T(const T&) throws, memory leaks

Thread Safety of the Pattern

No synchronization, no atomicity — not safe for concurrent access. Copy operation on auto_ptr are not atomic.

Memory Safety of the Pattern

The pattern itself doesn’t leak, but raw pointer containers or circular references with auto_ptr can cause trouble.

Refactoring: Moving to unique_ptr

We keep the original behavior: the pointer may be null, and deep copies must still work. But we want clearer ownership and modern syntax.

Step 1: Replace auto_ptr with unique_ptr

std::unique_ptr<Widget> m_widget;

This immediately removes silent moves and clarifies ownership.

Step 2: Remove the cdup Utility

Instead of a custom deep‑copy helper, use std::make_unique directly:

auto copy = original
    ? std::make_unique<Widget>(*original)
    : nullptr;

This eliminates an entire utility and makes intent clearer .

Step 3: Fix the Constructor

The old constructor took a raw pointer and deep‑copied it, which was ambiguous.

We replace it with two explicit options: copy or move (take ownership).

explicit WidgetOwner(const Widget& w)
    : m_widget(std::make_unique<Widget>(w)) {}

explicit WidgetOwner(std::unique_ptr<Widget> w)
    : m_widget(std::move(w)) {}

Null handling becomes explicit and predictable.

Step 4: Add Natural Access Operators

To avoid get() everywhere, we add operator->, operator*, and operator bool.

Widget* operator->() { return m_widget.get(); }
Widget& operator*()  { return *m_widget; }
explicit operator bool() const { return m_widget != nullptr; }

This makes usage clean and modern:

if (m_widget) {
    m_widget->doWork();
}

Step 5: Improve Null Check Syntax

Instead of:

if (m_widget.get())

We now simply write:

if (m_widget)

Or use a guard clause:

if (!m_widget) return;

Step 6: Fix Raw Pointer Exposure

Returning raw pointers is risky. We replace it with safer alternatives:

Widget& getWidget() {
    if (!m_widget) throw std::runtime_error("Widget is null");
    return *m_widget;
}

Or, if needed:

Widget* getWidget() { return m_widget.get(); }  // Document: can be null

Complete Refactored Example

Before: Legacy Code

// sys_util.h
namespace sys_Util {
    template<typename T>
    inline std::auto_ptr<T> cdup(T* t) {
        return t ? std::auto_ptr<T>(new T(*t)) : std::auto_ptr<T>(0);
    }
}

// database_handler.h
class DatabaseHandler {
    std::auto_ptr<Database> m_db;
    std::auto_ptr<Logger> m_logger;

public:
    DatabaseHandler(Database* db, Logger* logger) 
        : m_db(sys_Util::cdup(db))
        , m_logger(sys_Util::cdup(logger)) {}

    void query(const std::string& sql) {
        if (m_db.get()) {
            m_db.get()->execute(sql);
        }
        if (m_logger.get()) {
            m_logger.get()->log(sql);
        }
    }

    Database* getDatabase() { return m_db.get(); }
    Logger* getLogger() { return m_logger.get(); }
};

// main.cpp
Database* db = new Database("localhost:5432");
Logger* logger = new Logger("app.log");
DatabaseHandler handler(db, logger);
handler.query("SELECT * FROM users");
delete db;      // Confusing - does handler own this?
delete logger;  // Did we just double delete?

After: Modern Code

// sys_util.h - DELETED (no longer needed)

// database_handler.h
class DatabaseHandler {
    std::unique_ptr<Database> m_db;
    std::unique_ptr<Logger> m_logger;

public:
    // Clear: we make copies
    explicit DatabaseHandler(const Database& db, const Logger& logger) 
        : m_db(std::make_unique<Database>(db))
        , m_logger(std::make_unique<Logger>(logger)) {}

    // Clear: we take ownership (handles null case)
    explicit DatabaseHandler(std::unique_ptr<Database> db, std::unique_ptr<Logger> logger) 
        : m_db(std::move(db))
        , m_logger(std::move(logger)) {}

    void query(const std::string& sql) {
        if (m_db) {          // Clean bool check
            m_db->execute(sql);  // Natural syntax
        }
        if (m_logger) {
            m_logger->log(sql);
        }
    }

    // Safe access with reference
    Database& getDatabase() {
        if (!m_db) throw std::runtime_error("Database not initialized");
        return *m_db;
    }

    // Or optional access
    Database* getDatabaseOrNull() { return m_db.get(); }

    // Natural operators for direct use
    explicit operator bool() const { return m_db && m_logger; }
    Database* operator->() { return m_db.get(); }
};

// main.cpp - clear ownership
Database db("localhost:5432");     // Stack object
Logger logger("app.log");           // Stack object
DatabaseHandler handler(db, logger); // Clear: we copy
handler.query("SELECT * FROM users");
// No confusion - everything automatically cleaned up

// Handling null case
std::unique_ptr<Database> maybe_db = getDatabaseOrNull();  // Could be null
std::unique_ptr<Logger> maybe_logger = getLoggerOrNull();
DatabaseHandler nullable_handler(std::move(maybe_db), std::move(maybe_logger));
if (nullable_handler) {
    nullable_handler.query("SELECT * FROM users");
}

Migration Summary Table

A concise comparison of old vs. new patterns can be found here.
| Aspect | Before (auto_ptr) | After (unique_ptr) |
|——–|——————–|———————|
| Smart pointer | std::auto_ptr<T> | std::unique_ptr<T> |
| Copy utility | sys_Util::cdup(t) | std::make_unique<T>(*t) |
| Null check | if (ptr.get()) | if (ptr) |
| Member access | ptr.get()->method() | ptr->method() |
| Dereference | *ptr.get() | *ptr |
| Constructor | explicit A(T* t) | explicit A(const T& t) or explicit A(std::unique_ptr<T> t) |
| Exposure | T* get() (dangerous) | T& get() (safe) or T* get() (documented) |
| Bool check | Manual get() | explicit operator bool() |

Migration Checklist

A practical step‑by‑step list covering mechanical replacement, operator additions, interface cleanup, and correctness verification.

Phase 1: Mechanical Replacement

  • [ ] Replace std::auto_ptr<T> with std::unique_ptr<T>
  • [ ] Replace cdup(ptr) with ptr ? std::make_unique<T>(*ptr) : nullptr
  • [ ] Change raw pointer constructor parameters to const T& or std::unique_ptr<T>
  • [ ] Delete the cdup utility function

Phase 2: Add Convenience Operators

  • [ ] Add operator->() and operator*()
  • [ ] Add explicit operator bool() for null checks
  • [ ] Keep get() temporarily for compatibility

Phase 3: Clean Up Access Patterns

  • [ ] Replace ptr.get()->method() with ptr->method()
  • [ ] Replace if (ptr.get()) with if (ptr)
  • [ ] Replace *ptr.get() with *ptr

Phase 4: Improve Interfaces

  • [ ] Change T* get() to T& get() with null check (if never null)
  • [ ] Or keep T* get() but document null possibility
  • [ ] Consider encapsulation instead of exposing pointers

Phase 5: Verify Correctness

  • [ ] Ensure all null handling preserved
  • [ ] Run tests to verify behavior unchanged
  • [ ] Compile with C++17 or later to confirm no auto_ptr usage

Common Pitfalls and Solutions

Covers the three main issues: missing null checks, losing null checks during migration, and constructors that can’t represent null.

Pitfall 1: Forgetting Null Checks

// Wrong - if m_widget is null, this crashes
m_widget->doWork();

// Right - check first
if (m_widget) m_widget->doWork();

Pitfall 2: Losing the Null Check During Migration

// Before - had check
if (m_widget.get()) { ... }

// After - still need check, just cleaner
if (m_widget) { ... }  // Not removed!

Pitfall 3: Not Handling Null in Constructor

// Wrong - assumes non-null
explicit WidgetOwner(const Widget& w) : m_widget(std::make_unique<Widget>(w)) {}
// Can't represent null owner

// Right - handle null case
explicit WidgetOwner(std::unique_ptr<Widget> w) : m_widget(std::move(w)) {}
WidgetOwner null_owner(nullptr);  // Works

Conclusion

The migration from auto_ptr to unique_ptr is more than a mechanical replacement—it’s an opportunity to:

  1. Clarify ownership – unique_ptr makes exclusive ownership explicit
  2. Improve syntax – Operators provide natural pointer semantics
  3. Enhance safety – No silent moves, clear null handling
  4. Modernize code – Comply with C++11/14/17 standards

The pattern preserves the original behavior (deep copy with optional null) while making the code cleaner, safer, and more maintainable. Every auto_ptr replaced is a step toward modern C++.

Consolidating Power Automate Flows to a Single Connection Reference with PAC CLI

A friendly, end-to-end walkthrough for Power Platform admins and developers

If you’ve inherited (or grown) a Power Platform solution where every flow has its own connection reference for the same connector, you already know the pain: every new environment means re-mapping a dozen references, every connection rotation cascades into a dozen failures, and “who owns what” becomes a Tuesday-morning archaeology project.

The cleaner pattern is to consolidate to a single connection reference per connector and have every flow in the solution point at it. That way, when you swap the underlying connection, you swap it once.

This post walks through the full loop using PAC CLI: installing the tooling in VS Code, authenticating to your dev environment, cloning the solution, editing the flow JSON to point at one shared connection reference, and importing the result back without your flows turning themselves off in the process. There’s a small but important warning at the end about who owns what during import — please don’t skip it.

Before and after: five flows each with their own connection reference, vs. five flows pointing at a single shared connection reference

What you’ll need

  • Visual Studio Code installed
  • Maker access to a development environment with the solution you want to refactor
  • (Optional but recommended) A service principal you can use as the importer — covered in the appendix

Step 1 — Install PAC CLI in VS Code

The fastest way to get PAC CLI working is to install the Power Platform Tools extension. It bundles the CLI and wires it up to your integrated terminal automatically.

  1. Open VS Code.
  2. Open the Extensions view (Ctrl+Shift+X).
  3. Search for Power Platform Tools (publisher: microsoft-IsvExpTools).
  4. Click Install.
  5. Reload VS Code if prompted.

Power Platform Tools extension card in the VS Code Extensions view, with the Install button highlighted

Open a new PowerShell terminal in VS Code (Ctrl+`) and confirm everything’s wired up:

pac

You should see the PAC CLI banner with a version number:

Microsoft PowerPlatform CLI
Version: 1.x.x

If pac isn’t recognized in a plain Windows Command Prompt or system PowerShell, that’s expected — by default the extension only enables PAC inside the VS Code integrated terminal. If you want it everywhere, install the .NET Tool version or the Windows MSI alongside it. For this walkthrough, the VS Code terminal is perfectly fine.

Step 2 — Authenticate to your dev environment

PAC CLI uses authentication profiles. Each profile is a stored connection to a specific tenant and, optionally, a specific environment. Most teams end up with a profile per environment per customer.

Create a profile pointed at your dev environment:

pac auth create --name "Contoso-Dev" --environment "https://contoso-dev.crm.dynamics.com"

A browser window opens; sign in with your maker account. You can substitute the environment URL for an environment ID, a unique name, or even a partial name — PAC will resolve it.

Confirm the profile is active:

pac auth list

Output looks something like:

Index Active Kind      Name         Friendly Name   Url                                       User
[1]   *      UNIVERSAL Contoso-Dev  Contoso Dev     https://contoso-dev.crm.dynamics.com/     [email protected]

If you already have multiple profiles and need to switch, use pac auth select --index <n>. A quick pac auth who will tell you exactly which environment is currently in play — run it before any destructive command, just to be safe.

Terminal output of pac auth list showing one active profile pointed at the dev environment

Tip — find your environment ID. Open the Power Platform admin center, pick your environment, and copy the Environment ID from the Details pane.

Step 3 — Clone the solution

pac solution clone is the right tool for this job. It pulls the solution down as a buildable project (with a cdsproj) and unpacks it into source files you can read, diff, and edit. Use clone (rather than export) when you want to change the contents of the solution, not just deploy it.

From the folder where you want the project to live:

pac solution clone --name ContosoIntegrations

--name is the solution unique name, not the display name. Find it under Solutions in the maker portal — it’s the column to the right of the display name.

After it runs, you’ll see a folder structure like this (the XML format that clone emits by default):

ContosoIntegrations/
├── ContosoIntegrations.cdsproj
└── src/
    ├── Other/
    │   ├── Customizations.xml      ← connection references live in here
    │   ├── Solution.xml
    │   └── Relationships.xml
    └── Workflows/
        ├── FlowA-1A2B3C4D....json  ← flow definition (this is what you edit)
        ├── FlowA-1A2B3C4D....xml
        ├── FlowB-5E6F7G8H....json
        └── FlowB-5E6F7G8H....xml

VS Code Explorer showing the cloned solution structure, with src/Other/Customizations.xml and src/Workflows highlighted

The two places that matter for this refactor are src/Other/Customizations.xml (where every connection reference component is defined, all in one file) and src/Workflows/ (one JSON file per flow, named <FlowName>-<GUID>.json, with a matching .xml workflow descriptor).

A note on formats. If your team uses native Dataverse Git Integration — or a newer pac CLI configured to emit the YAML source-control format — you’ll see a different layout (solutions/<name>/solution.yml, separate connectionreferences/ and workflows/ folders at the repo root, etc.). The mechanics in this post are the same; you’d just edit connectionreferences/*.yml and workflows/<flow>/<flow>.json instead of Customizations.xml and Workflows/<flow>.json. For the rest of this walkthrough I’ll assume the default XML layout.

Step 4 — Update the flow JSON to use one connection reference

This is the core of the change. Today, each flow has its own connection reference logical name baked into its definition. We’re going to:

  1. Pick (or create) the single connection reference you want all flows to share.
  2. Edit each flow’s definition JSON so its connectionReferences block points at that shared logical name.

4a. Pick the canonical connection reference

Open src/Other/Customizations.xml and find the <connectionreferences> element. Each connection reference in the solution sits inside it as a <connectionreference> node. Pick the one you want all flows to point at — say the SharePoint one with logical name contoso_sharedsharepoint. It will look something like:

<connectionreferences>
  <connectionreference connectionreferencelogicalname="contoso_sharedsharepoint">
    <connectionreferencedisplayname>Shared SharePoint</connectionreferencedisplayname>
    <connectorid>/providers/Microsoft.PowerApps/apis/shared_sharepointonline</connectorid>
    <description>Single connection reference for all SharePoint actions</description>
  </connectionreference>
  <connectionreference connectionreferencelogicalname="contoso_flowa_sharepoint">
    <connectionreferencedisplayname>Flow A SharePoint</connectionreferencedisplayname>
    <connectorid>/providers/Microsoft.PowerApps/apis/shared_sharepointonline</connectorid>
  </connectionreference>
  <!-- ...the other per-flow connection references you're consolidating away... -->
</connectionreferences>

Note the connectionreferencelogicalname of the one you’re keeping — you’ll wire every flow to that value next.

4b. Update each flow’s JSON

Open one of the flow JSON files at src/Workflows/<FlowName>-<GUID>.json. You’re looking for the connectionReferences node near the top of the definition. Before consolidation it typically looks like this:

{
  "properties": {
    "connectionReferences": {
      "shared_sharepointonline": {
        "runtimeSource": "embedded",
        "connection": {
          "connectionReferenceLogicalName": "contoso_flowa_sharepoint"
        },
        "api": {
          "name": "shared_sharepointonline"
        }
      }
    },
    "definition": {
      "...": "..."
    }
  }
}

Change connectionReferenceLogicalName to your canonical reference:

{
  "properties": {
    "connectionReferences": {
      "shared_sharepointonline": {
        "runtimeSource": "embedded",
        "connection": {
          "connectionReferenceLogicalName": "contoso_sharedsharepoint"
        },
        "api": {
          "name": "shared_sharepointonline"
        }
      }
    },
    "definition": {
      "...": "..."
    }
  }
}

Repeat for every flow JSON in src/Workflows/. If you have a lot of flows, a careful find-and-replace across that folder works well — just make sure the key under connectionReferences (e.g., shared_sharepointonline) matches the connector you’re consolidating, and that you only replace the old logical names in connectionReferenceLogicalName, not the API name in the api.name field.

4c. Clean up the orphaned connection references

Once nothing in any flow JSON points at the per-flow references anymore, remove their <connectionreference> nodes from src/Other/Customizations.xml. While you’re in there, also remove their entries from the <RootComponents> block in src/Other/Solution.xml (look for type="29" entries with the schema names of the references you’re dropping — type 29 is connection references). Keeping only the canonical reference in both files is the whole point of the exercise.

Side-by-side diff showing the old per-flow connectionReferenceLogicalName replaced with the shared one

Step 5 — Pack and import the solution back

You have two reasonable patterns here. Both work; pick the one that matches how mature your ALM is.

Pattern A — Import the updated solution and let the maker portal prompt for connections

From inside the cloned project folder, build and import in one shot:

pac solution import --publish-changes

(With no --path, PAC builds the current cdsproj and imports the resulting .zip.) When the importing user has connections that match the connector(s) in your remaining connection reference, the import wizard auto-fills them. Otherwise you’ll be prompted to pick or create one.

Pattern B — Import with a deployment settings file (the ALM-friendly way)

This is the path you want for CI/CD or anything reproducible. Generate a deployment settings JSON from the built solution zip:

pac solution create-settings `
  --solution-zip .binDebugContosoIntegrations.zip `
  --settings-file .settingsdev.settings.json

You’ll get a JSON file like this, with one entry per connection reference and one per environment variable:

{
  "EnvironmentVariables": [],
  "ConnectionReferences": [
    {
      "LogicalName": "contoso_sharedsharepoint",
      "ConnectionId": "",
      "ConnectorId": "/providers/Microsoft.PowerApps/apis/shared_sharepointonline"
    }
  ]
}

Grab the ConnectionId from the target environment — easiest path is make.powerapps.com → Connections, click the connection, and copy the GUID from the URL (the segment after /connections/<apiName>/). Paste it into the JSON:

{
  "EnvironmentVariables": [],
  "ConnectionReferences": [
    {
      "LogicalName": "contoso_sharedsharepoint",
      "ConnectionId": "9f66d1d455f3474ebf24e4fa2c04cea2",
      "ConnectorId": "/providers/Microsoft.PowerApps/apis/shared_sharepointonline"
    }
  ]
}

Then import using the settings file:

pac solution import `
  --path .binDebugContosoIntegrations.zip `
  --settings-file .settingsdev.settings.json `
  --publish-changes `
  --force-overwrite

Because the connection ID is wired in up front, the import doesn’t have to ask you anything interactively. This is the same shape of file Power Platform Build Tools expects in Azure DevOps pipelines, so you can re-use it.

Successful pac solution import using a deployment settings file, with all flows enabled

⚠️ Critical: who owns what when you import

Two side-by-side outcomes: the bad scenario where the importer has no access to the connection so every flow is imported off, and the good scenario where a service principal with shared connection access imports cleanly with all flows on

This is the part everyone learns the hard way exactly once. Skim it, please.

When a solution containing flows is imported, Power Automate validates that the importing user can use every connection referenced by every flow. If they can’t, the affected flows get imported in the off state and have to be turned on manually. With a large solution that you “just” reimported, that can mean every flow in the solution needs to be re-enabled by hand.

Two ways to avoid this:

Option 1 — The importer owns the connection. Whoever runs pac solution import should also be the owner of the connection that the (now single) connection reference points to. If you’re the person doing the import, make sure you created — or have been gifted ownership of — the connection in the target environment.

Option 2 — Import as a service principal that has the connection shared with it. This is the right answer for anything beyond a single-developer dev environment. The SPN owns the flows after import, doesn’t churn when humans leave or change roles, and pipelines can use it without storing user credentials. Share the connection with the SPN as Can use before you import. Setup is in the appendix.

Why “all flows turn off” happens. It’s the same ConnectionAuthorizationFailed failure mode you’d hit turning a flow on manually — Power Automate refuses to enable a flow when the activating identity doesn’t have rights to every connection it touches. The solution import is just the same check applied to N flows at once.

Troubleshooting

“pac is not recognized.”
You’re probably in a terminal outside VS Code. Either run the command in the VS Code integrated terminal (where the extension wires up the PATH) or install the .NET Tool so pac is available everywhere.

pac solution clone fails with “Solution not found”.
You’re using the display name. Switch to the unique name from the Solutions list. Also confirm pac auth who is pointed at the correct environment.

Import succeeds but the flows are turned off.
Whoever ran the import doesn’t have access to the connection. See the warning above. Either share the connection with that identity (as Can use) and turn the flows on, or re-run the import as a user/SPN that does have access. Once enabled by the connection owner, co-owners can toggle the flow on and off as needed.

“ConnectionAuthorizationFailed” when activating a flow.
Same root cause — the activating user is missing access to at least one connection. Either share all connections used by the flow with that user, or have the connection owner be the one to enable the flow.

The deployment settings file has empty ConnectionId values.
That’s normal — pac solution create-settings generates the file with empty slots. You fill them in per-environment. Keep one settings file per environment (dev.settings.json, test.settings.json, prod.settings.json) and check them into source control.

Pack fails after editing JSON.
Make sure the JSON is still valid (a stray comma is the usual culprit) and that you didn’t change any GUIDs, only the connectionReferenceLogicalName values. Run pac solution pack against the cloned folder before importing if you want to validate without round-tripping.

Flow shows “Invalid connection” after import.
The connection reference is pointing at a connection that’s in a bad state — usually expired credentials. Go to Connections in the maker portal, edit/refresh the underlying connection, and the reference will pick it back up.

Appendix — Setting up a service principal for imports

If you’re moving to pipeline-driven deployments, do this once and you’ll thank yourself.

A1. Register the application in Microsoft Entra

  1. Open the Microsoft Entra admin center.
  2. Go to Identity → Applications → App registrations → New registration.
  3. Give it a name like pp-deployer-spn. Single tenant is fine.
  4. After registration, note the Application (client) ID and Directory (tenant) ID.
  5. Under Certificates & secrets, create a new Client secret. Copy the value immediately — you can’t view it again.

Entra app registration overview with the Application (client) ID and Directory (tenant) ID called out for copying

A2. Create the application user in Dataverse

  1. Open the Power Platform admin center.
  2. Pick your target environment → Settings → Users + permissions → Application users.
  3. Click New app user, find the registration from A1, pick the business unit, and assign a security role with enough permissions for what your pipeline does (often System Customizer plus a custom “deployer” role).

A3. Authenticate PAC CLI as the SPN

pac auth create `
  --name "Contoso-Dev-SPN" `
  --environment "https://contoso-dev.crm.dynamics.com" `
  --applicationId "<APP-CLIENT-ID>" `
  --clientSecret "<CLIENT-SECRET>" `
  --tenant "<TENANT-ID>"

A4. Share the connection with the SPN

Back in the maker portal:

  1. Connections → click your shared connection (e.g., the SharePoint one).
  2. Share → search for the application user you created.
  3. Set permission to Can use → Save.

Repeat for every connection your single connection reference is going to be wired to.

Note. OAuth connections can only be explicitly shared with a user representing a service principal — not with security groups. If you have multiple SPNs (one per environment), share with each one explicitly.

A5. Import as the SPN

pac auth select to make the SPN profile active, then run your pac solution import as in Step 5. Because the SPN owns (or has been granted access to) the connection, the flows come in enabled and stay enabled across redeployments — no more “every flow is off” Monday mornings.

Wrapping up

A single shared connection reference is one of those changes that feels like a small refactor and pays you back every single deployment thereafter. The mechanical part — clone, edit JSON, import — is straightforward once you’ve done it once. The part that bites people is ownership: make sure the identity doing the import has access to the underlying connection, or you’ll spend the next hour turning flows back on by hand.

If you want to take the next step, wire the deployment settings JSON and the SPN into an Azure DevOps pipeline using the Power Platform Build Tools. At that point you’ve gone from “manual refactor” to “every environment is one click.”

Sources

Creating a super-efficient Valid Palindrome algorithm in C#

First, do you know what a Palindrome is? No? So, according to the first Google result: A palindrome is a word, phrase, number, or sequence that reads the same backward as forward. The term originates from the Greek word “palindromos”, meaning “running back again”.

Secondly, are you able to verify whether a string is a valid palindrome in an algorithm created with C#? This is the problem we’re going to solve today🫡.

The problem: Given a string s, return true if it is a palindrome, or false otherwise.

Example: Input: s = “Step on no pets” | Output: true
Explanation: “steponnopets” is a palindrome.

💡 Analyzing the problem

To determine if a string is a palindrome, the main idea is to check if it reads the same forward and backward.

However, real-world examples like “Step on no pets” usually have a catch: case sensitivity (capital ‘S’ vs lowercase ‘s’) and non-alphanumeric characters: spaces, punctuation, or symbols, etc… To solve this effectively, we can follow a two-step process:

  1. Clean the string
  2. Check for symmetry

The Step-by-Step Strategy

Step 1: Clean the Input

Before doing any comparisons, we need to normalize the string so that formatting doesn’t get in the way.

  1. Convert everything to lowercase so ‘S’ and ‘s’ match.

  2. Remove all non-alphanumeric characters (spaces, commas, periods, etc.). For your example, Original: “Step on no pets” –> Cleaned: “steponnopets”

Step 2: The Two-Pointer Approach

The most efficient way to check for a palindrome is to use two pointers (or indices): one starting at the very beginning (left) of the cleaned string, and one at the very end (right).

  1. Compare the character at the left pointer with the character at the right pointer.

  2. If they match, move the left pointer one step right, and the right pointer one step left.

  3. If they don’t match, you can stop immediately and return false, definitively it’s not a palindrome.

  4. Repeat this until the pointers meet or cross in the middle. If you make it to the middle without any mismatches, return true.

🛠️Hands on

In C# we can do the following:

public class Solution {
    public bool IsPalindrome(string s) {
        int left = 0;
        int right = s.Length - 1;

        while (left < right) {
            if (!char.IsLetterOrDigit(s[left])) {
                left++;
            }
            else if (!char.IsLetterOrDigit(s[right])) {
                right--;
            }
            else {
                if (char.ToLowerInvariant(s[left]) != char.ToLowerInvariant(s[right])) {
                    return false; 
                }
                left++;
                right--;
            }
        }

        return true;
    }
}

The solution: The Two-Pointer Approach

Instead of creating a brand new “cleaned” string (which takes up extra memory), this solution moves the pointers across the original string and simply skips over any spaces or punctuation on the fly. This optimizes our space complexity to O(1).

  1. First, we Initialize pointers at the beginning and end of the string. Then, while left is lass than right:

  2. Verify if left index is letter or digit, if it’s affirmative, move the left pointer right (left++).

  3. Verify if right index is letter or digit, if it’s affirmative, move the right pointer left (right++).

  4. If both are alphanumeric, compare them case-insensitively. If the left index is different from the right index, so, an incompatibility was found. It returns false.

  5. Left is incremented and right decremented.

  6. Finally, if all valid pairs matched, return true (valid palindrome)

1. ⏳ Time Complexity: O(n)

What it means: The time it takes to run the code grows linearly with the length of the string (n).

Why: In the worst-case scenario (when the string is a palindrome), the left and right pointers start at the edges and meet exactly in the middle. This means every character in the string is inspected exactly once.

Even though we skip non-alphanumeric characters, each character is still visited only a single time by one of the pointers.

2. 📦 Space Complexity: O(1)

What it means: The memory used by the algorithm is constant, meaning it stays the same no matter how massive the input string gets.

Why: Unlike the LINQ approach (or solutions in other languages that create a brand-new, filtered version of the string), this method processes the original string in place.

The only extra memory allocated during execution is for a couple of primitive integer variables (left and right) to track the array indices.

🌱 Wrap up

I hope it helps you understand more about Data Structure and how to solve the Valid Palindrome problem with an elegant and efficient way.

I Got Sick of My Claude Code UI Slop, So I Crystallized It into a Go CLI

Every time I asked Claude Code to “build me a landing page,” I got the same page back.

Centered hero. Inter font for everything. Maybe Space Grotesk if it felt artsy. A purple-to-blue gradient on the H1. A small pill badge floating above the headline (“New,” “Beta,” “Now with AI”). Three identical feature cards underneath, each with a Lucide icon on top, usually zap, shield-check, sparkles. A numbered 1-2-3 steps row. Glassmorphism on the nav. shadcn/ui everywhere. Dark mode with grey-on-darker-grey body text that fails contrast checks but “looks clean.”

After about the twentieth one I started seeing it in my sleep. So I stopped complaining and built a tool.

uislop: a Go CLI that scores how AI-generated a page looks

It takes a URL, fetches the HTML and linked JS, runs a pile of regexes against it, and prints a single number between 0 and 100.

$ uislop https://some-ai-startup.ai
  font:Inter            hits=12  weight=2  add=24
  color:purple          hits=8   weight=3  add=24
  color:gradient        hits=14  weight=2  add=28
  css:shadcn            hits=4   weight=4  add=16
  icon:svg_lucide       hits=11  weight=2  add=22
  layout:centered       hits=7   weight=2  add=14
  layout:allCaps        hits=9   weight=1  add=9
  ----
87

0 means “looks human-built or pre-2022.” 100 means “this was vibe-coded last Tuesday.”

Why Go? Same reason I defaulted my new projects to Go. One binary, runs anywhere, no virtualenv, no dependency drift. I can scp it to anything and grep the entire web.

The signals I crystallized

After eyeballing a few hundred AI startup landing pages, the patterns are weirdly consistent.

Fonts

  • Inter on literally everything
  • Space Grotesk when feeling fancy
  • Geist Sans / Geist Mono in the year-of-Vercel
  • Instrument Serif for one italic word in the H1 (“the only ~platform~ you need”)
  • CameraPlainVariable served from cdn.gpteng.co, basically a Lovable fingerprint

Colors

  • VibeCode purple (#8b5cf6, #7c3aed)
  • Gradients on the H1, the CTA, the icon backgrounds, the nav border, everywhere
  • Dark mode with bg-zinc-950
  • Body text at text-zinc-400 over bg-zinc-900, 3.8:1 contrast, fails WCAG, “looks clean”
  • Soft colored glows behind every primary button

Layout

  • Centered hero with max-w-3xl text-center
  • Tiny pill badge above the H1 (rounded-full bg-primary/10)
  • Three feature cards, grid-cols-3 gap-8, each with an icon on top
  • Numbered 01 - Sign up / 02 - Get started / 03 - Win steps
  • All-caps section labels with tracking-wider
  • Stat banner row (“10k+ users,” “99.9% uptime,” “$0 cost”)

The icons

Always Lucide. Always the same five: zap, shield-check, bot, sparkles, rocket. If a page has all five visible above the fold, it is a tell.

The CSS giveaways

  • shadcn primitives in the bundle
  • backdrop-blur-md bg-white/10 on the nav
  • Tailwind utility soup
  • cdn.gpteng.co in the asset URLs

What I learned shipping it

A few things surprised me.

  1. The signal is loud. I expected to need ML. Regex on the HTML and one pass of linked JS gets you to 90%+ accuracy on the obvious cases.
  2. Geist Sans skews the score harder than I thought. Half the false positives are real human-built sites that just happen to use Vercel’s font.
  3. The pattern travels. Once you have the score, you start seeing it everywhere. Half the recent YC batch lands in the 80s.
  4. The cure for being part of the problem is to look at your own page. Mine scored 72. I shipped the tool anyway.

What this is and is not

It is not a tool to shame anyone. Shipping anything is hard. If shadcn + Lucide + purple gradient is what gets you to revenue, ship it.

It is a forcing function. When the default output of every model converges on the same aesthetic, “looks like everyone else” becomes the new “looks unstyled.” The score is just a mirror.

One weird trick to drop your score: swap Inter for a real typeface, pick a color that is not on the violet wheel, and delete one feature card. Three minutes, ten points off.

IBM Bob writes a Vault secrets engine

Bob starting to design a Cloudflare secrets engine

For about five years or so, I’ve reverse-engineered my way through building Vault secrets engines (AKA a type of plugin). While Vault has a number of them already built, I often want Vault to manage all of my secrets and it doesn’t support a few of them. As a result, I end up building my own, such as ones to manage HCP Terraform API tokens or Vault as a session token service.

Unlike plugin development for Terraform, Vault does not have any frameworks to expedite plugin development. After forgetting multiple times how to build a Vault secrets engine, I ended up creating a tutorial with the steps and considerations.

This problem got me thinking: why can’t I create a skill for a coding agent to help me build a Vault secrets engine? After all, I wrote the tutorial so it should not be so difficult to convince a coding agent to build it and not generate slop. I am not an expert in creating Vault secrets engines, but I do have experience reading through lots of Vault plugins and finding patterns in them!

Building designer skills

In my limited experience, I found that Vault secrets engine development involves 60% debating the expected workflow of the plugin, 30% writing code, and 10% testing and hoping it doesn’t break anything. This breakdown reflects that service APIs for credentials issuance and revocation have different behavior. Furthermore, secrets take the form of certificates or other mechanisms that don’t quite fit CRUD actions. Even the identity or group domain models using the credentials differ.

Three years ago, I had to review an existing secrets engine, find something similar to what I wanted, and reverse engineer it for the secrets engine I wanted. I hoped that a coding agent, like IBM Bob, would help with the mental load of scanning through API specifications and designing the plugin. Furthermore, I needed Bob to remember common patterns and “gotchas” for designing secrets engines.

Ideally, I would summarize all the collective wisdom of the engineers who built various Vault plugins over the years and put that into a skill. Turns out I could actually do that by exporting all of the RFCs (Request for Comments) designing various secrets engines into files and asking Bob to summarize and identify patterns for me. (Note: I had to extract the RFCs into PDFS and then use Docling to make it LLM-friendly. Thank you, Docling.)

It generated an essay for me, which I will not include here, but the key points at the end did help.

## Common Pitfalls

- Unintended revocation when generating new credentials
- ID confusion (multiple ID types in same system)
- Incomplete cleanup during revocation
- Scope creep in initial implementation
- Testing gaps with hosted-only services

How refreshing. I did recall scope creep and incomplete cleanup in my small sample size of secrets engines that I created. With this promising start, I drafted a vault-secrets-engine-designer skill to help design a secrets engine.

I decided to test the skill on Cloudflare API tokens, which do not have an official secrets engine. It started quite promisingly, in that Bob activated my skill and proceeded to research the Cloudflare API.

Bob starting to design a Cloudflare secrets engine

Things took an interesting turn when Bob opened a browser to navigate the API documentation.

Bob opening the browser to navigate API documentation

RIP my tokens. Bob found the information it needed and went right into designing the interface.

Bob designs the secrets engine interface

The design phase turned out to be fairly quick, although I caught a few mistakes. A major one had to do with how Cloudflare API tokens have an expires_in field. Since Vault has a leasing system as well, these fields must match. During design, Bob concluded that if you renew the lease, the token will just have to expire with expires_in. I deemed this unacceptable behavior, so we went back to refining the design, such that the secrets engine updates the expires_in field any time the lease renews. I had Bob write the design to a file before moving on to the next task.

Trying to code

For the most part, I had to assume that a number of LLMs probably had some inkling of a tutorial for building a custom Vault secrets engine. Awesome. However, I wanted to make sure that I was clear on the steps I wanted Bob to take for building the secrets engine. Bring on yet another skill, the vault-secrets-engine-developer skill

Bob using a skill to build a Vault secrets engine

Bob went through and wrote each file in the order that I found best to implement secrets engines. First, define the backend and configuration. Then, define the role that maps to identities. Finally, implement the logic for credentials rotation.

Again, a few mistakes during this phase. I was not careful enough in my initial design review and discovered that Bob added permission_groups to the role schema. Cloudflare’s API has a set of policies you can define for the token but no top-level permission_groups. Oops. This was quite costly as a refactor (again, RIP my tokens).

Working with Bob to fix the incorrect schema

After resolving this problem, Bob proceeded to generate and run unit tests. I appreciated this since I really did not enjoy generating test cases for secrets engines. I never remembered how to mock the plugin backends and various API calls, so this went much faster than expected.

Conclusion

Overall, the secrets engine works. However, what about token usage? I used about 2.8 million in designing and 4.1 million in developing. I asked Bob to provide me a breakdown. During the design phase, most of the tokens went toward browser research of the API and iterative feedback. During the development phase, most of the tokens went toward refactoring the schema based on the incorrect attributes. It turns out that if you do some work upfront, you could optimize your tokens. If I gave Bob a downloaded specification for Cloudflare’s API token and reviewed the architecture more carefully, I could save myself a half million to 2 million tokens.

Did it make developing a secrets engine plugin faster? It did, albeit for a more straightforward use case with a fairly well-automated rotation workflow. The API to revoke and generate Cloudflare’s API tokens follows a standard pattern for creating and deleting, which helped Bob and me plan and generate the secrets engine quickly. I have yet to try this with a more complicated use case, so the skills may need more refining. I’ll give it a try again the next time I write a secrets engine, pending I still have tokens to use in my budget.