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.
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.
component.output("key") → OutputRef¶
Reads a named output from terraform output -json after the component is applied.
component.state(jmes="...") → StateRef¶
Reads a value from terraform show -json using a JMESPath expression.
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.testson eachBaseComponent— 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.