Metadata-Version: 2.4
Name: infranaut
Version: 0.4.2
Summary: GitOps-driven Managed Infrastructure Framework
License: MIT
Keywords: infrastructure,devops,gitops,terraform,opentofu,ansible,terragrunt
Classifier: Development Status :: 3 - Alpha
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: System Administrators
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: System :: Systems Administration
Classifier: Topic :: Utilities
Requires-Python: >=3.10
Description-Content-Type: text/markdown
Requires-Dist: typer[all]>=0.12
Requires-Dist: jinja2>=3.1
Requires-Dist: rich>=13.0
Requires-Dist: pyyaml>=6.0
Requires-Dist: ansible>=9.0
Requires-Dist: cryptography>=41.0
Requires-Dist: jmespath>=1.0
Requires-Dist: netaddr>=0.9
Requires-Dist: passlib[bcrypt]>=1.7
Provides-Extra: dev
Requires-Dist: pytest>=7.0; extra == "dev"
Requires-Dist: pytest-cov>=4.0; extra == "dev"
Requires-Dist: ruff>=0.1; extra == "dev"
Requires-Dist: ansible-lint>=24.0; extra == "dev"

# 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](#overview)
2. [Design philosophy](#design-philosophy)
3. [Architecture](#architecture)
   - [Workspace layout](#workspace-layout)
   - [Variable resolution cascade](#variable-resolution-cascade)
   - [Output bridge — Terraform → Ansible](#output-bridge--terraform--ansible)
   - [Template system](#template-system)
   - [Deployment pipeline](#deployment-pipeline)
4. [Supported workload types](#supported-workload-types)
5. [Installation](#installation)
6. [Quick start](#quick-start)
7. [Commands reference](#commands-reference)
   - [Workspace](#workspace)
   - [Project](#project)
   - [Environment](#environment)
   - [Workload](#workload)
   - [Inventory](#inventory)
   - [Deploy](#deploy)
8. [infranaut.yml — project registry](#infranauyml--project-registry)
9. [Configuration hierarchy in depth](#configuration-hierarchy-in-depth)
   - [_env.hcl — client-level constants](#_envhcl--client-level-constants)
   - [env.hcl — environment-level overrides](#envhcl--environment-level-overrides)
   - [inputs.hcl — workload-level values](#inputshcl--workload-level-values)
   - [terragrunt.hcl — cascade resolution](#terragrunnthcl--cascade-resolution)
10. [Output bridge internals](#output-bridge-internals)
    - [output_map.yml schema](#output_mapyml-schema)
    - [Nested inventory output handling](#nested-inventory-output-handling)
11. [Ansible inventory generation](#ansible-inventory-generation)
    - [Static inventory (default.yml)](#static-inventory-defaultyml)
    - [Dynamic inventory (dynamic.yml)](#dynamic-inventory-dynamicyml)
    - [Group variables structure](#group-variables-structure)
12. [Core providers](#core-providers)
13. [CI/CD pipeline integration](#cicd-pipeline-integration)
14. [Development workflow](#development-workflow)
15. [Release workflow](#release-workflow)
16. [Smoke test procedure](#smoke-test-procedure)
17. [Project structure](#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:

```hcl
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)

```bash
pip install infranaut
```

### From source (development)

```bash
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

```bash
# 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_cluster` → `group_vars/controlplanes/main.yml` + `group_vars/workers/main.yml`
- `multi_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 delete` removes files only. Destroy provisioned infrastructure with `terragrunt destroy` before deleting.

**Examples:**

```bash
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:**

```bash
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:**

```bash
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

```yaml
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.

```hcl
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.

```hcl
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:

```hcl
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:

```yaml
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:

```json
{
  "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`:

```json
{
  "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:

```yaml
# 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:

```yaml
# 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:

```yaml
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

```bash
# 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

```yaml
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

```bash
pip install -e ".[dev]"    # installs typer, jinja2, pyyaml, rich, ruff, pytest
```

### Run tests

```bash
pytest tests/ -v
```

### Lint and format

```bash
ruff check infranaut/
ruff format infranaut/
```

### Smoke test (full end-to-end, no cloud access)

```bash
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:**

```python
# infranaut/__init__.py
__version__ = "0.5.0"
```

```toml
# pyproject.toml
version = "0.5.0"
```

**2. Commit and tag:**

```bash
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.

```bash
# 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
