G1.1: SubstituteInFilesStep — close the IIS UI-theatre toggle
Summary
Phase G1.1 of the Calamari Refactor Roadmap (Notion). First concrete PR that follows from the parent Architecture Decision document.
Closes the IIS deploy handler's currently-fake "Substitute variables in files" toggle. Pre-G1.1, operators set SubstituteInFiles.Enabled = True in the UI, the deploy ran, and the script preamble inlined the variable as 'True' — but no Calamari step consumed it. Silent UI theatre.
What's new
Three components under Squid.Calamari.Commands.Substitution:
| Component | Lines | Tests | Purpose |
|---|---|---|---|
TokenSubstituter |
~80 | 13 | Pure #{Token} regex replacer with Octostache parity (single-pass, ##{Foo} escape, lenient default) |
GlobMatcher |
~110 | 8 | In-house glob→regex + file enumeration, no NuGet dep, path-traversal sandboxed |
SubstituteInFilesStep |
~180 | 18 | Pipeline step orchestrating both; encoding-preserving; strict + lenient modes |
Wired into RunScriptCommandPipeline between LoadVariablesFromFilesStep and WriteBootstrappedBashScriptStep.
Operator-visible behaviour
Enabled + with file globs (operator's typical case):
Variables:
Squid.Action.IISWebSite.SubstituteInFiles.Enabled = "True"
Squid.Action.IISWebSite.SubstituteInFiles.TargetFiles = "web.config\n**/*.json"
Db.ConnectionString = "Server=prod;Database=app"
Squid.Environment.Name = "Production"
File before: {"ConnectionString":"#{Db.ConnectionString}","Env":"#{Squid.Environment.Name}"}
File after: {"ConnectionString":"Server=prod;Database=app","Env":"Production"}
Disabled or missing toggle: step is skipped entirely (IsEnabled returns false).
Unresolved token, lenient (default): stays as #{NotDefined} in the file + stderr warning.
Unresolved token, strict (ShouldFailDeploymentOnSubstitutionFails = True): step throws SubstituteInFilesException listing unresolved tokens grouped by file.
Architectural anchors
-
Sandboxed:
../etc/passwdglobs yield zero matches. Substitution stays within working dir. - Encoding-preserving: UTF-8 BOM round-trips exactly. IIS / .NET FX config parsers detect encoding via BOM; stripping it breaks them.
- Binary-safe: null-byte heuristic (same as git) skips binary files silently.
- Lean: no NuGet deps added — Calamari binary stays minimal (parent decision: keep agent zip small).
Wire-contract pinning (Rule 8 — both sides)
Cross-project drift detector in Squid.UnitTests.IISSubstituteInFilesWireContractTests asserts:
SubstituteInFilesVariableNames.Enabled (Squid.Calamari, public)
== IISDeployProperties.SubstituteInFilesEnabled (Squid.Core)
== "Squid.Action.IISWebSite.SubstituteInFiles.Enabled"
A rename on either side without the other = failing test, not silent production no-op.
Test plan
-
13 unit tests on TokenSubstituter— regex shape, escape, single-pass, dotted names, edge cases -
8 unit tests on GlobMatcher— patterns, recursion, dot-literal, path-traversal sandbox -
18 unit tests on SubstituteInFilesStep— enable-gate × 4, happy path × 3, encoding × 2, strict/lenient × 2, edge cases × 4, wire pins × 3 -
4 cross-project drift tests in Squid.UnitTestspinning server↔️ agent contract -
5607/5607 Squid.UnitTests green (+4 net new) -
179/179 Squid.Calamari.Tests green (+39 net new)
What's NOT in this PR
- G1.2 (ConfigurationTransformsStep — XDT): next, separate PR
- G1.3 (StructuredConfigVariablesStep — JSON path edits): separate PR
- G1.4 (ExtractPackageStep): separate PR
- G1.5 (Convention hooks PreDeploy/Deploy/PostDeploy): separate PR
Refs
- Notion:
🦑 Squid — Calamari Architecture Decision (2026-05-23) — parent decision - Notion:
🦑 Squid — Calamari Refactor Roadmap (Post 1.8.1) — phase plan - PR #353 — the bypass that prompted the strategic question