Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

4 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

tmc-server

License: MIT

A lightweight, self-hostable implementation of the Terramate Cloud HTTP API, with a built-in web dashboard for querying infrastructure state.

Designed to be pointed at directly by the Terramate CLI — no network access to cloud.terramate.io needed. All state is persisted in PostgreSQL.


Features

API server

  • Full coverage of every endpoint consumed by the Terramate CLI
  • Static API key authentication (SHA-256 hashed, stored in the database)
  • HTTP Basic auth compatible — matches exactly what the CLI sends (Authorization: Basic base64(token:))
  • Single-organisation model — no multi-tenancy overhead
  • Schema applied automatically on startup (IF NOT EXISTS — safe to restart)
  • Native Postgres types via pgx v5TEXT[] arrays, JSONB, TIMESTAMPTZ
  • Clean routing via Gin — no manual path-string parsing
  • Correct state machine for composite stack.status — derived from both drift and deployment outcomes per the real TMC transition table
  • Drift status propagation: every PATCH /v2/drifts/:org/:uuid immediately updates the parent stack's drift_status and recomputes status
  • Resources materialised from changeset_json at PATCH time — parsed from the tfjson.Plan format produced by terraform show -json <planfile>

Web dashboard (/ui)

  • Stacks view with real-time filtering and column sorting
  • Resources view with filtering, column sorting, and account-ID dropdown
  • HTMX-powered — no JavaScript framework, no build step
  • Server-side rendered via Go templates embedded in the binary
  • Pagination on both views (50 rows per page)

Requirements

Tool Version
Go 1.22+
PostgreSQL 14+

Quick start

1. Create the database

createuser -P tmcapi          # choose a password
createdb -O tmcapi tmcapi

Or with psql as a superuser:

CREATE USER tmcapi WITH PASSWORD 'tmcapi';
CREATE DATABASE tmcapi OWNER tmcapi;

2. Build

git clone https://github.com/your-org/tmc-server
cd tmc-server
make build          # produces ./bin/tmc-server

Or directly with go build:

go build -mod=vendor -o tmc-server ./cmd/server

3. Run

TMC_API_KEYS="your-secret-key" \
TMC_ORG_NAME="acme" \
TMC_DATABASE_URL="postgres://tmcapi:tmcapi@localhost/tmcapi?sslmode=disable" \
./tmc-server

Startup output:

━━━ Terramate Cloud API ━━━
  Listen     : :8080
  Database   : postgres://tmcapi:***@localhost/tmcapi?sslmode=disable
  Org        : acme  (6187958c-f4ef-47ac-b575-e7c26b8e86aa)
  User       : admin@example.com
  API keys   : 1 configured

  Dashboard  : http://localhost:8080/ui
  To use with the Terramate CLI:
    export TMC_API_URL=http://localhost:8080
    export TMC_TOKEN=your-secret-key
    export TM_CLOUD_ORGANIZATION=acme

4. Connect the CLI

No fork, no patch, no TLS tricks. The Terramate CLI has built-in env vars for this:

export TMC_API_URL=http://localhost:8080      # overrides cloud.terramate.io
export TMC_TOKEN=your-secret-key             # must match TMC_API_KEYS
export TM_CLOUD_ORGANIZATION=acme           # must match TMC_ORG_NAME

Then use the CLI normally:

terramate cloud info
terramate run --sync-drift-status -- terraform plan
terramate run --sync-drift-status --terraform-plan-file=tfplan -- terraform plan -out=tfplan
terramate run --sync-deployment -- terraform apply

TMC_API_URL is read in ui/tui/cliauth/cli_cloud.go and wired to cloud.WithBaseURL(...), completely overriding the region-based URL. TMC_API_HOST also works if you prefer to supply just the hostname (the CLI prepends https:// automatically).

The CLI sends credentials as HTTP Basic auth — Authorization: Basic base64(token:). The server accepts both Basic and Bearer formats.


Web dashboard

Open http://localhost:8080/ui in a browser after starting the server. No authentication is required for the dashboard — it is read-only.

Stacks view (/ui/stacks)

Shows all synced stacks with their current health.

Summary bar: Total · OK · Drifted · Failed · Resources tracked

Columns:

Column Description
Status Composite health: ok (green) · drifted (amber) · failed (red)
Title Stack display name with path below in monospace
Resources Count of tracked Terraform resources; links to the filtered Resources view
Repository org/repo short form
Updated Time since last sync

Filters: free-text search (name, path, repository) · status select · repository dropdown

Sorting: click any column header to sort ascending; click again to reverse. Sort state is preserved through pagination.

The composite status column is the worst of drift and deployment state — it matches the --status flag in terramate list --status=drifted.

Resources view (/ui/resources)

Shows every Terraform resource extracted from plan files, across all stacks.

Summary bar: Total · OK (no-op) · Drifted (any planned change) · Stacks

Columns:

Column Description
Status ✓ ok (green) for no-op; △ drifted (amber) with the specific action below for anything else
Resources Resource type (e.g. aws_lb) on line one; the actual cloud identifier (ARN / self_link / resource ID) on line two, falling back to the Terraform address if no cloud ID was captured
Account Cloud account ID (AWS account ID from ARN, GCP project, Azure subscription)
Name Resource name segment
Stack Stack display name; links to filtered Stacks view
Provider Short provider name (aws, google, azurerm, …)
Repository org/repo short form
Updated Time since last plan sync

Filters: free-text search · status select (OK / Drifted variants) · provider dropdown · account ID dropdown (values from DB) · stack name/path

Sorting: all columns except Repository are sortable.

Resources only appear when --terraform-plan-file is passed. Without it, changeset_json is empty and no resources are extracted.

Populating the resource view

# Generate and upload a plan file — resources appear in /ui/resources
terramate run \
  --sync-drift-status \
  --terraform-plan-file=tfplan \
  -- terraform plan -out=tfplan

The CLI runs terraform show -json tfplan locally, sanitises the output, and sends it as changeset_json in the PATCH /v2/drifts/:org/:uuid call. The server parses resource_changes from the plan JSON and upserts rows into stack_resources.

Provider-specific account ID extraction:

  • AWS — extracted from the ARN (arn:aws:...:ACCOUNT_ID:...) or owner_id attribute
  • GCP — from the project attribute
  • Azure — from the resource ID (/subscriptions/SUB_ID/...) or subscription_id
  • Azure AD — from tenant_id

Provider-specific cloud ID extraction (shown as line 2 in the Resources column):

  • AWS — the full ARN (arn, or role_arn / policy_arn / topic_arn / instance_profile_arn), falling back to id
  • GCPself_link, falling back to id
  • Azure / Azure ADid (already the full ARM resource path)

If none of these attributes are present in the plan (e.g. the resource doesn't exist yet, or its type isn't covered above), the Terraform address is shown instead.


Configuration

All configuration is via environment variables.

Variable Default Description
TMC_ADDR :8080 HTTP listen address
TMC_DATABASE_URL postgres://tmcapi:tmcapi@localhost/tmcapi?sslmode=disable PostgreSQL DSN
TMC_API_KEYS dev-api-key Comma-separated raw API keys
TMC_ORG_NAME my-org Organisation slug — must match cloud.organization in terramate.tm.hcl
TMC_ORG_DISPLAY_NAME My Org Human-readable org name
TMC_ORG_DOMAIN `` Optional org domain
TMC_USER_EMAIL admin@example.com Seed user email (bound to the first API key)
TMC_USER_NAME Admin Seed user display name

Multiple API keys

TMC_API_KEYS is comma-separated. Each key gets its own row in api_keys. The first key is bound to the seed user.

TMC_API_KEYS="ci-key,developer-key,readonly-key"

Revoking a key

UPDATE api_keys SET revoked_at = NOW() WHERE description = 'ci-key';

Docker

Build and run

docker build -t tmc-server .

docker run -p 8080:8080 \
  -e TMC_DATABASE_URL="postgres://tmcapi:tmcapi@host.docker.internal/tmcapi?sslmode=disable" \
  -e TMC_API_KEYS="your-secret-key" \
  -e TMC_ORG_NAME="acme" \
  tmc-server

Docker Compose

services:
  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: tmcapi
      POSTGRES_PASSWORD: tmcapi
      POSTGRES_DB: tmcapi
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U tmcapi"]
      interval: 5s
      retries: 5

  tmc-server:
    build: .
    ports:
      - "8080:8080"
    environment:
      TMC_DATABASE_URL: "postgres://tmcapi:tmcapi@postgres/tmcapi?sslmode=disable"
      TMC_API_KEYS: "your-secret-key"
      TMC_ORG_NAME: "acme"
    depends_on:
      postgres:
        condition: service_healthy

volumes:
  pgdata:

API

All endpoints except /.well-known/cli.json, /health, and /ui/* require Authorization: Bearer <key> or Authorization: Basic base64(key:).

Identity

Method Path Description
GET /.well-known/cli.json CLI version compatibility check (public)
GET /health Liveness probe — checks DB connection (public)
GET /v1/users Authenticated user
GET /v1/memberships Org memberships
GET /v1/organizations/name/:name SSO org lookup by slug

Stacks

Method Path Description
PUT /v1/stacks/:org Upsert a stack (idempotent on meta_id + repository + target)
GET /v1/stacks/:org List stacks — filterable by repository, target, meta_id, status, drift_status, deployment_status
GET /v1/stacks/:org/:stackid Single stack by numeric ID
GET /v1/stacks/:org/:stackid/drifts Drift history for a stack
GET /v1/stacks/:org/:stackid/resources Terraform resources for a stack

Deployments

Method Path Description
POST /v1/deployments/:org/:uuid/stacks Create deployment + register stacks
PATCH /v1/deployments/:org/:uuid/stacks Update deployment stack statuses + optional changeset
POST /v1/stacks/:org/:stackid/deployments/:uuid/logs Append command logs
GET /v1/stacks/:org/:stackid/deployments/:uuid/logs Read command logs

Drifts

Method Path Description
POST /v2/drifts/:org Start a drift check run → { uuid }
PATCH /v2/drifts/:org/:uuid Update drift status, changeset, and materialise resources
GET /v2/drifts/:org List drifts — filterable by status, repository, target
GET /v2/drifts/:org/:uuid Single drift with joined stack fields
POST /v2/drifts/:org/:uuid/logs Append CommandLog entries
GET /v2/drifts/:org/:uuid/logs Read command logs
GET /v1/drifts/:org/:stackid/:driftid Legacy v1 drift detail

Previews

Method Path Description
POST /v1/previews/:org Create a preview run
GET /v1/previews/:org/:uuid Get preview with stack previews
PATCH /v1/stack_previews/:org/:id Update stack preview status + changeset
POST /v1/stack_previews/:org/:id/logs Append stack preview logs

Store outputs

Method Path Description
POST /v1/store/:org/outputs Create or update a named output
GET /v1/store/:org/outputs Lookup by key (?name=, ?repository=, ?stack_meta_id=, ?target=)
GET /v1/store/:org/outputs/:id Get by UUID
PUT /v1/store/:org/outputs/:id/value Update value
DELETE /v1/store/:org/outputs/:id Delete

Review requests

Method Path Description
GET /v1/review_requests/:org List review requests

Wire formats

POST /v2/drifts/:org

{
  "stack": {
    "repository":     "github.com/acme/infra",
    "path":           "/stacks/vpc",
    "meta_id":        "vpc-stack",
    "meta_name":      "VPC",
    "default_branch": "main",
    "target":         ""
  },
  "command":    ["terraform", "plan", "-detailed-exitcode"],
  "started_at": "2024-01-01T12:00:00Z"
}

Response 201: { "uuid": "26832be6-..." }

PATCH /v2/drifts/:org/:uuid

{
  "status":     "drifted",
  "changeset": {
    "provisioner":     "terraform",
    "changeset_ascii": "~ aws_instance.web: ami changed",
    "changeset_json":  "{\"resource_changes\": [...]}"
  },
  "updated_at": "2024-01-01T12:05:00Z"
}

Note: the request field is changeset (not drift_details) and the timestamp is updated_at (not finished_at). The response shape uses drift_details — the request and response field names differ.

Valid status values: running · ok · drifted · failed · unknown

Response 204.

Logs (POST body — bare JSON array)

The CLI sends logs as a bare JSON array, not wrapped in an object:

[
  { "log_line": 1, "channel": "stdout", "message": "Refreshing state...", "timestamp": "2024-01-01T12:00:01Z" },
  { "log_line": 2, "channel": "stderr", "message": "Warning: deprecated" }
]

The server also accepts the wrapped form { "logs": [...] } for direct API use.


Database schema

The schema is embedded into the binary and applied automatically on startup. All DDL is idempotent.

api_keys          – SHA-256 hashed API keys with optional revocation
org               – single-row organisation record
users             – users, each bound to an API key
stacks            – Terramate stacks; composite status derived from drift + deployment
deployments       – deployment runs identified by CLI-provided UUID
deployment_stacks – per-stack rows within a deployment
drifts            – drift check runs with JSONB changeset details
logs              – polymorphic log table (entity_kind + entity_id)
previews          – preview runs
stack_previews    – per-stack rows within a preview
stack_resources   – Terraform resources parsed from changeset_json at PATCH time
store_outputs     – key-value store for stack outputs
review_requests   – GitHub/GitLab PR/MR associations (upserted on deployment/preview)

Project layout

tmc-server/
├── cmd/server/
│   └── main.go                    – startup, seeding, graceful shutdown
├── internal/
│   ├── api/
│   │   ├── handlers.go            – one Gin handler per endpoint
│   │   ├── router.go              – route table, auth middleware (Basic + Bearer)
│   │   └── integration_test.go    – httptest end-to-end tests (build tag: integration)
│   ├── config/
│   │   ├── config.go              – env-var config with defaults
│   │   └── config_test.go
│   ├── db/
│   │   ├── db.go                  – pgxpool wrapper, all SQL queries
│   │   ├── schema.sql             – embedded schema (go:embed)
│   │   ├── db_pure_test.go        – unit tests for dependency-free helpers
│   │   └── integration_test.go    – live-Postgres tests (build tag: integration)
│   ├── planparse/
│   │   ├── planparse.go           – tfjson.Plan parser → stack_resources rows
│   │   └── planparse_test.go
│   └── ui/
│       ├── db.go                  – read-only queries for the dashboard
│       ├── handler.go             – Gin handlers + template functions
│       ├── handler_test.go        – pure function + funcMap unit tests
│       ├── templates_test.go      – template rendering tests
│       └── templates/
│           ├── base.html          – nav, CSS design tokens, HTMX/Tailwind CDN
│           ├── stacks.html        – stacks full page
│           ├── stacks_rows.html   – HTMX partial (tbody + OOB thead)
│           ├── resources.html     – resources full page
│           └── resources_rows.html – HTMX partial (tbody + OOB thead)
├── Dockerfile
├── docker-compose.yml
├── Makefile
├── go.mod
└── README.md

Development

Makefile targets

Target Description
make build Compile the server binary into ./bin/tmc-server
make run Build and run with dev defaults (key dev-api-key, org my-org)
make dev-db Create the local tmcapi and tmcapi_test databases (idempotent)
make dev-db-reset Drop and recreate both databases — destroys all data
make test Run unit tests — fast, no database required
make test-integration Run integration tests against tmcapi_test
make test-all Unit tests followed by integration tests
make coverage Generate an HTML coverage report at coverage.html
make vet go vet ./...
make lint go vet + gofmt check
make fmt Format all Go source files in place
make docker-build Build the production Docker image
make docker-run Start Postgres + server via docker-compose
make clean Remove build artifacts
make help List all targets with descriptions

Run locally

make dev-db    # one-time setup
make run
# → http://localhost:8080, key = "dev-api-key", org = "my-org"
# → http://localhost:8080/ui (dashboard)

Testing

Tests are split into two tiers:

Unit tests (make test) run in milliseconds with no external dependencies. They cover:

  • internal/planparse — the tfjson.Plan parser: action collapsing, AWS/GCP/Azure/Azure AD account ID extraction, check-status correlation, deduplication between resource_changes and resource_drift
  • internal/config — environment variable parsing and defaults
  • internal/db — pure helpers: HashKey, buildWhere (SQL WHERE clause construction), RawJSON marshalling, nullInt64
  • internal/ui — template functions (statusClass, actionClass, repoShort, timeAgo, pagination math, sort-direction toggling) and full template rendering via httptest-free html/template execution against the embedded templates

Integration tests (make test-integration, build tag integration) require a live Postgres database and are excluded from go test ./... by default. They cover:

  • internal/db — every exported method against a real database: upsert idempotency, the drift/deployment → stack composite status state machine, per-stack log isolation, resource upsert-in-place semantics
  • internal/api — full HTTP round trips through the real Gin router via httptest.NewServer: auth (Basic and Bearer), the complete drift lifecycle (create → patch → get), the target="" / target="default" normalisation that fixed the cloud drift show bug, bare-array log payloads, and review request materialisation

Run everything:

make dev-db          # creates tmcapi_test if it doesn't exist
make test-all

Run integration tests against a different database:

TEST_DATABASE_URL="postgres://user:pass@host/scratch_db?sslmode=disable" make test-integration

Integration tests TRUNCATE every table before each test (RESTART IDENTITY CASCADE). Never point TMC_TEST_DATABASE_URL at a database containing data you care about.

Useful queries

-- All API keys
SELECT id, description, created_at, revoked_at FROM api_keys;

-- Stack health overview
SELECT meta_id, path, status, drift_status, deployment_status, updated_at FROM stacks ORDER BY updated_at DESC;

-- Recent drifts with changeset
SELECT d.uuid, d.status, s.path, d.changeset_details->>'changeset_ascii' AS diff, d.updated_at
FROM drifts d JOIN stacks s ON s.id = d.stack_id
ORDER BY d.updated_at DESC LIMIT 20;

-- Resources by action (drifted = anything except no-op)
SELECT address, resource_type, action, account_id, s.path
FROM stack_resources r JOIN stacks s ON s.id = r.stack_id
WHERE action != 'no-op'
ORDER BY r.updated_at DESC;

-- Revoke a key
UPDATE api_keys SET revoked_at = NOW() WHERE description = 'ci-key';

-- Add a key at runtime (SHA-256 of "new-key")
INSERT INTO api_keys (key_hash, description)
VALUES (encode(sha256('new-key'::bytea), 'hex'), 'new-key');

License

MIT — see LICENSE for the full text.

About

Local Terramate Cloud implementation

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages