Skip to main content

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

  1. Overview
  2. Design philosophy
  3. Architecture
  4. Supported workload types
  5. Installation
  6. Quick start
  7. Commands reference
  8. infranaut.yml — project registry
  9. Configuration hierarchy in depth
  10. Output bridge internals
  11. Ansible inventory generation
  12. Core providers
  13. CI/CD pipeline integration
  14. Development workflow
  15. Release workflow
  16. Smoke test procedure
  17. 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.hcl and group_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 apply only creates or modifies what has diverged from the desired state
  • ansible-playbook roles are written to be re-entrant (check before act)
  • infranaut deploy can 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 by gitlab, single_node, multi_node: a flat map of server_name → {ansible_host, private_ip, fqdn}. The bridge flattens these into a hosts list for the inventory template.
  • component.k8s_cluster — used by kubernetes_cluster: a nested map with masters and workers sub-maps. The bridge unpacks these into separate controlplanes and workers lists.

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:

  1. First from core/opentofu/cloud/hetzner/component/ (user-managed modules)
  2. 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+
  • terragrunt in $PATH (for deploy and inventory generate)
  • ansible-playbook in $PATH (for deploy)

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 delete removes files only. Destroy provisioned infrastructure with terragrunt destroy before 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.hcl
  • opentofu/<env>/<workload>/inputs.hcl
  • ansible/inventories/<env>/<workload>/default.yml
  • ansible/inventories/<env>/<workload>/group_vars/all/main.yml
  • ansible/inventories/<env>/<workload>/group_vars/all/vault.yml
  • ansible/playbooks/<env>/<workload>/site.yml

Type-specific extras created automatically:

  • kubernetes_clustergroup_vars/controlplanes/main.yml + group_vars/workers/main.yml
  • multi_nodegroup_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 delete removes files only. Destroy provisioned infrastructure with terragrunt destroy before 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 type field is the only link between a workload directory on disk and its Terraform module and Ansible templates. Do not edit infranaut.yml by 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 defaults
  • public_ipv4_enabled, public_ipv6_enabled, enable_firewall
  • volumes — attached persistent volumes (size, format, automount)
  • dns_config — Cloudflare or Hetzner DNS records (record_names is a list)
  • extra_firewall_rules — appended to the base rules from _env.hcl
  • ansible_groups, ansible_vars — passed through as Terraform outputs to the inventory bridge

For kubernetes_cluster:

  • masters / workers — per-node maps with server_type, location, image, backups
  • network_ip_range / subnet_ip_range — cluster-internal network (distinct from the environment network)
  • enable_api_lb, enable_ingress_lb — Hetzner load balancers
  • kubernetes_version, pod_subnet, service_subnet
  • dns_config.records — populated after first apply once LB IPs are known
  • ansible_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 location in its inputs.hcl — it takes precedence.
  • If not pinned, the environment's value from env.hcl is used.
  • If the environment has no override either, the project default from _env.hcl applies.

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 → builds hosts list + flattens first server's IP for single-host templates
  • component.k8s_cluster → builds separate controlplanes and workers lists

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 OK lines printed in steps 8–10
  • Generated HCL and YAML files have the correct project_name, env_name, workload_name substituted
  • ansible.cfg, inventories/, playbooks/, group_vars/ directories present under ansible/
  • infranaut.yml updated after each create command
  • The three-level cascade pattern (try(local.inp.X, try(local.env.X, ...))) is present in every workload's terragrunt.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


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

infranaut-0.4.2.tar.gz (97.2 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

infranaut-0.4.2-py3-none-any.whl (93.5 kB view details)

Uploaded Python 3

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

Hashes for infranaut-0.4.2.tar.gz
Algorithm Hash digest
SHA256 4c9e49641d50a5949021a33562399db34f66edec883790b24a346780b303a5d3
MD5 132565e2232325af7c7e9e542e187c97
BLAKE2b-256 2a32a16ebc0906784cd41a269cda418053b12c67a1276dd1af7abe71c9d1fd7b

See more details on using hashes here.

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

Hashes for infranaut-0.4.2-py3-none-any.whl
Algorithm Hash digest
SHA256 f731d20319a3ae4a2a9e58e8ce06e38d2b3177ec9f2c7917e2a10158e2f8abf9
MD5 c1301f536964120e5d9fa2657561cebc
BLAKE2b-256 8b4ac305adb086a87d51f2499c08e802327730977a4a56c1457728ebde43d632

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page