Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .claude/skills/swamp-model/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,10 @@ methods:
Inputs are provided at runtime with `--input` or `--input-file` and referenced
in globalArguments using `${{ inputs.<name> }}` expressions.

**Factory pattern:** Use inputs to create multiple instances from one model
definition — see
[references/scenarios.md#scenario-5](references/scenarios.md#scenario-5-factory-pattern-for-model-reuse).

## Edit a Model

**Recommended:** Use `swamp model get <name> --json` to get the file path, then
Expand Down
223 changes: 223 additions & 0 deletions .claude/skills/swamp-model/references/scenarios.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ End-to-end scenarios showing how to build models for common use cases.
- [Scenario 2: Chained AWS Lookups](#scenario-2-chained-aws-lookups)
- [Scenario 3: Model with Runtime Inputs](#scenario-3-model-with-runtime-inputs)
- [Scenario 4: Multi-Environment Configuration](#scenario-4-multi-environment-configuration)
- [Scenario 5: Factory Pattern for Model Reuse](#scenario-5-factory-pattern-for-model-reuse)

---

Expand Down Expand Up @@ -431,3 +432,225 @@ swamp workflow run deploy-api --input '{"environment": "production"}' --json
| api-client-staging | `vault.get("staging-secrets", "API_KEY")` | `staging-key-67890` |
| api-client-prod | `vault.get("prod-secrets", "API_KEY")` | `prod-key-secure` |
| All models | `self.name` | Model name |

---

## Scenario 5: Factory Pattern for Model Reuse

### User Request

> "I need to create 4 subnets (public-a, public-b, private-a, private-b) but
> they all have the same schema. I don't want to maintain 4 separate model
> definitions."

### What You'll Build

- 1 model definition (`prod-subnet`) called 4 times with different inputs
- 4 distinct data instances keyed by `instanceName`

### Decision Tree

```
Multiple instances of the same resource type? → Factory pattern
Same schema, different parameters? → Yes → One model + inputs
Different schemas or behaviors? → No → Separate models
```

### When to Use Factory vs Separate Models

| Situation | Approach |
| --------------------------------------------- | ----------------- |
| 4 subnets with different CIDRs/AZs | Factory (1 model) |
| 2 EIPs with different tags | Factory (1 model) |
| A VPC and a subnet (different resource types) | Separate models |
| Resources with different method signatures | Separate models |

### Step-by-Step

**1. Create the model**

```bash
swamp model create @user/aws-subnet prod-subnet --json
```

**2. Configure with inputs schema**

Edit `models/prod-subnet/input.yaml`:

```yaml
name: prod-subnet
version: 1
tags: {}
inputs:
properties:
instanceName:
type: string
description: Unique name for this subnet instance (becomes the data name)
cidrBlock:
type: string
description: CIDR block for the subnet
availabilityZone:
type: string
description: AWS availability zone
required: ["instanceName", "cidrBlock", "availabilityZone"]
globalArguments:
name: ${{ inputs.instanceName }}
VpcId: ${{ data.latest("prod-vpc", "main").attributes.VpcId }}
CidrBlock: ${{ inputs.cidrBlock }}
AvailabilityZone: ${{ inputs.availabilityZone }}
Tags:
- Key: Name
Value: ${{ inputs.instanceName }}
methods:
create:
arguments: {}
delete:
arguments: {}
```

**3. The `name` and data name connection**

The `name: ${{ inputs.instanceName }}` in globalArguments is critical. It sets
the **data instance name**, so when you call the model with
`instanceName: "public-a"`, the output data is stored as `public-a`. This means
downstream models can access it with:

```yaml
subnetId: ${{ data.latest("prod-subnet", "public-a").attributes.SubnetId }}
```

Each call with a different `instanceName` creates a separate data instance under
the same model definition.

**4. Create workflow — call the model multiple times**

```yaml
name: create-subnets
version: 1
jobs:
- name: create-subnets
description: Create all 4 subnets in parallel
steps:
- name: create-public-a
task:
type: model_method
modelIdOrName: prod-subnet
methodName: create
inputs:
instanceName: public-a
cidrBlock: "10.0.1.0/24"
availabilityZone: us-east-1a
- name: create-public-b
task:
type: model_method
modelIdOrName: prod-subnet
methodName: create
inputs:
instanceName: public-b
cidrBlock: "10.0.2.0/24"
availabilityZone: us-east-1b
- name: create-private-a
task:
type: model_method
modelIdOrName: prod-subnet
methodName: create
inputs:
instanceName: private-a
cidrBlock: "10.0.3.0/24"
availabilityZone: us-east-1a
- name: create-private-b
task:
type: model_method
modelIdOrName: prod-subnet
methodName: create
inputs:
instanceName: private-b
cidrBlock: "10.0.4.0/24"
availabilityZone: us-east-1b
```

Steps within a job run in parallel, so all 4 subnets are created concurrently.

**5. Delete workflow — provide inputs the method actually uses**

Delete steps must provide `instanceName` because
`name: ${{ inputs.instanceName }}` determines which data instance to read and
delete. Other inputs are only needed if the delete method implementation
accesses those globalArguments.

The system **selectively evaluates** globalArgument expressions — inputs that
aren't provided are skipped. A runtime error only occurs if the method code
actually tries to access an unresolved globalArgument.

```yaml
name: delete-subnets
version: 1
jobs:
- name: delete-subnets
description: Delete all 4 subnets
steps:
- name: delete-public-a
task:
type: model_method
modelIdOrName: prod-subnet
methodName: delete
inputs:
instanceName: public-a
cidrBlock: "10.0.1.0/24"
availabilityZone: us-east-1a
identifier: ${{ data.latest("prod-subnet", "public-a").attributes.SubnetId }}
- name: delete-public-b
task:
type: model_method
modelIdOrName: prod-subnet
methodName: delete
inputs:
instanceName: public-b
cidrBlock: "10.0.2.0/24"
availabilityZone: us-east-1b
identifier: ${{ data.latest("prod-subnet", "public-b").attributes.SubnetId }}
- name: delete-private-a
task:
type: model_method
modelIdOrName: prod-subnet
methodName: delete
inputs:
instanceName: private-a
cidrBlock: "10.0.3.0/24"
availabilityZone: us-east-1a
identifier: ${{ data.latest("prod-subnet", "private-a").attributes.SubnetId }}
- name: delete-private-b
task:
type: model_method
modelIdOrName: prod-subnet
methodName: delete
inputs:
instanceName: private-b
cidrBlock: "10.0.4.0/24"
availabilityZone: us-east-1b
identifier: ${{ data.latest("prod-subnet", "private-b").attributes.SubnetId }}
```

### Understanding Input Requirements for Delete

The system handles unresolved globalArguments gracefully:

| Input | Needed for delete? | Why |
| ------------------ | -------------------------- | ---------------------------------------------- |
| `instanceName` | **Always** | Keys the data instance (`name` globalArgument) |
| `identifier` | **Always** | The resource ID to delete |
| `cidrBlock` | Only if method accesses it | Skipped if not provided and not used by method |
| `availabilityZone` | Only if method accesses it | Skipped if not provided and not used by method |

If your delete method implementation only reads `globalArgs.name` and
`args.identifier`, you can omit `cidrBlock` and `availabilityZone` from the
delete step inputs. Unresolved expressions are skipped — the system only throws
an error if the method code actually tries to access an unresolved value.

### CEL Paths Used

| Data | CEL Path |
| --------------------- | ------------------------------------------------------------- |
| Subnet ID (public-a) | `data.latest("prod-subnet", "public-a").attributes.SubnetId` |
| Subnet ID (private-b) | `data.latest("prod-subnet", "private-b").attributes.SubnetId` |
| VPC ID (dependency) | `data.latest("prod-vpc", "main").attributes.VpcId` |
108 changes: 108 additions & 0 deletions .claude/skills/swamp-workflow/references/data-chaining.md
Original file line number Diff line number Diff line change
Expand Up @@ -416,3 +416,111 @@ jobs:
| Create | Forward (VPC → subnets → RT) | Write new data via `writeResource()` |
| Update | Forward (VPC → subnets → RT) | Read stored data, modify, write via `writeResource()` |
| Delete | Reverse (RT → subnets → VPC) | Read stored data, clean up, return empty handles |

## Factory Model Patterns

The factory pattern uses one model definition with `inputs` to create multiple
named instances. Instead of maintaining 4 separate subnet model definitions, you
define one `prod-subnet` model and call it 4 times with different inputs.

For a complete walkthrough, see the
[swamp-model scenarios](../../swamp-model/references/scenarios.md#scenario-5-factory-pattern-for-model-reuse).

### Calling One Model Multiple Times

Steps within a job run in parallel. Each step calls the same `modelIdOrName`
with different inputs:

```yaml
jobs:
- name: create-subnets
steps:
- name: create-public-a
task:
type: model_method
modelIdOrName: prod-subnet
methodName: create
inputs:
instanceName: public-a
cidrBlock: "10.0.1.0/24"
availabilityZone: us-east-1a
- name: create-public-b
task:
type: model_method
modelIdOrName: prod-subnet
methodName: create
inputs:
instanceName: public-b
cidrBlock: "10.0.2.0/24"
availabilityZone: us-east-1b
```

The `instanceName` input flows into `name: ${{ inputs.instanceName }}` in the
model's globalArguments, which sets the data instance name. This is how one
model produces separately addressable data instances.

### Referencing Factory Instance Data Downstream

Each factory call creates a distinct data instance keyed by `instanceName`. Use
`data.latest()` with the instance name to reference specific outputs:

```yaml
jobs:
- name: create-route-tables
dependsOn:
- job: create-subnets
condition:
type: succeeded
steps:
- name: create-public-rt
task:
type: model_method
modelIdOrName: prod-route-table
methodName: create
inputs:
instanceName: public-rt
subnetId: ${{ data.latest("prod-subnet", "public-a").attributes.SubnetId }}
```

### Delete Steps for Factory Models

Delete steps must provide `instanceName` because the `name` globalArgument
(`name: ${{ inputs.instanceName }}`) determines which data instance to read and
delete. Other inputs are only required if the delete method's implementation
accesses those globalArguments at runtime.

The system **selectively evaluates** globalArgument expressions — inputs that
aren't provided are skipped, and a runtime error only occurs if the method code
actually tries to access an unresolved value.

**What breaks — missing `instanceName`:**

```yaml
# WRONG: No instanceName, so the system can't resolve which data instance to use
- name: delete-public-a
task:
type: model_method
modelIdOrName: prod-subnet
methodName: delete
inputs:
identifier: ${{ data.latest("prod-subnet", "public-a").attributes.SubnetId }}
```

**What works — `instanceName` provided:**

```yaml
# CORRECT: instanceName resolves the name globalArgument and keys the data instance
- name: delete-public-a
task:
type: model_method
modelIdOrName: prod-subnet
methodName: delete
inputs:
instanceName: public-a
identifier: ${{ data.latest("prod-subnet", "public-a").attributes.SubnetId }}
```

Whether you also need `cidrBlock`, `availabilityZone`, or other create-time
inputs depends on your delete method implementation. If the method code accesses
`globalArgs.CidrBlock`, you must provide `cidrBlock`. If it doesn't, the
unresolved expression is silently skipped.
Loading