GitOps-driven Managed Infrastructure Framework
Project description
infranaut
GitOps-driven Managed Infrastructure Framework — a Python CLI that scaffolds and operates multi-client cloud infrastructure using OpenTofu/Terragrunt + Ansible.
Table of contents
- Overview
- Design philosophy
- Architecture
- Supported workload types
- Installation
- Quick start
- Commands reference
- infranaut.yml — project registry
- Configuration hierarchy in depth
- Output bridge internals
- Ansible inventory generation
- Core providers
- CI/CD pipeline integration
- Development workflow
- Release workflow
- Smoke test procedure
- Project structure
Overview
infranaut generates and manages the directory structure, Terragrunt HCL files, and Ansible inventories for any number of projects deployed on Hetzner Cloud. Each project represents one client or product; each project has one or more environments (dev, staging, prod); each environment has one or more workloads (a GitLab server, a Kubernetes cluster, a database, …).
The CLI is the equivalent of Django or Laravel for infrastructure engineers: it hides the directory conventions and boilerplate so that engineers only need to fill in workload-specific values and run a single command to go from zero to a fully provisioned and configured server.
Design philosophy
Separation of concerns
infranaut is built around three strictly separated layers:
| Layer | Tool | What it controls |
|---|---|---|
| Infrastructure provisioning | OpenTofu + Terragrunt | Cloud resources (servers, networks, firewalls, DNS, volumes) |
| Server configuration | Ansible | OS hardening, package installation, application deployment |
| Orchestration | infranaut CLI | Scaffolding, pipeline execution, state synchronization |
Framework vs. library
infranaut is a framework (not a library): it dictates the project structure and execution order so that engineers do not need to invent conventions on a per-project basis. The only customizable parts are:
- The workload-specific values in
inputs.hclandgroup_vars/ - The module implementations in
core/(shared across all projects)
DRY infrastructure
The core/ directory contains shared OpenTofu modules and Ansible roles that are referenced by all projects. Nothing is duplicated per project. When a role or module is improved it benefits every project that uses it.
Idempotent pipeline
Every command in the pipeline is idempotent:
terragrunt applyonly creates or modifies what has diverged from the desired stateansible-playbookroles are written to be re-entrant (check before act)infranaut deploycan be run multiple times without side effects
Architecture
Workspace layout
workspace/
├── .env # GIT_USER, GIT_TOKEN for private core repos
├── .infranaut # Workspace marker (created by infranaut init)
├── core/ # Shared modules and roles — never duplicated per project
│ ├── opentofu/
│ │ ├── cloud/
│ │ │ └── hetzner/
│ │ │ ├── based/ # Primitive modules: server, network, firewall, dns-record, …
│ │ │ └── component/ # Composed workloads: gitlab, kubernetes_cluster, single-node, multi-node
│ │ └── on-prem/
│ └── ansible/
│ ├── roles/ # Reusable roles: harden_server, docker, kubernetes/*, gitlab, …
│ └── requirements.yml
└── projects/
└── <client>/
├── infranaut.yml # Project registry — single source of truth
├── opentofu/
│ ├── _root.hcl # S3 backend + provider blocks (shared across all environments)
│ ├── _env.hcl # Client-level constants (location, image, server_sizes, firewall rules)
│ ├── .envrc # direnv auto-export for HCLOUD_TOKEN, etc.
│ ├── .env.example # Template for required environment variables
│ ├── shared/
│ │ └── ssh-keys/ # SSH public keys — provisioned once, used by all workloads
│ └── <env>/
│ ├── env.hcl # Environment-level overrides (CIDRs, server sizes, feature flags)
│ ├── .envrc
│ ├── network/ # Shared private network for this environment
│ └── <workload>/
│ ├── terragrunt.hcl # Module source, dependency wiring, cascade locals
│ └── inputs.hcl # Workload-specific values (volumes, DNS, firewall extras)
└── ansible/
├── ansible.cfg # roles_path → ../../../core/ansible/roles
├── inventories/
│ └── <env>/
│ └── <workload>/
│ ├── default.yml # Static host skeleton (connection params, ports)
│ ├── dynamic.yml # Generated at deploy time from Terraform outputs
│ └── group_vars/
│ └── all/
│ ├── main.yml # Role configuration variables
│ └── vault.yml # Encrypted secrets (ansible-vault)
└── playbooks/
└── <env>/
└── <workload>/
└── site.yml # Ordered role execution playbook
Variable resolution cascade
One of the core design patterns of infranaut is the three-level variable cascade. This pattern avoids duplicating configuration across environments while still allowing each layer to override the one above it:
_env.hcl (client level)
↓ overridden by
env.hcl (environment level)
↓ overridden by
inputs.hcl (workload level)
In Terragrunt HCL, each workload resolves location, image, and network_zone with explicit try() calls:
locals {
location = try(local.inp.location, try(local.env.location, include.env_common.locals.location))
image = try(local.inp.image, try(local.env.image, include.env_common.locals.image))
network_zone = try(local.inp.network_zone, try(local.env.network_zone, include.env_common.locals.network_zone))
}
What each layer controls:
| Level | File | Typical contents |
|---|---|---|
| Client | _env.hcl |
Project name prefix, default datacenter location, default OS image, canonical server-size catalogue (server_sizes.nano/small/medium/large), base firewall rules (SSH + HTTP + HTTPS) |
| Environment | env.hcl |
CIDR allocations, per-workload server types (gitlab_server_type, mattermost_server_type), feature flags (enable_backups, enable_runners_cluster), environment labels |
| Workload | inputs.hcl |
Volumes, DNS configuration, extra firewall rules, Ansible metadata, Kubernetes topology |
Output bridge — Terraform → Ansible
After terragrunt apply, infranaut needs to pass the provisioned infrastructure details (IP addresses, cluster endpoint, etc.) to Ansible. This is done through the output bridge (output_bridge.py):
terragrunt output -json
│
▼
_flatten_inventory_output() ← unpacks nested 'inventory' output
│
▼
resolve_outputs() ← maps TF output names to Ansible variable names
│ using output_map.yml (alias resolution + type coercion)
▼
build_ansible_context() ← injects project/env/workload metadata
│
▼
render inventory_dynamic.yml.j2
The bridge handles two distinct Terraform output structures:
component.servers— used bygitlab,single_node,multi_node: a flat map ofserver_name → {ansible_host, private_ip, fqdn}. The bridge flattens these into ahostslist for the inventory template.component.k8s_cluster— used bykubernetes_cluster: a nested map withmastersandworkerssub-maps. The bridge unpacks these into separatecontrolplanesandworkerslists.
Template system
Every workload type ships a directory of Jinja2 templates:
infranaut/templates/workloads/<type>/
├── terragrunt.hcl.j2 # Terragrunt config (module source, dependencies, inputs)
├── inputs.hcl.j2 # Workload-specific values (to fill in before applying)
├── inventory.yml.j2 # Static Ansible inventory skeleton
├── inventory_dynamic.yml.j2 # Dynamic inventory rendered from Terraform outputs
├── group_vars_main.yml.j2 # Role configuration variables skeleton
├── site.yml.j2 # Ansible playbook with ordered role execution
└── output_map.yml # Declarative Terraform→Ansible variable mapping
Templates use Jinja2 StrictUndefined: any reference to an undefined variable raises an error immediately rather than silently producing an empty string. This ensures that every template variable is accounted for before generating files.
Deployment pipeline
infranaut deploy -p <project> -e <env> -w <workload>
Step 1: Terraform
terragrunt apply
└── creates/updates cloud resources in Hetzner
Step 2: Output bridge
terragrunt output -json
└── _flatten_inventory_output() → unpack nested 'inventory'
└── resolve_outputs() → normalize via output_map.yml
└── render inventory_dynamic.yml.j2
Step 3: Ansible
ansible-playbook site.yml -i dynamic.yml
└── runs from projects/<client>/ansible/ (so ansible.cfg is found)
Each step can be controlled independently:
--skip-infra → jump directly to step 2 (infra already applied)
--skip-config → stop after step 2 (provision only, no Ansible)
--dry-run → terragrunt plan + inventory preview, no changes
--run-deps → auto-apply dependency workloads (network, ssh-keys) before step 1
Supported workload types
| Type | OpenTofu module | Ansible groups | Description |
|---|---|---|---|
gitlab |
hetzner/component/gitlab |
gitlab_servers |
GitLab CE/EE server with optional runners cluster |
kubernetes_cluster |
hetzner/component/kubernetes_cluster |
controlplanes, workers |
Multi-node k8s cluster with kubeadm, Calico CNI, Rook-Ceph, ArgoCD |
single_node |
hetzner/component/single-node |
<name>_servers |
Generic standalone server (Mattermost, Nextcloud, databases, …) |
multi_node |
hetzner/component/multi-node |
<name>_primary, <name>_secondary |
Primary/secondary cluster (database, message broker, …) |
network |
hetzner/based/network |
— | Shared private network (created automatically per environment) |
ssh_keys |
hetzner/based/ssh-keys |
— | SSH public keys uploaded once, referenced by all workloads |
Workload types are discovered dynamically at runtime:
- First from
core/opentofu/cloud/hetzner/component/(user-managed modules) - Fallback: bundled templates shipped with the infranaut package
This means custom workload types can be added to core/ without patching infranaut itself.
Installation
From PyPI (production)
pip install infranaut
From source (development)
git clone https://gitlab.kopsengineering.com/ksoft/infrastructure/infranaut_ctl.git
cd infranaut_ctl
pip install -e ".[dev]"
Requirements:
- Python 3.11+
terragruntin$PATH(fordeployandinventory generate)ansible-playbookin$PATH(fordeploy)
Quick start
# 1. Create a workspace
infranaut init /srv/infra
# Optional: pull a shared core (modules + roles) from a git repository
infranaut init /srv/infra --core-provider https://gitlab.example.com/org/infra-core.git
# 2. Create a project (configures S3 state backend by default)
infranaut project create --name acme
# 3. Add environments (each also creates a shared private network workload)
infranaut env create --project acme --name dev
infranaut env create --project acme --name staging
# 4. Add workloads to an environment
infranaut workload create --project acme --env dev --type gitlab
infranaut workload create --project acme --env dev --type kubernetes_cluster --name k8s
infranaut workload create --project acme --env dev --type single_node --name mattermost
# 5. Fill in workload-specific values, then export provider tokens and deploy
export HCLOUD_TOKEN=...
export CLOUDFLARE_API_TOKEN=...
export AWS_ACCESS_KEY_ID=... # S3 backend credentials
export AWS_SECRET_ACCESS_KEY=...
infranaut deploy --project acme --env dev --workload gitlab
# 6. Regenerate the dynamic Ansible inventory after a standalone apply
infranaut inventory generate --project acme --env dev --workload gitlab
Commands reference
Workspace
infranaut init [PATH] [--core-provider PROVIDER] [--force] [--list-providers]
infranaut version
| Option | Description |
|---|---|
PATH |
Workspace root directory (default: current directory) |
--core-provider, -c |
empty, local path, or git URL (default: empty) |
--force, -f |
Reinitialize even if the workspace already exists |
--list-providers |
Print available core provider aliases and exit |
init creates the .infranaut workspace marker, the core/ skeleton, and the workspace-level .env and .env.example files.
Project
infranaut project create --name NAME [--state-backend s3|local]
[--s3-bucket BUCKET] [--s3-region REGION] [--s3-endpoint URL]
infranaut project list
infranaut project delete --name NAME [--force]
| Option | Description |
|---|---|
--state-backend |
s3 (default) or local |
--s3-bucket |
S3 bucket name (auto-generated as infranaut-tfstate-<name> if omitted) |
--s3-region |
S3-compatible region (default: nbg1 for Hetzner Object Storage) |
--s3-endpoint |
S3 endpoint URL (default: Hetzner Object Storage nbg1) |
--force, -f |
Delete without confirmation prompt |
Created files: _root.hcl, _env.hcl, .envrc, .env.example, shared/env.hcl, shared/ssh-keys/, ansible/ansible.cfg, infranaut.yml.
Environment
infranaut env create --project PROJECT --name ENV [--no-network] [--from-env SOURCE]
infranaut env list --project PROJECT
infranaut env delete --project PROJECT --name ENV [--force]
| Option | Description |
|---|---|
--no-network |
Skip automatic creation of the network workload |
--from-env SOURCE |
Copy all files from an existing environment instead of rendering from templates. Terraform cache directories (.terraform/, .terragrunt-cache/) and generated inventories (dynamic.yml) are never copied. Review env.hcl after creation — it still contains the source environment's values (CIDRs, server types). |
--force, -f |
Delete without confirmation prompt |
env create always creates opentofu/<env>/env.hcl, .envrc, .env.example, ansible/inventories/<env>/, and ansible/playbooks/<env>/. Unless --no-network, it also creates the network workload (Hetzner private network required by all other workloads as a dependency).
Warning:
env deleteremoves files only. Destroy provisioned infrastructure withterragrunt destroybefore deleting an environment.
Workload
infranaut workload create --project P --env E --type TYPE [--name NAME] [--roles ROLES]
[--from-workload WORKLOAD] [--from-env ENV]
infranaut workload list [--project P] [--env E] [--types]
infranaut workload delete --project P --env E --name NAME [--force]
| Option | Description |
|---|---|
--type, -t |
Workload type (gitlab, kubernetes_cluster, single_node, multi_node). Required unless --from-workload is used. |
--name, -n |
Custom workload directory name (defaults to the type name; required with --from-workload). |
--roles, -r |
Comma-separated Ansible roles to inject into site.yml, e.g. harden_server,docker_compose_deploy,restic. Overrides the type defaults. |
--from-workload W |
Copy files from an existing workload instead of rendering from templates. Workload type is auto-detected from infranaut.yml. |
--from-env E |
Source environment for --from-workload (defaults to --env if omitted). |
--types |
List available workload types instead of project workloads. |
--force, -f |
Delete without confirmation prompt. |
Template mode (--type): renders and writes:
opentofu/<env>/<workload>/terragrunt.hclopentofu/<env>/<workload>/inputs.hclansible/inventories/<env>/<workload>/default.ymlansible/inventories/<env>/<workload>/group_vars/all/main.ymlansible/inventories/<env>/<workload>/group_vars/all/vault.ymlansible/playbooks/<env>/<workload>/site.yml
Type-specific extras created automatically:
kubernetes_cluster→group_vars/controlplanes/main.yml+group_vars/workers/main.ymlmulti_node→group_vars/<name>_primary/main.yml+group_vars/<name>_secondary/main.yml
Copy mode (--from-workload): copies terragrunt.hcl, inputs.hcl, .env, .envrc, the full Ansible inventory tree, and site.yml. Terraform cache directories and generated dynamic.yml files are never copied.
Warning:
workload deleteremoves files only. Destroy provisioned infrastructure withterragrunt destroybefore deleting.
Examples:
infranaut workload create -p acme -e dev -t gitlab
infranaut workload create -p acme -e dev -t kubernetes_cluster --name k8s
infranaut workload create -p acme -e dev -t single_node -n mattermost --roles harden_server,docker_compose_deploy,restic
infranaut workload create -p acme -e prod -n gitlab --from-workload gitlab --from-env staging
infranaut workload list --types
Inventory
infranaut inventory generate --project P --env E --workload W [--output PATH] [--show]
| Option | Description |
|---|---|
--output, -o |
Output file path (default: ansible/inventories/<env>/<workload>/dynamic.yml) |
--show |
Print the generated inventory without writing to disk |
Runs terragrunt output -json in the workload directory, resolves values through output_map.yml, and renders inventory_dynamic.yml.j2 into the Ansible dynamic inventory file.
This command is a standalone version of step 2 of infranaut deploy. Use it to regenerate the inventory without running Terraform or Ansible.
Examples:
infranaut inventory generate -p acme -e dev -w gitlab
infranaut inventory generate -p acme -e dev -w gitlab --show
infranaut inventory generate -p acme -e dev -w k8s --output /tmp/k8s-inventory.yml
Deploy
infranaut deploy --project P --env E --workload W
[--skip-infra] [--skip-config] [--dry-run] [--auto-approve]
[--run-deps] [--tags TAGS] [--extra-vars VARS] [--vault-pass PASS]
Full deployment pipeline: Terraform apply → Output bridge → Ansible playbook.
| Option | Description |
|---|---|
--skip-infra |
Skip Terraform apply — infra already provisioned |
--skip-config |
Skip Ansible — provision infra only |
--dry-run |
terragrunt plan + inventory preview — no changes applied |
--auto-approve |
terragrunt apply -auto-approve (no interactive prompt) |
--run-deps |
Auto-apply dependency workloads (network, ssh-keys) before the main apply |
--tags |
Ansible tags, e.g. --tags harden,install |
--extra-vars |
Ansible extra vars passed to ansible-playbook --extra-vars |
--vault-pass |
Ansible vault password. Written to a chmod 600 temp file and deleted after the run — never appears in logs or ps output. Also readable from the VAULT_PASS environment variable. |
Requires terragrunt and ansible-playbook in $PATH. Both are checked before starting — a clear install hint is printed if either is missing.
Examples:
infranaut deploy -p acme -e dev -w gitlab
infranaut deploy -p acme -e prod -w gitlab --auto-approve
infranaut deploy -p acme -e dev -w gitlab --skip-infra --tags harden
infranaut deploy -p acme -e dev -w gitlab --dry-run
infranaut deploy -p acme -e dev -w k8s --skip-config
infranaut deploy -p acme -e dev -w k8s --run-deps --auto-approve
# CI/CD usage — vault password from environment variable
VAULT_PASS="$SECRET" infranaut deploy -p acme -e prod -w gitlab --auto-approve
infranaut.yml — project registry
projects/<name>/infranaut.yml is the single source of truth for a project's metadata. It is created by project create and kept in sync automatically by every CLI command that adds or removes environments and workloads.
Schema
project: acme # project name (matches directory name)
state_backend: s3 # s3 | local
s3_bucket: infranaut-tfstate-acme # Terraform remote state bucket
s3_region: nbg1 # S3-compatible region
s3_endpoint: https://nbg1.your-objectstorage.com # S3-compatible endpoint
environments: # ordered list of declared environment names
- dev
- staging
- prod
workloads: # one entry per workload, per environment
- env: dev
name: network # directory name under opentofu/<env>/
type: network # workload type — links the directory to its templates and output_map
- env: dev
name: gitlab
type: gitlab
- env: dev
name: k8s
type: kubernetes_cluster
- env: staging
name: gitlab
type: gitlab
Automatic updates
| CLI command | Field(s) modified |
|---|---|
project create |
Creates the file — all fields set, environments: [], workloads: [] |
env create |
Appends env name to environments[] |
env create --from-env <src> |
Appends to environments[] and propagates every workload of <src> into workloads[] with the new env name |
env delete |
Removes env name from environments[] and purges all matching entries from workloads[] |
workload create (template or copy mode) |
Appends {env, type, name} to workloads[] |
workload delete |
Removes the matching {env, name} entry from workloads[] |
What reads it
| CLI command | Field read | Consequence if corrupted |
|---|---|---|
deploy |
workloads[].type |
Cannot resolve the correct output_map.yml — fatal error |
inventory generate |
workloads[].type |
Picks wrong output_map → wrong Ansible variables injected |
workload create --from-workload |
workloads[].type |
Auto-detection copies from wrong template directory |
workload list |
workloads[], environments[] |
Displays stale or phantom entries |
env create (new environments) |
s3_bucket, s3_region, s3_endpoint |
New env.hcl files get wrong backend config |
The
typefield is the only link between a workload directory on disk and its Terraform module and Ansible templates. Do not editinfranaut.ymlby hand.
Configuration hierarchy in depth
_env.hcl — client-level constants
Generated once per project, used as a read-only constant source by every workload in every environment. Included with merge_strategy = "no_merge" in all terragrunt.hcl files.
locals {
project_name = "acme"
location = "nbg1" # Hetzner datacenter
image = "ubuntu-22.04" # Default OS image
network_zone = "eu-central"
# Canonical server sizes — reference with include.env_common.locals.server_sizes.medium
server_sizes = {
nano = "cx22" # 2 vCPU, 4 GB RAM
small = "cx32" # 4 vCPU, 8 GB RAM
medium = "cx42" # 8 vCPU, 16 GB RAM
large = "cx52" # 16 vCPU, 32 GB RAM
}
# Base firewall rules applied to all standalone servers
base_firewall_rules = [
{ direction = "in", port = "22", protocol = "tcp", source_ips = ["0.0.0.0/0", "::/0"], description = "SSH (bootstrap)" },
{ direction = "in", port = "2222", protocol = "tcp", source_ips = ["0.0.0.0/0", "::/0"], description = "SSH (hardened)" },
{ direction = "in", port = "80", protocol = "tcp", source_ips = ["0.0.0.0/0", "::/0"], description = "HTTP" },
{ direction = "in", port = "443", protocol = "tcp", source_ips = ["0.0.0.0/0", "::/0"], description = "HTTPS" },
]
}
env.hcl — environment-level overrides
One file per environment. Controls environment-specific sizing, CIDR allocations, and feature toggles.
locals {
environment = "dev"
# CIDR allocation convention (avoid overlaps across environments):
# dev → 10.0.0.0/16 subnet 10.0.1.0/24
# staging → 10.1.0.0/16 subnet 10.1.1.0/24
# prod → 10.2.0.0/16 subnet 10.2.1.0/24
network_cidr = "10.0.0.0/16"
subnet_cidr = "10.0.1.0/24"
# Per-workload server sizes (read by terragrunt.hcl via try())
gitlab_server_type = "cx42" # 8 vCPU, 16 GB
mattermost_server_type = "cx22" # 2 vCPU, 4 GB
# Feature flags — set false in dev to reduce cost
enable_backups = false
enable_runners_cluster = false
env_labels = {
environment = "dev"
cost_center = "engineering"
}
}
inputs.hcl — workload-level values
One file per workload. Provides values that are specific to this instance of the workload type and cannot be derived from the environment.
For single_node:
server_type,location,image— optional overrides of the cascade defaultspublic_ipv4_enabled,public_ipv6_enabled,enable_firewallvolumes— attached persistent volumes (size, format, automount)dns_config— Cloudflare or Hetzner DNS records (record_namesis a list)extra_firewall_rules— appended to the base rules from_env.hclansible_groups,ansible_vars— passed through as Terraform outputs to the inventory bridge
For kubernetes_cluster:
masters/workers— per-node maps withserver_type,location,image,backupsnetwork_ip_range/subnet_ip_range— cluster-internal network (distinct from the environment network)enable_api_lb,enable_ingress_lb— Hetzner load balancerskubernetes_version,pod_subnet,service_subnetdns_config.records— populated after first apply once LB IPs are knownansible_vars— passed to the inventory bridge for group_vars rendering
terragrunt.hcl — cascade resolution
Each workload's terragrunt.hcl is the glue between the three config levels. It declares the module source, wires the dependencies (network, ssh_keys), and resolves the cascade:
locals {
env_config = read_terragrunt_config(find_in_parent_folders("env.hcl"))
env = local.env_config.locals
inputs_config = read_terragrunt_config("${get_terragrunt_dir()}/inputs.hcl")
inp = local.inputs_config.locals
# ── Cascade: workload > env > client ────────────────────────────────────────
location = try(local.inp.location, try(local.env.location, include.env_common.locals.location))
image = try(local.inp.image, try(local.env.image, include.env_common.locals.image))
network_zone = try(local.inp.network_zone, try(local.env.network_zone, include.env_common.locals.network_zone))
}
This pattern means:
- A workload pinned to a specific datacenter defines
locationin itsinputs.hcl— it takes precedence. - If not pinned, the environment's value from
env.hclis used. - If the environment has no override either, the project default from
_env.hclapplies.
Output bridge internals
output_map.yml schema
Each workload type ships an output_map.yml that declares how to map raw Terraform output names to Ansible variable names:
mappings:
# Required variable — raises ValueError if not found
ansible_host:
aliases: [ansible_host, server_ip, public_ipv4, ipv4_address]
type: string # string | list
# Optional variable — falls back to default if not found
kubernetes_version:
aliases: [kubernetes_version]
optional: true
default: "1.35"
# Optional list variable
ssh_key_ids:
aliases: [ssh_key_ids, ssh_keys]
type: list
optional: true
default: []
static:
# Injected verbatim — no Terraform output lookup
ansible_user: deploy
container_runtime: containerd
Alias resolution: the bridge tries each alias in order and uses the first match. This decouples the Ansible variable names from the exact output names in each core module, which may vary between module versions.
Type coercion: when type: list is declared, the bridge ensures the value is a Python list even if Terraform emits a single string.
Nested inventory output handling
The infranaut-standard Terraform module outputs a single nested inventory object:
{
"inventory": {
"component": {
"servers": {
"gitlab-dev": {
"ansible_host": "1.2.3.4",
"private_ip": "10.0.1.5",
"fqdn": "gitlab-dev.acme.com"
}
},
"vars": {
"gitlab_version": "17.9"
}
}
}
}
For kubernetes_cluster, the structure uses k8s_cluster instead of servers:
{
"inventory": {
"component": {
"k8s_cluster": {
"masters": {
"k8s-master-1-acme-dev": { "ansible_host": "", "private_ip": "10.10.1.10" }
},
"workers": {
"k8s-worker-1-acme-dev": { "ansible_host": "", "private_ip": "10.10.1.20" },
"k8s-worker-2-acme-dev": { "ansible_host": "", "private_ip": "10.10.1.21" }
}
}
}
}
}
_flatten_inventory_output() in output_bridge.py handles both shapes:
component.servers→ buildshostslist + flattens first server's IP for single-host templatescomponent.k8s_cluster→ builds separatecontrolplanesandworkerslists
The controlplanes and workers lists are injected directly into the template context (bypassing output_map.yml) because they are structured objects (list of dicts), which the YAML-based mapping format cannot express.
Ansible inventory generation
Static inventory (default.yml)
Generated at workload creation time from the inventory.yml.j2 template. Contains connection parameters that are known before any infrastructure is provisioned:
# projects/acme/ansible/inventories/dev/gitlab/default.yml
all:
children:
gitlab_servers:
hosts:
gitlab-dev-acme:
ansible_host: "" # filled by dynamic.yml at deploy time
ansible_port: 2222 # hardened SSH port (post harden_server)
default_ssh_port: 22 # bootstrap port
ssh_port: 2222
ansible_user: deploy
Dynamic inventory (dynamic.yml)
Generated by infranaut deploy (step 2) or infranaut inventory generate. Contains the actual IP addresses and configuration variables from Terraform outputs:
# auto-generated — do not edit
all:
children:
gitlab_servers:
hosts:
gitlab-dev-acme:
ansible_host: "95.217.1.2"
private_ip: "10.0.1.5"
fqdn: "gitlab-dev.acme.com"
ansible_user: deploy
gitlab_version: "17.9"
gitlab_external_url: "https://gitlab-dev.acme.com"
For kubernetes_cluster, the dynamic inventory generates separate host groups:
all:
children:
controlplanes:
hosts:
k8s-master-1-acme-dev:
ansible_host: ""
private_ip: "10.10.1.10"
kubernetes_version: "1.35.4"
workers:
hosts:
k8s-worker-1-acme-dev:
ansible_host: ""
private_ip: "10.10.1.20"
Group variables structure
inventories/<env>/<workload>/
├── default.yml # Static skeleton (merged with dynamic.yml by Ansible)
├── dynamic.yml # Generated from Terraform outputs
└── group_vars/
├── all/
│ ├── main.yml # Role configuration (kubernetes_version, pod_network_addon, …)
│ └── vault.yml # Encrypted secrets (vault_user_password_hash, restic_password, …)
├── controlplanes/ # kubernetes_cluster only
│ └── main.yml
└── workers/ # kubernetes_cluster only
└── main.yml
Ansible merges default.yml and dynamic.yml at playbook execution time — both files are passed to the -i flag as a directory.
Core providers
--core-provider controls what goes into core/ at infranaut init time.
| Provider | Effect |
|---|---|
empty (default) |
Creates an empty core/ skeleton — add your own modules and roles. |
/local/path |
Copies from a local directory (existing files are preserved). |
https://…/repo.git |
Clones the repo with --depth 1 into core/. |
git@host:org/repo.git |
Same, SSH-based URL. |
Git credentials for HTTPS clones are read from the workspace .env file and injected automatically — they are never printed to the console.
.env format for git provider
# workspace/.env
GIT_USER=my-ci-user
GIT_TOKEN=glpat-xxxxxxxxxxxxxxxxxxxx
CI/CD pipeline integration
infranaut is designed to be the single entry point in a GitLab CI/CD pipeline — every infrastructure operation goes through the CLI, never by calling terragrunt or ansible-playbook directly.
Why not call the tools directly?
The key problem with calling the tools directly in CI is a hidden gap between the apply and the Ansible run:
# ✗ Problematic pattern — two separate CI jobs
Job 1: terragrunt apply # servers created, IP addresses assigned
Job 2: ansible-playbook -i inventories/dev/gitlab/ # uses STALE dynamic.yml from the repo
If a server was recreated between runs, dynamic.yml still contains the old IP. Ansible runs against a ghost address and fails silently or connects to the wrong machine.
infranaut deploy makes the inventory regeneration a mandatory step 2, atomically chained between the apply and Ansible:
infranaut deploy --auto-approve --vault-pass "$VAULT_PASS"
│
├─ Step 1: terragrunt apply
├─ Step 2: terragrunt output -json → dynamic.yml ← always fresh
└─ Step 3: ansible-playbook -i dynamic.yml
Recommended .gitlab-ci.yml structure
stages:
- validate # lint, fmt, security — no cloud calls, no infranaut
- plan # infranaut plan (dry-run, no changes)
- apply # infranaut deploy (MANUAL)
- destroy # infranaut destroy (MANUAL, requires DESTROY=true)
variables:
CLIENT: ksoftlab # overridden via "Run pipeline > Variables"
ENV: dev
WORKLOAD: "" # empty = all workloads in the environment
TOOL: both # both | opentofu | ansible
# plan stage
plan:
script:
- |
for wl in $(list_workloads); do
infranaut plan --project "$CLIENT" --env "$ENV" --workload "$wl"
done
# apply stage (MANUAL)
apply:
script:
- |
SKIP_FLAGS=""
[ "$TOOL" = "opentofu" ] && SKIP_FLAGS="--skip-config"
[ "$TOOL" = "ansible" ] && SKIP_FLAGS="--skip-infra"
for wl in $(list_workloads); do
infranaut deploy \
--project "$CLIENT" --env "$ENV" --workload "$wl" \
--auto-approve --vault-pass "$VAULT_PASS" \
$SKIP_FLAGS
done
# destroy stage (MANUAL, explicit unlock required)
destroy:
script:
- |
[ "${DESTROY:-false}" = "true" ] || { echo "Set DESTROY=true to unlock"; exit 1; }
[ -n "$WORKLOAD" ] || { echo "WORKLOAD must be set explicitly"; exit 1; }
infranaut destroy \
--project "$CLIENT" --env "$ENV" --workload "$WORKLOAD" \
--auto-approve --force
TOOL variable
The TOOL variable maps to deploy flags, allowing partial re-runs without duplicating jobs:
TOOL |
Flag added | Use case |
|---|---|---|
both (default) |
— | Full pipeline: infra + inventory + Ansible |
opentofu |
--skip-config |
Apply infra changes only (no Ansible run) |
ansible |
--skip-infra |
Re-run Ansible only — inventory is still regenerated from current TF state |
Required CI/CD variables
Set these in GitLab → Settings → CI/CD → Variables. Scope per client with client/* to have GitLab inject the right token automatically.
| Variable | Scope | Description |
|---|---|---|
HCLOUD_TOKEN |
client/* |
Hetzner Cloud API token |
CLOUDFLARE_API_TOKEN |
client/* |
Cloudflare API token |
AWS_ACCESS_KEY_ID |
client/* |
S3 state backend key |
AWS_SECRET_ACCESS_KEY |
client/* |
S3 state backend secret |
VAULT_PASS |
client/* |
Ansible vault password (masked) |
HETZNER_S3_KEY |
global | Shared state backend key |
HETZNER_S3_SECRET |
global | Shared state backend secret |
Runner image
The CI runner must have terragrunt, tofu, ansible-playbook, and infranaut installed. The jonabacho/iac-toolkit image provides all four.
Development workflow
Prerequisites
pip install -e ".[dev]" # installs typer, jinja2, pyyaml, rich, ruff, pytest
Run tests
pytest tests/ -v
Lint and format
ruff check infranaut/
ruff format infranaut/
Smoke test (full end-to-end, no cloud access)
mkdir -p /tmp/smoke && cd /tmp/smoke
infranaut init .
infranaut project create --name acme
infranaut env create --project acme --name dev
infranaut workload create --project acme --env dev --type gitlab
infranaut workload create --project acme --env dev --type kubernetes_cluster --name k8s
infranaut workload create --project acme --env dev --type single_node --name mattermost
infranaut workload create --project acme --env dev --type multi_node --name app
infranaut workload list --project acme --env dev
infranaut workload list --types
Release workflow
infranaut uses semantic versioning. Releases are triggered by pushing a vX.Y.Z tag; the GitLab CI pipeline builds and publishes to PyPI automatically.
Steps
1. Bump the version in two files:
# infranaut/__init__.py
__version__ = "0.5.0"
# pyproject.toml
version = "0.5.0"
2. Commit and tag:
git add infranaut/__init__.py pyproject.toml
git commit -m "chore: bump version to 0.5.0"
git tag v0.5.0
git push origin dev
git push origin v0.5.0
CI stages:
| Stage | Job | Description |
|---|---|---|
| lint | ruff | Style check (non-blocking) |
| test | pytest | Unit tests |
| smoke | CLI end-to-end | Full scaffold + verify without cloud access |
| publish | twine upload | Build sdist + wheel, publish to PyPI via PYPI_TOKEN |
Required CI/CD variable
In GitLab → Settings → CI/CD → Variables:
| Variable | Type | Description |
|---|---|---|
PYPI_TOKEN |
Masked | PyPI API token with publish scope |
Smoke test procedure
Run this after each release to validate the published package on a clean machine.
# 1. Install from PyPI
pip install infranaut==0.5.0
# 2. Verify the CLI
infranaut version
infranaut --help
# 3. Initialize a workspace
mkdir -p /tmp/infra-test && cd /tmp/infra-test
infranaut init .
# 4. Create project and environments
infranaut project create --name acme
infranaut env create --project acme --name dev
infranaut env create --project acme --name prod
# 5. Create all workload types
infranaut workload create --project acme --env dev --type gitlab
infranaut workload create --project acme --env dev --type kubernetes_cluster --name k8s
infranaut workload create --project acme --env dev --type single_node --name mattermost
infranaut workload create --project acme --env dev --type multi_node --name backend
# 6. Test environment copy
infranaut env create --project acme --name staging --from-env dev
# 7. Test workload copy
infranaut workload create --project acme --env prod --name gitlab --from-workload gitlab --from-env dev
# 8. Check key generated files
test -f projects/acme/opentofu/_env.hcl && echo "OK _env.hcl"
test -f projects/acme/opentofu/_root.hcl && echo "OK _root.hcl"
test -f projects/acme/opentofu/dev/env.hcl && echo "OK env.hcl"
test -f projects/acme/opentofu/dev/gitlab/terragrunt.hcl && echo "OK gitlab terragrunt"
test -f projects/acme/opentofu/dev/gitlab/inputs.hcl && echo "OK gitlab inputs"
test -f projects/acme/opentofu/dev/k8s/terragrunt.hcl && echo "OK k8s terragrunt"
test -f projects/acme/opentofu/dev/k8s/inputs.hcl && echo "OK k8s inputs"
test -f projects/acme/opentofu/dev/backend/terragrunt.hcl && echo "OK multi_node terragrunt"
test -f projects/acme/ansible/inventories/dev/gitlab/default.yml && echo "OK gitlab inventory"
test -f projects/acme/ansible/inventories/dev/k8s/default.yml && echo "OK k8s inventory"
test -f projects/acme/ansible/inventories/dev/k8s/group_vars/controlplanes/main.yml && echo "OK k8s controlplanes"
test -f projects/acme/ansible/inventories/dev/k8s/group_vars/workers/main.yml && echo "OK k8s workers"
test -f projects/acme/ansible/playbooks/dev/gitlab/site.yml && echo "OK gitlab site.yml"
test -f projects/acme/ansible/playbooks/dev/k8s/site.yml && echo "OK k8s site.yml"
# 9. Verify cascade pattern is in place
grep -q "try(local.inp.location" projects/acme/opentofu/dev/gitlab/terragrunt.hcl && echo "OK gitlab cascade"
grep -q "try(local.inp.location" projects/acme/opentofu/dev/k8s/terragrunt.hcl && echo "OK k8s cascade"
grep -q "try(local.inp.location" projects/acme/opentofu/dev/mattermost/terragrunt.hcl && echo "OK single_node cascade" 2>/dev/null || true
grep -q "servers = local.inp.servers" projects/acme/opentofu/dev/backend/terragrunt.hcl && echo "OK multi_node servers input"
# 10. Verify record_names (plural list) in dns_config
grep -q "record_names" projects/acme/opentofu/dev/gitlab/inputs.hcl && echo "OK gitlab record_names"
# 11. List CLI output
infranaut project list
infranaut workload list --project acme --env dev
infranaut workload list --types
# 12. Cleanup
rm -rf /tmp/infra-test
What to verify after each step:
- No Python tracebacks
- All
OKlines printed in steps 8–10 - Generated HCL and YAML files have the correct
project_name,env_name,workload_namesubstituted ansible.cfg,inventories/,playbooks/,group_vars/directories present underansible/infranaut.ymlupdated after eachcreatecommand- The three-level cascade pattern (
try(local.inp.X, try(local.env.X, ...))) is present in every workload'sterragrunt.hcl
Project structure
infranaut_ctl/
├── infranaut/
│ ├── __init__.py # Package version
│ ├── main.py # CLI entry point (Typer app, command registration)
│ ├── config.py # Workspace discovery, workload type enumeration, env cascade loader
│ ├── core_provider.py # Core init: empty skeleton / local copy / git clone
│ ├── renderer.py # Jinja2 environment (StrictUndefined), render_and_write
│ ├── output_bridge.py # Terraform output → Ansible variable bridge
│ │ # get_terraform_outputs() runs terragrunt output -json
│ │ # load_output_map() loads output_map.yml
│ │ # resolve_outputs() alias resolution + type coercion
│ │ # _flatten_inventory_output() unpacks nested inventory output
│ │ # build_ansible_context() full pipeline entry point
│ ├── commands/
│ │ ├── init.py # infranaut init
│ │ ├── project.py # infranaut project create / list / delete
│ │ ├── env_workload.py # infranaut env / workload create / list / delete
│ │ │ # + _create_env_from_source()
│ │ │ # + _create_workload_from_source()
│ │ │ # + _copy_tree_no_cache()
│ │ ├── inventory_cmd.py # infranaut inventory generate
│ │ ├── deploy.py # infranaut deploy (3-step pipeline)
│ │ │ # + --vault-pass: written to chmod 600 tmpfile, deleted in finally
│ │ │ # + _resolve_deps() (parses dependency config_paths)
│ │ │ # + _handle_deps() (warns or applies with --run-deps)
│ │ ├── destroy.py # infranaut destroy (terragrunt destroy + optional file/config cleanup)
│ │ └── plan.py # infranaut plan (alias for deploy --dry-run)
│ ├── templates/
│ │ ├── core/
│ │ │ ├── envrc.j2 # Workspace-level direnv
│ │ │ └── env_example.j2 # Workspace-level .env.example
│ │ ├── project/
│ │ │ ├── opentofu/
│ │ │ │ ├── _root.hcl.j2 # S3 backend + provider blocks
│ │ │ │ ├── _env.hcl.j2 # Client constants: location, image, server_sizes, firewall rules
│ │ │ │ ├── env.hcl.j2 # Environment: CIDRs, per-workload sizes, feature flags
│ │ │ │ ├── envrc.j2 # Project-level direnv
│ │ │ │ ├── env_example.j2
│ │ │ │ └── env/ # Per-environment templates
│ │ │ │ ├── envrc.j2
│ │ │ │ └── env_example.j2
│ │ │ └── ansible/
│ │ │ └── ansible.cfg.j2
│ │ └── workloads/
│ │ ├── gitlab/
│ │ │ ├── terragrunt.hcl.j2 # Cascade locals, dependency wiring
│ │ │ ├── inputs.hcl.j2 # Volumes, DNS (record_names), firewall extras, runners cluster
│ │ │ ├── inventory.yml.j2 # Static host skeleton
│ │ │ ├── inventory_dynamic.yml.j2 # Host IPs + ansible_vars from Terraform outputs
│ │ │ ├── group_vars_main.yml.j2 # GitLab role vars (external_url, registry_port)
│ │ │ ├── site.yml.j2 # Bootstrap → hardening → GitLab → configure
│ │ │ └── output_map.yml # TF→Ansible mapping (ansible_host, private_ip, …)
│ │ ├── kubernetes_cluster/
│ │ │ ├── terragrunt.hcl.j2 # masters/workers, LB, k8s metadata inputs
│ │ │ ├── inputs.hcl.j2 # Node topology, CIDR, volumes, DNS records
│ │ │ ├── inventory.yml.j2 # controlplanes + workers host groups
│ │ │ ├── inventory_dynamic.yml.j2 # Filled from k8s_cluster.masters/workers output
│ │ │ ├── group_vars_main.yml.j2 # kubernetes_version, pod_network_addon, …
│ │ │ ├── site.yml.j2 # packages → first-controlplane → workers → cni → argocd
│ │ │ └── output_map.yml # api_server_address, cluster_name, kubernetes_version
│ │ ├── single_node/
│ │ │ ├── terragrunt.hcl.j2 # Cascade locals, server + network + firewall inputs
│ │ │ ├── inputs.hcl.j2 # server_type, volumes, dns_config (record_names), extras
│ │ │ ├── inventory.yml.j2
│ │ │ ├── inventory_dynamic.yml.j2
│ │ │ ├── group_vars_main.yml.j2
│ │ │ ├── site.yml.j2
│ │ │ └── output_map.yml
│ │ ├── multi_node/
│ │ │ ├── terragrunt.hcl.j2 # servers input, dns_zone, load_balancers, ansible_vars
│ │ │ ├── inputs.hcl.j2 # servers map (per-server dns.record_names), dns_zone, LBs
│ │ │ ├── inventory.yml.j2 # primary + secondary host groups
│ │ │ ├── inventory_dynamic.yml.j2
│ │ │ ├── group_vars_main.yml.j2
│ │ │ ├── site.yml.j2
│ │ │ └── output_map.yml
│ │ ├── network/
│ │ │ └── terragrunt.hcl.j2 # Shared private network (auto-created per env)
│ │ └── ssh_keys/
│ │ ├── terragrunt.hcl.j2 # SSH keys (shared/ssh-keys)
│ │ └── inputs.hcl.j2
│ └── utils/
│ └── console.py # Rich-based helpers: info, warning, error, success, section
├── tests/
├── pyproject.toml
└── .gitlab-ci.yml
License
MIT
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file infranaut-0.4.2.tar.gz.
File metadata
- Download URL: infranaut-0.4.2.tar.gz
- Upload date:
- Size: 97.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.11.15
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
4c9e49641d50a5949021a33562399db34f66edec883790b24a346780b303a5d3
|
|
| MD5 |
132565e2232325af7c7e9e542e187c97
|
|
| BLAKE2b-256 |
2a32a16ebc0906784cd41a269cda418053b12c67a1276dd1af7abe71c9d1fd7b
|
File details
Details for the file infranaut-0.4.2-py3-none-any.whl.
File metadata
- Download URL: infranaut-0.4.2-py3-none-any.whl
- Upload date:
- Size: 93.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.11.15
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
f731d20319a3ae4a2a9e58e8ce06e38d2b3177ec9f2c7917e2a10158e2f8abf9
|
|
| MD5 |
c1301f536964120e5d9fa2657561cebc
|
|
| BLAKE2b-256 |
8b4ac305adb086a87d51f2499c08e802327730977a4a56c1457728ebde43d632
|