Skip to content

Concepts

This page explains the mental model behind small-config — how the pieces fit together and why they're designed that way.

The big picture

config.py
  └── CONFIG = AppConfig()          ProjectConfig subclass
        └── project(env) → Project  one Project per environment
              ├── components: [...]  ordered graph of work units
              └── tests: [...]       assertions that run after deploy

When you run sc build, the CLI calls CONFIG.project(env), orders the components topologically, runs each one, then runs the tests.


ProjectConfig

ProjectConfig is the entry point for your stack. You subclass it in config.py, set default_env, implement project(), and expose an instance as CONFIG.

from small_config import Project, ProjectConfig

class AppConfig(ProjectConfig):
    default_env = "dev"

    def project(self, env: str) -> Project:
        ...
        return Project(name="my-app", env=env, components=[...])

CONFIG = AppConfig()

project() is a plain Python method. You can use if env == "production", import helpers from other modules, read environment variables, or do anything else Python allows.

Legacy def env() support

For backward compatibility, small-config still loads files that define def env(name: str) -> Project without ProjectConfig. The CONFIG object is preferred; the bare function is a fallback.


Project

Project is a dataclass holding:

Field Type Description
name str Stack name — used in lock names and state keys
env str Active environment name (e.g. dev, production)
components list[BaseComponent] Ordered or unordered list of work units
tests list[HttpTest] Smoke tests run after sc build
lock str Lock strategy: "local" (default) or "git-tag"

To merge a second Project into yours, compose them in Python: build each Project separately and combine their component lists.


Components

A component is a unit of work: render templates → run commands → capture state.

Built-in components

Class Default commands State capture
TerraformComponent terraform init + terraform apply/plan/destroy terraform show -json + terraform output -json
AnsibleComponent ansible-playbook <playbook> none
ShellComponent user-supplied commands= list none

Common parameters (all components)

Parameter Type Default Description
name str required Unique identifier within a Project
path str \| None ./{name} Directory containing the component's files. Omitted → ./{name}. "" → same path with a warning (prefer omitting path or set an explicit directory)
action str "create" Lifecycle verb; affects which commands run
vars dict {} Template variables — values can be literals or Ref objects
steps list[str] \| None None Override the default command list entirely
depends_on list[BaseComponent] [] Explicit ordering edges
tests list[Any] [] HTTP smoke tests owned by this component (see Tests on components)

Template rendering

Before any command runs, small-config renders the component's directory to .dist/<component>/. Any file ending in .j2 is treated as a Jinja2 template — variables from vars are injected at render time. Non-.j2 files are copied unchanged.

Terraform state files (.terraform/, terraform.tfstate*) are stashed before rendering and restored after, so state is never lost on re-deploy.

Dependency ordering

Components listed in depends_on run first. The engine orders all components topologically; circular dependencies raise an error at startup.

app = AnsibleComponent(name="app", depends_on=[infra])  # path defaults to ./app

You don't need depends_on just to read infra outputs — that's handled by Ref objects at resolution time.


Refs

A ref is a lazy reference to a value that isn't known until deploy time: an output from a Terraform run, a secret from the environment, or an arbitrary value from state JSON.

Refs are resolved by the engine at the moment a component's vars are evaluated.

sc.secret("name") → SecretRef

Reads SC_SECRET_<NAME> from the environment at deploy time. The value is never written to state or logs.

ssh_pub_key = sc.secret("ssh_pub_key")  # reads SC_SECRET_SSH_PUB_KEY

component.output("key") → OutputRef

Reads a named output from terraform output -json after the component is applied.

url = infra.output("ip", transform=lambda ip: f"http://{ip}:8000")

component.state(jmes="...") → StateRef

Reads a value from terraform show -json using a JMESPath expression.

instance_id = infra.state(jmes="values.root_module.resources[0].values.id")

Not every integration captures outputs or JSON state. AnsibleComponent and ShellComponent have no state_cmd() or outputs_cmd() — only TerraformComponent captures structured state. See Config Reference for details.


State

After each successful component run, small-config saves a state record to sc.state.<env>.json:

{
  "components": {
    "infra": {
      "state": { ... },        // terraform show -json output
      "outputs": { "ip": "1.2.3.4" },  // terraform output -json
      "deployed_at": "2026-04-12T10:00:00Z",
      "git_sha": "abc1234",
      "operator": "you@example.com"
    }
  }
}

Each environment gets its own state file — sc.state.dev.json, sc.state.production.json, etc. — so environments are fully isolated.

sc output infra ip prints the saved output value without re-running Terraform.


Tests (HttpTest)

After deploy, small-config runs HTTP smoke tests (GET + status + optional body check). They run at the end of sc build and with sc test.

Tests come from two places, merged when the CLI runs checks:

  • Project.tests — stack-wide checks (e.g. external URLs, or refs that name a specific component).
  • component.tests on each BaseComponent — checks that belong to that component.

sc build <component> only runs tests that target that component (see filtering below).

A test retries up to attempts times (default 6) on transport errors, waiting retry_delay_s seconds (default 5) between tries. See Config Reference for all parameters.

Project-level tests

Inside with Project(...) as p:, a bare HttpTest(...) (no owning component) registers on p.tests. Use a normal URL or an OutputRef / infra.output(...) when the URL must resolve from saved Terraform outputs.

with Project("my-app", env) as p:
    infra = TerraformComponent(name="infra", vars={...})
    HttpTest(
        name="api health",
        url=infra.output("ip", transform=lambda ip: f"http://{ip}:8000"),
        expect_status=200,
    )

Tests on components

Keep checks next to the stack they validate by putting them on tests=[...] (or assigning component.tests after construction). Recommended patterns:

1. Constructor + SelfOutputRef — output key only; the owning component is implied (no duplicate component name in the ref).

from small_config import HttpTest, SelfOutputRef, TerraformComponent

TerraformComponent(
    name="infra",
    vars={...},
    tests=[
        HttpTest(
            name="hello http",
            url=SelfOutputRef("ip", transform=lambda ip: f"http://{ip}:8000"),
            expect_status=200,
        ),
    ],
)

2. Context manager + infra.output(...) — after TerraformComponent(...) exists, assign tests; binding runs when the with block exits.

with TerraformComponent(name="infra", vars={...}) as infra:
    infra.tests = [
        HttpTest(
            name="hello http",
            url=infra.output("ip", transform=lambda ip: f"http://{ip}:8000"),
            expect_status=200,
        ),
    ]

3. Explicit owner — set HttpTest(..., component=infra) when you already have the infra variable.

Do not use SelfOutputRef in Terraform vars= or for project-only URLs — it only works for component-scoped HttpTest (constructor list, context-manager assignment, or component=). For explicit cross-component or project-level URLs, use OutputRef or other.output(...).

Filtering by component

When you pass a component name to sc build / sc test, only tests that reference that component are run: OutputRef / StateRef URLs match by component_name, and SelfOutputRef tests match the HttpTest’s owning component.


Locking

sc deploy, sc build, and sc destroy all acquire a lock before running and release it on finish. This prevents concurrent deploys to the same environment.

Strategy When to use
lock="local" (default) Single developer, local runs
lock="git-tag" Team, shared repo, CI/CD

If a lock is stuck after a crash: sc unlock --force.


Audit log

Every deploy, destroy, or test run appends a JSON line to .sc/audit.log:

{"timestamp": "...", "action": "deploy", "component": "infra", "env": "dev", "operator": "you@example.com", "git_sha": "abc1234"}

This gives you a lightweight history without a remote backend.