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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ Generated pipelines therefore compute ONE effective mode for stages 6 and 7: `AP

### Environment and anchoring across stages

- `fluid bundle --env <env>` freezes the overlay-applied contract and records `source: {contract, env, overlay}` in `MANIFEST.json` (outside the merkle root; `contract` is relative to the bundle's directory and is only trusted when, with the recorded env's overlay applied, it declares the bundled contract's `id`, so an overlay may rename the product). Every later stage given `--env` on a bundle refuses a different env with `bundle_env_mismatch` (a bundle built without `--env` is accepted for `--env dev` when no dev overlay exists); `fluid validate` reports it as a `BUNDLE-ENV-MISMATCH` finding. `fluid generate artifacts` takes `--env` too, and on a raw contract materialises it with the same code as `fluid bundle --env`.
- `fluid bundle --env <env>` freezes the overlay-applied contract and records `source: {contract, env, overlay}` in `MANIFEST.json`, plus `overlayWarnings` when the merge raised a warning `--strict` fails on (outside the merkle root; `contract` is relative to the bundle's directory and is only trusted when, with the recorded env's overlay applied, it declares the bundled contract's `id`, so an overlay may rename the product). Every later stage given `--env` on a bundle refuses a different env with `bundle_env_mismatch` (a bundle built without `--env` is accepted for `--env dev` when no dev overlay exists); `fluid validate` reports it as a `BUNDLE-ENV-MISMATCH` finding, and each recorded overlay warning as an `OVERLAY-WARNING` one. `fluid generate artifacts` takes `--env` too, and on a raw contract materialises it with the same code as `fluid bundle --env`.
- A relative `exposes[].binding.location.path` resolves against the SOURCE contract's directory in every writer and reader (build runners, the local provider, `fluid verify`), whatever directory the command runs from. For a bundle or a bundle-made plan that is the contract the MANIFEST records (`plan.json` carries it as `contract_metadata.source_contract`).
- `--env` naming no overlay logs a WARNING (`overlay_not_found`, listing the overlays that exist); `dev` with none is the base contract (INFO).
- `fluid apply --state-backend` defaults from `FLUID_STATE_BACKEND`; an explicit empty `--state-backend ""` forces local state. A `FLUID_STATE_BACKEND` that names only a bucket (`s3://bucket`, `gcs://bucket`) keys every contract's state apart (S3 key `fluid/<id>/terraform.tfstate`, GCS prefix `fluid/<id>`, the id exactly as written), packaging block or not, because one CI job applies every product with it. Not `safe_ident(id)`: it folds `.` and `-` into `_`, so distinct valid ids would share a state; an id outside the FLUID identifier grammar is refused (`apply_state_backend_invalid`). The flag keeps its old default: `fluid/<safe_ident(id)>/terraform.tfstate` for a contract with a `packaging` block, the shared `fluid/terraform.tfstate` otherwise. A key or prefix in either one is used as given. The apply output's `state:` line prints the resolved object (`remote: s3://bucket/key (from FLUID_STATE_BACKEND)`), and given back as `--state-backend` that value selects the same state. A bucket name outside `[A-Za-z0-9._-]` is refused without being echoed. Generated pipelines never pass `--state-backend` (a test holds them to it), so the variable's per-contract keys apply.
Expand Down
16 changes: 13 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
as an "Environment overlay" row, `fluid validate --quiet` as a `WARNING:` line), and the
loader no longer also logs it there. Put the overlay's entries in the base's order, or
set `fluidVersion: "0.7.6"` with a unique id on every base entry, so overlays patch by
id. `overlay_entry_added` stays a log line and does not fail `--strict`. `fluid validate
<bundle>.tgz` does not see the warning: `fluid bundle --env` merged the overlay before,
so validate the contract file with `--env`.
id. `overlay_entry_added` stays a log line and does not fail `--strict`. A bundle is
never re-overlaid, so `fluid bundle --env --format tgz` records the warning in the
bundle's MANIFEST (`source.overlayWarnings`) and says how many it recorded; `fluid
validate <bundle>.tgz` lists it, and under `--strict`, which stage 2 of the generated
pipelines runs, fails on it. A bundle built before this change records none and passes
as before: rebuild it to have it checked. `fluid contract-validation` and `fluid test`
do not read the record, so on a bundle they miss the warning: run them on the contract
file with `--env`.
- `fluid validate --strict` now says `Validation failed: warnings treated as errors
(--strict)` when warnings alone fail it, after a summary that reads valid; with
`--quiet` it prints each such warning as `ERROR (--strict): <warning>`.
- `fluid validate <bundle>.tgz` now prints what it prints for a contract file: a bundle
that passes lists its warnings, not only their count; `--quiet`, which printed nothing,
prints each error as `ERROR: <finding>`, a recorded overlay warning as `WARNING:
<finding>`, and under `--strict` each warning as `ERROR (--strict): <finding>`; and
`--strict` failing on warnings alone says so, as above.
- A product whose every build becomes `continuous` (or runs outside forge-cli) gets no
`schedule/` artifacts, so pipeline stage 11 skips `fluid schedule-sync`, and a DAG
deployed for it earlier stays in the scheduler: remove that DAG by hand.
Expand Down
35 changes: 27 additions & 8 deletions fluid_build/cli/bundle.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
except Exception: # pragma: no cover
yaml = None

from ..loader import RefResolutionError, compile_contract, load_with_overlay
from ..loader import RefResolutionError, collect_overlay_warnings, compile_contract
from ..observability.tracing import traced_stage

COMMAND = "bundle"
Expand Down Expand Up @@ -386,10 +386,14 @@ def run(args: argparse.Namespace, logger: logging.Logger) -> int:
# overlay on top when requested, with the same loud notice as every
# other stage when ``--env`` names an overlay that does not exist: a
# bundle is the root of trust for stages 2-9, so a silently
# un-overlaid bundle poisons every one of them.
compiled, overlay_path = materialize_contract(
contract_path, env, logger, compile_logger=_compile_logger
)
# un-overlaid bundle poisons every one of them. A positional overlay
# patch that lands on another entry is collected, not only logged: a
# bundle is never re-overlaid, so the tgz MANIFEST records it for
# ``fluid validate <bundle> --strict`` (pipeline stage 2) to fail on.
with collect_overlay_warnings() as overlay_warnings:
compiled, overlay_path = materialize_contract(
contract_path, env, logger, compile_logger=_compile_logger
)

except FileNotFoundError as e:
sys.stderr.write(f"❌ File not found: {e}\n")
Expand All @@ -401,6 +405,10 @@ def run(args: argparse.Namespace, logger: logging.Logger) -> int:
sys.stderr.write(f"❌ Compilation failed: {e}\n")
return 1

# The loader logs a warning the block took at DEBUG only: say it here, once.
for warning in overlay_warnings:
logger.warning("%s", warning.message, extra={"event": warning.event})

# Feedback: tell the user if the contract had nothing to bundle
# (suppressed for --format tgz — the tgz has fragments regardless of refs)
if not has_refs and not env and fmt != "tgz":
Expand Down Expand Up @@ -436,10 +444,16 @@ def run(args: argparse.Namespace, logger: logging.Logger) -> int:
# anchored at the contract's directory, as it is when the contract
# itself is the input) and which environment was applied (so
# ``plan`` / ``apply`` / ``verify --env X`` can refuse a bundle built
# for a different one). Recorded in MANIFEST.json, outside the
# merkle root: the digest still names the contract alone.
# for a different one), with the overlay warnings the merge raised (so
# ``fluid validate <bundle> --strict`` fails on them). Recorded in
# MANIFEST.json, outside the merkle root: the digest still names the
# contract alone.
source = make_bundle_source(
Path(contract_path), Path(out), env=env, overlay_path=overlay_path
Path(contract_path),
Path(out),
env=env,
overlay_path=overlay_path,
overlay_warnings=[warning.message for warning in overlay_warnings],
)
try:
digest = build_bundle_tgz(
Expand All @@ -457,6 +471,11 @@ def run(args: argparse.Namespace, logger: logging.Logger) -> int:
if env:
applied = source.get("overlay") or "none (base contract)"
sys.stderr.write(f" env: {env} (overlay: {applied})\n")
if overlay_warnings:
sys.stderr.write(
f" overlay warnings: {len(overlay_warnings)} recorded in the bundle; "
"`fluid validate --strict` fails on it\n"
)

# ── Optional: Sigstore cosign keyless signing ─────────────────
# Runs after tgz emission so the digest is already stable. A
Expand Down
110 changes: 90 additions & 20 deletions fluid_build/cli/validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -1206,9 +1206,11 @@ def _cprint_literal(line: str) -> None:
Messages are data, not markup, so ``markup=False``, as in
:func:`_cprint_json`. The redaction pass in ``cprint`` still runs.
``soft_wrap`` leaves wrapping to the terminal, so Rich never breaks a line
inside an identifier or a copy-pasteable command.
inside an identifier or a copy-pasteable command. ``highlight=False``, as
in :func:`_cprint_json`: Rich's highlighter takes time quadratic in a run
of ``<`` (4 s for 10,000), and a bundle's MANIFEST can carry one.
"""
cprint(line, markup=False, soft_wrap=True)
cprint(line, markup=False, soft_wrap=True, highlight=False)


def _output_json_results(result: ValidationResult, args) -> int:
Expand Down Expand Up @@ -1274,20 +1276,20 @@ def _output_text_results(result: ValidationResult, args, logger: logging.Logger)
if has_errors or treat_warnings_as_errors:
# Under --quiet the warnings were not listed, errors or not.
if treat_warnings_as_errors and (args.quiet or not has_errors):
_explain_strict_failure(result, args)
_explain_strict_failure(result.warnings, args.quiet)
return 1
else:
return 0


def _explain_strict_failure(result: ValidationResult, args: Any) -> None:
def _explain_strict_failure(warnings: List[str], quiet: bool) -> None:
"""Say why ``--strict`` exits 1: after a summary that reads valid, or under ``--quiet``.

``--quiet`` prints errors only, so there each warning is printed as the
error ``--strict`` made it: a count alone does not say what to fix.
"""
if args.quiet:
for warning in result.warnings:
if quiet:
for warning in warnings:
_cprint_literal(f"ERROR (--strict): {warning}")
else:
cprint("❌ Validation failed: warnings treated as errors (--strict)")
Expand Down Expand Up @@ -1506,13 +1508,15 @@ def _run_bundle_validation(
report.issues.insert(0, env_issue)
report.status = "fail"

# Contract-level rules (sovereignty, agent policy, binding prerequisites,
# The overlay warnings ``fluid bundle --env`` recorded, then the
# contract-level rules (sovereignty, agent policy, binding prerequisites,
# packaging, semantics, composition, plugin validators) on the resolved
# contract the bundle carries — the same rules the contract path runs.
# contract the bundle carries — what the contract path reports.
# Skipped when the MANIFEST gate already failed: there is no trustworthy
# contract to run them on, and the tamper finding is the answer.
manifest_ok = not any(i.code == "MANIFEST-TAMPER" for i in report.issues)
if manifest_ok and not (fail_fast and report.status == "fail"):
report.issues.extend(_bundle_overlay_issues(tgz_path))
report.issues.extend(_bundle_contract_rule_issues(tgz_path, args, schema_manager, logger))
has_error = any(i.severity == "error" for i in report.issues)
has_warning = any(i.severity == "warning" for i in report.issues)
Expand All @@ -1525,7 +1529,9 @@ def _run_bundle_validation(

if out_format == "json":
_cprint_json(json.dumps(report.to_dict(), indent=2))
elif not quiet:
elif quiet:
_print_quiet_bundle_issues(report.issues, strict)
else:
status_icon = "✅" if report.status == "pass" else "❌"
cprint(f"{status_icon} Bundle {report.status}: {tgz_path}")
cprint(f" digest: {report.bundle_digest}")
Expand All @@ -1535,17 +1541,14 @@ def _run_bundle_validation(
f"({s.get('error', 0)} error, {s.get('warning', 0)} warning, "
f"{s.get('info', 0)} info)"
)
if verbose or report.status == "fail":
for issue in report.issues:
loc = ""
if issue.line is not None:
loc = f" (L{issue.line}"
if issue.column is not None:
loc += f":C{issue.column}"
loc += ")"
_cprint_literal(
f" [{issue.severity}] {issue.validator}: {issue.file}{loc}: {issue.message}"
)
# A bundle that passes still lists its warnings, as a contract file does.
for issue in report.issues:
if verbose or report.status == "fail" or issue.severity == "warning":
_cprint_literal(f" [{issue.severity}] {_bundle_issue_text(issue)}")
if strict and not any(issue.severity == "error" for issue in report.issues):
warnings = [_bundle_issue_text(i) for i in report.issues if i.severity == "warning"]
if warnings:
_explain_strict_failure(warnings, quiet=False)

# Structured report file — atomic write.
if report_path:
Expand All @@ -1564,6 +1567,73 @@ def _run_bundle_validation(
return 0 if report.status == "pass" else 1


#: The report code of an overlay warning ``fluid bundle --env`` recorded.
_OVERLAY_WARNING_CODE = "OVERLAY-WARNING"


def _bundle_issue_text(issue: Any) -> str:
"""One bundle finding as the report lists it: ``validator: file (Lline:Ccol): message``."""
loc = ""
if issue.line is not None:
loc = f" (L{issue.line}"
if issue.column is not None:
loc += f":C{issue.column}"
loc += ")"
return f"{issue.validator}: {issue.file}{loc}: {issue.message}"


def _print_quiet_bundle_issues(issues: List[Any], strict: bool) -> None:
"""``--quiet`` on a bundle: what it prints for a contract file.

Each error; under ``--strict`` each warning, as the error it made
(:func:`_explain_strict_failure`); without it, the overlay warnings
``fluid bundle --env`` recorded, which ``fluid validate <contract> --env
--quiet`` shows too (:func:`_print_quiet_overlay_warnings`).
"""
for issue in issues:
if issue.severity == "error":
_cprint_literal(f"ERROR: {_bundle_issue_text(issue)}")
warnings = [issue for issue in issues if issue.severity == "warning"]
if strict:
_explain_strict_failure([_bundle_issue_text(issue) for issue in warnings], quiet=True)
return
for issue in warnings:
if issue.code == _OVERLAY_WARNING_CODE:
_cprint_literal(f"WARNING: {_bundle_issue_text(issue)}")


def _bundle_overlay_issues(tgz_path: Path) -> list:
"""The overlay warnings ``fluid bundle --env`` recorded, as report findings.

A bundle is never re-overlaid, so validating it cannot see a positional
overlay patch the merge landed on another entry: ``fluid bundle`` records
the warning in the MANIFEST ``source`` block, and it is reported here as a
warning, as ``fluid validate <contract> --env`` reports it, so
``--strict`` (pipeline stage 2) fails on it. A bundle that records none,
one built before the record existed included, has none to report.
"""
import tarfile

from fluid_build.forge.core.bundle import bundle_overlay_warnings, read_bundle_source
from fluid_build.forge.core.validators import ValidationIssue

try:
source = read_bundle_source(tgz_path)
except (tarfile.TarError, OSError, ValueError):
# Unreadable: the MANIFEST gate before this reports that bundle.
return []
return [
ValidationIssue(
file="MANIFEST.json",
validator="overlay",
severity="warning",
message=warning,
code=_OVERLAY_WARNING_CODE,
)
for warning in bundle_overlay_warnings(source)
]


def _bundle_env_issue(tgz_path: Path, env: Optional[str], logger: logging.Logger):
"""``--env`` against the env the bundle was built for, as a report finding.

Expand Down
Loading
Loading