Skip to content

G1.1: SubstituteInFilesStep — close the IIS UI-theatre toggle

Placeholder ppxd requested to merge feat/calamari-g1-substitute-in-files into main

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/passwd globs 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.UnitTests pinning 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

Merge request reports

Loading