Skip to content

fix(apply): every engine validates the contract before planning - #714

Merged
fas89 merged 6 commits into
mainfrom
fix/apply-validates-every-engine
Oct 12, 2026
Merged

fas89 merged 6 commits into
mainfrom
fix/apply-validates-every-engine

Conversation

@fas89

@fas89 fas89 commented Oct 12, 2026 •

Copy link
Copy Markdown
Collaborator

Description

fluid apply never schema-validated the contract on the OpenTofu engine, which is every cloud provider's default (aws, gcp, snowflake, confluent, plugin clouds). cli/apply.py::run returned early for apply_engine == "opentofu" while both _gate_contract_for_apply call sites sat below it. fluid plan and local apply did validate, so only the cloud apply path was open. An out-of-enum orchestration.engine (or a pre-0.7 contract) reached planning.

  • The schema/version gate now runs once, before the engine branch, for every engine and for the build-augmented modes (amend-and-build, replace-and-build, dry-run).
  • It raises the same typed CLIError events as the native path: apply_contract_invalid, contract_version_unsupported. There is no new flag and no escape hatch.
  • A plan.json with no embedded contract is refused instead of falling back to the native engine and exiting 0.
  • Every engine applies the copy that was checked; the OpenTofu engine and the native build runner no longer re-read the file after the gate.
  • Upgrade note in CHANGELOG.md: cloud and build-augmented applies of a schema-invalid contract now stop before planning.

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • Breaking change: contracts that were never schema-valid used to reach planning on the cloud path and now stop with apply_contract_invalid.

Documentation

Checklist

  • Tests added (test_apply_schema_gate_every_engine.py, plus apply hook tests driven through the real top-level parser, for both YAML and plan.json inputs)
  • ruff check, black (24.10.0), import-linter and the mypy strict hotspots are clean
  • Full suite green on Python 3.10–3.14 (plus perf, coverage gates and build smoke on 3.12), run as part of an integration branch with the other in-flight fixes

Testing

Real fluid CLI in fc-test:3.12 (no network, fake HOME, no AWS credentials, tofu absent), origin/main vs this branch:

Input Before After
aws contract with orchestration.engine: not-a-real-engine reaches the OpenTofu engine; fails only on opentofu_engine_no_tofu apply_contract_invalid, exit 1, never reaches the engine
valid aws contract reaches the engine passes the gate and reaches the engine (unchanged)
pre-0.7 contract reaches the engine contract_version_unsupported, exit 1
plan.json embedding the invalid contract, --mode amend reaches the engine apply_contract_invalid, exit 1
plan.json with no embedded contract falls back to native, exit 0 refused
invalid local contract, --mode amend-and-build builds, writes out/rows.parquet, exit 0 apply_contract_invalid, exit 1

fas89 added 6 commits October 12, 2026 19:21
…tract before planning

`fluid apply` ran its pre-apply contract gate (pre-0.7 rejection plus
JSON-schema validation, the check `fluid plan` and `fluid validate` run)
only on the native engine's non-build path. Two early returns sat above
both call sites: the OpenTofu dispatch, which every aws / gcp / snowflake /
confluent and IaC plugin cloud takes, and the `needs_build` return for
`--mode amend-and-build` / `replace-and-build`. A contract with an
out-of-enum `orchestration.engine`, unknown keys or an end-of-life
fluidVersion reached `tofu` or the build runner and could exit 0.

The gate now runs once per apply, after the plan-binding and data-loss
gates and before engine dispatch, the same placement the data-loss gate
already uses for the same reason. A plan.json is checked on its
digest-attested embedded contract; a YAML source is loaded once and the
native path applies exactly the contract that was validated. The events
are unchanged (`apply_contract_invalid`, `contract_version_unsupported`),
there is no new flag, and a plan with no embedded contract is still the
engine's error (`opentofu_engine_no_contract`).

Three existing test fixtures only passed because the gap existed; two are
now schema-valid, and the Command Center report tests, whose fixture
carries a top-level `version` the run report reads but the schema does
not allow, stand the gate down explicitly.

Bug fix inside existing code: reuses `_gate_contract_for_apply`, adds no
validation logic.
…an upgrade note

docs/apply.md gains a "Contract validation" section: where the gate runs,
its two events and exit code, the real output for an aws contract with an
out-of-enum orchestration.engine, and what is checked for a plan.json.

CHANGELOG [Unreleased] Upgrade notes: cloud applies and build-augmented
modes of a contract that was never schema-valid now stop before planning;
run `fluid validate --env`, fix, apply again. No flag skips the check.
…evel parser

Parametrises the refusal cases (schema-invalid, pre-0.7) across modes and both engines with shared helpers, and parses through fluid_build.cli.build_parser instead of a hand-built subparser copy.
…efusal

One writer produces either input form, so each refusal (schema-invalid, pre-0.7) is exercised as a YAML file and as a digest-bound plan.json on both engines and every mode, and the shared refusal helper asserts the guarded engine or build runner never ran.
… applies the copy that was checked

Two gaps in the schema gate this branch hoisted above the engine dispatch:

- A plan.json whose `contract` was missing, empty or not an object returned
  unvalidated. resolve_apply_engine could not classify it and fell back to
  the native engine, which ran the plan's actions through
  build_provider("aws").apply and exited 0. planDigest is an unkeyed
  sha256, so it proves the file is unchanged, not who wrote it. Such a plan
  now stops with apply_plan_missing_contract (exit 1) on every engine, with
  or without --no-verify-plan-binding, mirroring the build runner's
  plan_missing_contract and the OpenTofu engine's opentofu_engine_no_contract.

- The gate validated one copy and the consumers re-read the file:
  resolve_apply_engine, the OpenTofu hooks, apply_via_opentofu and
  run_builds_from_args each loaded args.contract again, so a file swapped
  after the gate reached tofu or the build runner unvalidated. run() now
  loads the source once (_load_apply_source) and passes that dict to the
  engine choice, the gate, the hooks, apply_via_opentofu(contract=...) and
  run_builds_from_args(contract=...). The engine still resolves
  {{ env.* }} placeholders, on a copy.

A contract that fails to load also logs its traceback under --debug again,
as the native try block did before the load moved out of it.

The native provider-detection unit tests now pin the native engine: they
reached it only because the engine was chosen from a re-read of the real
local fixture while the native path applied the mocked aws/gcp contract.
…ed copy is the applied copy

docs/apply.md said the OpenTofu engine refuses a plan with no embedded
contract (opentofu_engine_no_contract). It never reached that engine: it fell
back to the native one and applied unchecked. Document
apply_plan_missing_contract, and that the engine choice, hooks, tofu, the
build runner and the native provider all take the one copy that was checked.
The Upgrade-notes bullet says the same.
@github-actions github-actions Bot added cli Changes to the CLI surface or implementation docs Documentation changes tests Test coverage or test infrastructure changes labels Oct 12, 2026
@fas89
fas89 merged commit a5c25a6 into main Oct 12, 2026
37 checks passed
@fas89
fas89 deleted the fix/apply-validates-every-engine branch October 12, 2026 20:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cli Changes to the CLI surface or implementation docs Documentation changes tests Test coverage or test infrastructure changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant