Workflow Contract¶
A CUTIP workflow is a plain Python file (workflow.py) containing exactly one function: main(ctx: CutipContext). CUTIP discovers and imports this file dynamically at runtime and calls main(ctx).
There is no base class to inherit, no decorator to apply, no registration step. If the file exists and exports main, it runs.
CutipContext¶
@dataclass
class CutipContext:
group: Group # the Group being executed
resolved_units: dict[str, Unit] # unit name → Unit
resolved_cards: dict[str, CutipBaseModel] # card ref → Card
registry: CutipRegistry # full workspace registry
project_root: Path # absolute path to project root
runtime: CutipBackend | None # None in cutip plan (dry-run)
ctx.resolved_cards¶
Keyed by the card's registry ref ("images/app", "containers/db", etc.). Only cards that are reachable from the group's unit chain are included.
img_card = ctx.resolved_cards["images/app"] # ImageCard
cc = ctx.resolved_cards["containers/app"] # ContainerCard
net = ctx.resolved_cards["networks/dev"] # NetworkCard
ctx.runtime¶
A CutipBackend instance connected to the configured runtime. It is None when the workflow is invoked via cutip plan. Always guard against this if you want a plan-safe workflow.
CutipBackend methods¶
The backend handle (ctx.runtime) exposes the following interface:
# Images
runtime.pull_image(card: ImageCard) -> None
runtime.build_image(card: ImageCard, project_root: Path | None = None) -> None
# Networks
runtime.ensure_network(card: NetworkCard) -> None
# Containers
runtime.create_container(card: ContainerCard, image_name: str | None = None) -> str
runtime.start_container(name: str) -> None
runtime.stop_container(name: str) -> None
runtime.remove_container(name: str) -> None
# Inspection
runtime.container_status(name: str) -> str # "running" | "exited" | "not_found" | ...
runtime.container_logs(name: str) -> str # stdout + stderr as a string
create_container image_name¶
If image_name is omitted, CUTIP infers <card.metadata.name>:latest. Pass it explicitly when using a build tag:
image_name = f"{img_card.name}:{img_card.spec.tag}"
runtime.create_container(cc, image_name=image_name)
Runtime injection pattern¶
Cards are immutable Pydantic models. To inject runtime values (paths from .env, dynamic mount sources), use model_copy:
from dotenv import load_dotenv
import os
load_dotenv()
runtime_mounts = [
{"type": "bind", "source": os.environ["DATA_DIR"], "target": "/data"}
]
cc = ctx.resolved_cards["containers/app"]
cc = cc.model_copy(update={
"spec": cc.spec.model_copy(update={"mounts": runtime_mounts})
})
runtime.create_container(cc, image_name="app:latest")
Plan-safe guard¶
ctx.runtime is None when invoked via cutip plan. If your workflow contains side effects beyond container operations (file writes, HTTP calls, etc.), guard them:
def main(ctx):
if ctx.runtime is None:
print("Dry-run — skipping execution")
return
# ... actual deployment logic
Example: full lifecycle workflow¶
from cutip.models.cards.image import ImageCard
from cutip.models.cards.container import ContainerCard
from cutip.models.cards.network import NetworkCard
def main(ctx):
if ctx.runtime is None:
return
img = next(c for c in ctx.resolved_cards.values() if isinstance(c, ImageCard))
cc = next(c for c in ctx.resolved_cards.values() if isinstance(c, ContainerCard))
net = next(c for c in ctx.resolved_cards.values() if isinstance(c, NetworkCard))
ctx.runtime.build_image(img, project_root=ctx.project_root)
ctx.runtime.ensure_network(net)
ctx.runtime.create_container(cc, image_name=f"{img.name}:{img.spec.tag}")
ctx.runtime.start_container(cc.name)