Skip to content

Hand declared config strings to Ansible as data, never as templates (#174) - #179

Merged
shipmill-romamo[bot] merged 2 commits into
mainfrom
fix/174-no-templating-config-values
Oct 31, 2026
Merged

shipmill-romamo[bot] merged 2 commits into
mainfrom
fix/174-no-templating-config-values

Conversation

@shipmill-romamo

Copy link
Copy Markdown
Contributor

Summary

A component's service.command, healthCheck.command and release.command reached the cloudfall_deploy role through the rendered inventory, and Ansible templated them: [/bin/true, "{{ lookup('pipe', 'touch /tmp/pwned') }}"] ran on the management host while health.yml validated the role's arguments, outside any gate. It also broke honest values such as docker inspect --format {{.State.Health.Status}} app (a template syntax error).

The fix sits at the hand-off, as agreed on the issue: every string Cloudfall writes for Ansible is marked unsafe in the JSON form Ansible's loader reads, {"__ansible_unsafe": ...}.

  • New cloudfall.ansible_data.as_ansible_data marks every string of a document unsafe (keys, numbers, booleans and null pass through; anything else is a TypeError)
  • cloudfall-engine inventory render marks the whole rendered inventory, both the file it writes and the JSON it prints. That one file is what every engine playbook reads (deploy, health, restart, rollback, services, domains, data, backup, ...), so the marking also covers the Gate a deploy on the command health check and receipt it (S-009-2..9, 11, 12) #164 command health check gate once it lands
  • cloudfall observe's overlay (cloudfall_server_type and cloudfall_server_id) is marked the same way, since a server type also holds free strings (a package version, for example)

The audit of the other paths:

  • Environment values never pass through Jinja. The SDK writes the env file on the controller, and the role copies it with copy: src:
  • Build steps run on the controller as subprocesses in artifact build; they never reach Ansible
  • Every other declared string (runtime version, paths, package versions, alert rules, domains, logging stack) rides in the same rendered inventory, so it is covered by the same marking
  • The SDK's own extra vars for engine playbooks carry only pattern-checked ids (ResourceId, ReleaseId, declared database names, PostgresSchemaName from Copy only the named schemas in data copy (#124) #134, which is left as it is) and SDK-made absolute paths. They are not marked in this PR

Behaviour change: a declared value holding {{ ... }} is now literal. The rendered inventory's strings now read as {"__ansible_unsafe": "..."} in its JSON and in ansible-inventory --list.

Plan

  • Serves: quality bar
  • Files: sdk/src/cloudfall/ansible_data.py (new), engine/src/cloudfall_engine/cli.py, sdk/src/cloudfall/observe.py, engine/tests/test_unsafe_config_values.py (new), engine/tests/test_ansible_inventory.py, engine/tests/test_examples_render_unchanged.py with its golden inventory, sdk/tests/test_observe.py, changelog.d/174-no-templating-config-values.md
  • Order: the failing tests first (a {{ lookup('pipe', 'touch <marker>') }} value in healthCheck.command, service.command, release.command and a server type's package version, run through ansible-playbook as the playbooks hand it over), then mark every string of the rendered inventory and of the observe overlay unsafe where they are written for Ansible, then the tests that read the rendered JSON, then the changelog fragment
  • Proof: the new tests fail on main (the marker file appears, or the honest {{.Status}} fails to template) and pass with the fix (no marker; the literal text reaches the unit file's ExecStart=, the release command's argv and the health check command); the golden example's role templates still render byte-identical

Test plan

  • engine/tests/test_unsafe_config_values.py (new, 5 tests). Each one renders the inventory through cloudfall-engine inventory render and runs ansible-playbook against local hosts:
    • the real health.yml with a command health check holding the payload: no marker
    • the health check command handed over as the playbooks do (cloudfall_deploy_component: "{{ cloudfall_component_item }}"): arrives as declared, with docker inspect --format {{.State.Health.Status}} app in it
    • the role's component.service.j2: ExecStart= carries the payload and {{.Status}} literally
    • the role's real release_command tasks with a stand-in systemd-run: the argv ends with the payload as written
    • the cloudfall observe overlay with a package version holding the payload: no marker, literal value
  • On main, 4 of the 5 fail with the marker created or a template syntax error. The overlay test was run against main's observe.py and failed with the marker created
  • The marking shows up in test_engine_cli_emits_ansible_json, test_ansible_merges_the_overlay_over_the_team_inventory and the regenerated golden ansible-inventory.json. The golden test reads the values back as Ansible's loader does, so the nftables and PostgreSQL templates still render byte-identical
  • uv run pytest engine/tests: 237 passed, 1 skipped
  • uv run pytest sdk/tests/test_observe.py sdk/tests/test_lifecycle.py sdk/tests/test_domains_apply.py sdk/tests/test_owned_postgresql_converge.py: passed
  • uv run ruff check ., uv run mypy and ansible-lint engine/ansible (with CI's ANSIBLE_CONFIG) are clean
  • The full suite is left to CI. No run against a real SSH host: the marked ansible_host/ansible_user were exercised only through local connections

Fixes #174

🤖 Generated with Claude Code

A component's service.command, healthCheck.command and release.command
reached the cloudfall_deploy role through the rendered inventory, and
Ansible templated them, so a value holding {{ lookup('pipe', ...) }} ran
on the management host during cloudfall health or deploy, and an honest
docker inspect --format {{.State.Health.Status}} failed to template.

inventory render and the observe overlay now mark every string unsafe
({"__ansible_unsafe": ...}) where they are written for Ansible, so every
playbook that reads them, the #164 command health check gate included,
gets the text as declared.

Fixes #174

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@shipmill-romamo

Copy link
Copy Markdown
Contributor Author

@romamo Decision needed: the security fix for #174 changes how the inventory file that cloudfall-engine inventory render writes looks; is that change all right?

Every string in that JSON file becomes {"__ansible_unsafe": "..."} instead of "...", so Ansible treats it as data and never runs a {{ ... }} inside it. Ansible, ansible-inventory --list and every Cloudfall playbook read it as before; a tool outside Ansible that parses the file itself would see the wrapper.

  1. Keep the wrapper on every string (recommended): the fix covers every declared value and every playbook at once, including the command health check Gate a deploy on the command health check and receipt it (S-009-2..9, 11, 12) #164 adds
  2. Wrap only the values a person declares (commands, environment, routes, and the like), leaving names, addresses and paths Cloudfall builds as plain strings: a smaller visible change, but each new declared field must remember the wrapper, or it reopens the hole
  3. Refuse {{ and {% in declared values instead: the file stays as it is, and a value such as docker inspect --format {{.State.Health.Status}} app can't be declared

Reply here with a number or your own answer; shipmill takes this up on the tick after your reply. Nothing on this PR merges until then.

@shipmill-romamo shipmill-romamo Bot added the needs-decision A shipmill session asked a question here; waits for a reply label Oct 30, 2026
The engine reads the rendered inventory itself to stage the owner role
passwords services.yml sets. With every string marked unsafe, serviceKind
read as a mapping, so no owned database was found: no password was staged
and a missing secret no longer stopped the run before Ansible (S-006-6,
S-006-7). Read marked strings back as text with a shared object_hook, and
build the test inventory the way inventory render writes it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@shipmill-romamo

Copy link
Copy Markdown
Contributor Author

Decision by @romamo, relayed by Claude Code

  1. Keep the wrapper on every string.

@shipmill-romamo shipmill-romamo Bot removed the needs-decision A shipmill session asked a question here; waits for a reply label Oct 31, 2026
@shipmill-romamo
shipmill-romamo Bot merged commit a98c79f into main Oct 31, 2026
1 check passed
@shipmill-romamo
shipmill-romamo Bot deleted the fix/174-no-templating-config-values branch October 31, 2026 07:42
shipmill-romamo Bot added a commit that referenced this pull request Oct 31, 2026
#181) (#189)

An approved operation's inputs reached ansible-playbook as plain
--extra-vars JSON, and Ansible templates extra vars: an input holding
{{ lookup('pipe', ...) }} ran on the management host with the
operator's rights, and an honest input holding {{ broke the run.

run_command now marks every input string unsafe with as_ansible_data,
as #179 did for the rendered inventory; integers and booleans keep
their types. The replay reads the argv back with object_hook=unmarked,
so its key matches the unmarked inputs a recording keeps, from before
this change and after. The decision record's inputs are unchanged.

Fixes #181

Co-authored-by: shipmill-romamo[bot] <338557544+shipmill-romamo[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A component's command values are Ansible-templated on the management host

0 participants