DEV Community

TuviDev
TuviDev

Posted on

How I built a DSL for infrastructure in 10 days with AI

Lessons from building infra-lang — an IaC DSL with 5 compilation targets, an LSP, and live K8s E2E tests. What worked, what surprised me, and what I'd do differently.

The idea

Every team I've worked with maintains the same application in at least three formats: Kubernetes manifests for production, a docker-compose.yml for local dev, and a GitHub Actions workflow for CI. Every change touches all of them. They drift. And bugs in the K8s YAML only surface when kubectl apply rejects them — not when you write them.

I wanted one file that compiles to all of them. So I built Infra Lang.

What it looks like

service api {
    image: "myapp/api:v1.0.0"
    replicas: 3
    port 8080
    health http("/health")
    resources {
        requests { cpu: 200m, memory: 256Mi }
        limits   { cpu: 1000m, memory: 512Mi }
    }
}
Enter fullscreen mode Exit fullscreen mode

That block compiles to a Kubernetes Deployment + Service, a Docker Compose service, a Helm chart, Terraform HCL, or a GitHub Actions workflow. One source of truth, five targets.

Why a DSL, not YAML templates
Helm, Kustomize, and string interpolation in CI all push the same problem down the road: you still write YAML, just with placeholders. Validation still happens late.

A real DSL gives you three things:

A parser that catches errors immediately. Infra Lang uses a hand-written LALR(1) grammar with {} blocks. A typo is caught at parse time with a location and a helpful message — not after you deploy.

Compile-time linting. 10 security rules catch hardcoded secrets, mutable image tags, and privileged containers. 13 reliability rules catch thundering-herd replica counts, databases without backups, and single-replica Kafka. Error-severity findings block compilation entirely.

One mental model. You think in services, databases, queues, and pipelines — not in individual YAML documents for each platform.

The AI-assisted process
I want to be transparent: this project was built with extensive AI assistance — about 40 sessions over 10 days using Claude as a coding partner.

Here's how the process actually worked:

I owned every architecture decision. The DSL syntax, which backends to support, what the LSP should do, how to structure tests. AI doesn't make those calls.

AI wrote implementation and tests. I reviewed, ran, and iterated. Every session had a specific scope: "add semantic tokens to LSP", "fix Compose secret mounting", not "build me a DSL".

I ran everything locally. Docker Desktop, kind for Kubernetes E2E, real helm lint, real docker compose up. AI can't do that.

27 real bugs were found through the process. Secret base64 encoding that passed unit tests but failed kubectl apply. Service port naming that Kubernetes rejected. Windows URI path conversion that crashed the LSP. These are things you only find by actually running the code against real targets.

Is there risk in AI-assisted development? Yes — I don't know 100% of the codebase intimately. But the code runs, tests pass on 3 operating systems and 3 Python versions, and the output is validated against real Kubernetes clusters.

What surprised me
Coverage lies
93% line coverage sounded impressive. Then I ran mutation testing — automatically introducing bugs into the code and checking whether tests catch them.

Results: some modules had 34% mutation score despite high line coverage. The tests executed the code but didn't verify the output. A test that says assert result is not None gives you coverage but catches nothing.

After two sessions of targeted fixes, critical modules reached 82-100% mutation score. The lesson: line coverage tells you what code ran, not whether your tests actually work.

Cross-platform is harder than you think
My first CI run on Windows failed because .read_text() without encoding="utf-8" uses the system default (cp1252 on Windows). Every file read in the entire codebase needed explicit UTF-8.

Docker daemon detection needed special handling too — Windows CI runners have the Docker CLI installed but no running daemon. docker version succeeds but docker compose up fails. The fix: check docker info exit code, not just whether the binary exists.

The LSP was the most rewarding part
Building a language server taught me more about developer experience than anything else in this project. Each feature had its own challenge:

Cross-file rename needed word-boundary-aware regex — renaming db shouldn't touch main-db
Semantic tokens required a line-based tokenizer that doesn't crash on malformed input
Signature help needed brace-balance counting to detect which block the cursor is inside
Workspace indexing needed to scan files on disk without blocking the main LSP thread
The result: completion, hover, diagnostics, go-to-definition, find-references, rename, semantic tokens, signature help, document highlight, and folding — all working across every .infra file in the project.

Live E2E tests catch what unit tests miss
Three of the most serious bugs were invisible to unit tests:

Kubernetes Secrets with invalid base64 — unit tests checked structure, kubectl apply rejected the values
Multi-port Services without port names — valid YAML, invalid Kubernetes API
Compose secrets declared but never mounted to services — file existed, container couldn't access it
Now there's an opt-in test suite that actually spins up a kind cluster, runs kubectl apply, starts docker compose up, and runs helm lint --strict. These tests found real bugs that 1800+ unit tests missed.

What I'd do differently
Start with PyPI from day 1. I spent the first week telling people to pip install git+https://github.com/.... The friction was enormous. Once I published to PyPI, installation became pip install infra-lang — 5 seconds instead of a paragraph of instructions.

Write blog posts before launching. SEO takes weeks to build. Dev.to articles, technical deep dives, comparison posts — all of these should exist before you post on HN, not after.

Build community before features. I built 5 backends, a full LSP, Helm chart generation, and live E2E tests. Then I posted on Hacker News and got 13 upvotes. Features don't create adoption. Reach does.

The numbers
1877 tests, 93% line coverage, mutation testing on all critical modules
5 backends: Kubernetes, Helm, Docker Compose, Terraform, GitHub Actions
LSP with 10+ features including completion, hover, rename, semantic tokens, and signature help
CI on Linux, macOS, Windows across Python 3.11, 3.12, 3.13
Live E2E: real kubectl apply on kind, real docker compose up, real helm lint
Try it
Bash

pip install infra-lang
infra --help
Or with the VS Code language server:

Bash

pip install 'infra-lang[lsp]'
Links:

If you maintain infrastructure in multiple formats, I'd love your feedback — especially on language design and which compilation targets matter most.

Top comments (4)

Collapse
 
reidmarlow profile image
Reid Marlow

The compile-before-apply part is the real win, not the single source. Bugs showing up at kubectl apply instead of at write time is exactly the drift problem, and a compiler moves that rejection earlier. The boundary I'd probe is hand-edits: once someone tweaks a generated manifest directly, you're back to three drifting files with no signal. Did you make the generated output read-only, or do you plan to?

Collapse
 
tuvidev profile image
TuviDev

Great question — you've hit the actual hard problem, not the marketing one.

Honest answer for v0.1.1: the generated output is plain files on disk. Nothing stops you from hand-editing a deployment.yaml after infra compile, and yes, the moment you do, you're back to drift with no signal.

The workflow I recommend today is the same one you'd use for any compiled artifact: don't commit the output. Treat generated manifests like .class files — .gitignore them, regenerate in CI, apply from there. The .infra source is the only thing in version control. This also solves the "three drifting files" angle: K8s YAML, Compose, and Terraform all regenerate from the same source every pipeline run, so they stay in sync with each other even though none of them are checked in.

Two things in the codebase partially address the hand-edit escape hatch:

infra import — a reverse compiler (K8s YAML → .infra). If someone does hand-edit a live manifest, you can re-import and reconcile back to source. It's lossy on edge cases, but it closes the loop better than "start over."
infra doctor — currently checks tooling health, but the architecture supports adding a drift-detection mode: re-compile, diff against what's on disk, warn if they diverge. That's on the roadmap.
A # AUTO-GENERATED by infra-lang — do not edit header is tempting and cheap; I'll probably add it. But real enforcement has to live in CI (git diff --exit-code after compile), not in the file itself.

The compile-before-apply win is exactly what you described — catching a missing } or a type mismatch at write time instead of at kubectl apply time. Drift is the next layer, and I don't want to pretend it's solved at v0.1.1.

Sorry for that long waiting for respond (I was just busy).

Collapse
 
richard_smith_154156d471ef profile image
Richard Smith

Thinking in services and databases instead of wrestling with K8s YAML concepts is the real DX win here. The mental model shift matters more than the five targets.

Collapse
 
tuvidev profile image
TuviDev

Thank you for this — you’ve captured the core philosophy behind infra-lang better than I could have phrased it myself.
When building cloud infrastructure, 90% of developers just want to express "I have a web service, a PostgreSQL database, and a Redis cache with these resource limits and environment variables."
Having to translate that mental model into 200+ lines of Kubernetes YAML (Deployment, StatefulSet, Service, PersistentVolumeClaim, ConfigMap, PodDisruptionBudget) introduces enormous cognitive load and drift.
Compiling to 5 targets (K8s, Helm, Compose, Terraform, GitHub Actions) is the execution mechanism, but elevating the developer mental model to application-level primitives is the actual DX goal.
Really appreciate this perspective — it reinforces our focus on keeping .infra clean and high-level rather than exposing low-level manifest leakage!