From bfbfb040f7134767eaa90356b88ac2cce6ba5478 Mon Sep 17 00:00:00 2001 From: Simon Halvorsen Date: Fri, 2 Oct 2026 09:44:45 +0200 Subject: [PATCH] Added promise-type `assert` Created as part of ENT-14444: `cfengine test` Adds assertions to cfengine policy returning `promise KEPT/FAILED` on assertion-failure, logging `[ASSERT] Promiser: PASS/FAIL` to [INFO/ERROR] respectively Ticket: ENT-14444 Signed-off-by: Simon Halvorsen --- cfbs.json | 9 + promise-types/assert/README.md | 121 ++++++++ promise-types/assert/assert_promise_type.py | 325 ++++++++++++++++++++ promise-types/assert/enable.cf | 6 + promise-types/assert/example.cf | 96 ++++++ 5 files changed, 557 insertions(+) create mode 100644 promise-types/assert/README.md create mode 100644 promise-types/assert/assert_promise_type.py create mode 100644 promise-types/assert/enable.cf create mode 100644 promise-types/assert/example.cf diff --git a/cfbs.json b/cfbs.json index ea526b5..7646c72 100644 --- a/cfbs.json +++ b/cfbs.json @@ -545,6 +545,15 @@ "append init.cf services/init.cf" ] }, + "promise-type-assert": { + "description": "Promise type to add assertions to policy.", + "subdirectory": "promise-types/assert", + "dependencies": ["library-for-promise-types-in-python"], + "steps": [ + "copy assert_promise_type.py modules/promises/", + "append enable.cf services/init.cf" + ] + }, "promise-type-git": { "description": "Promise type to manage git repos.", "subdirectory": "promise-types/git", diff --git a/promise-types/assert/README.md b/promise-types/assert/README.md new file mode 100644 index 0000000..854703c --- /dev/null +++ b/promise-types/assert/README.md @@ -0,0 +1,121 @@ +Promise type for asserting facts about system state. + +Unlike other promise types, `assert:` promises never change anything. Evaluation only checks whether something is already true, and reports `PASS` or `FAIL` accordingly, with a comparison of what was expected against what was actually found. + +## Promiser + +A short, descriptive name for the assertion, e.g. `"apache is listening on port 80"`. Used for logging each assertion. + +## Attributes + +Each `assert:` promise needs exactly one subject attribute, and at least one check attribute (except `command`, which can stand alone and only checks that the command exits zero) -- or no subject at all, see [No subject: gating on `if`/`unless`](#no-subject-gating-on-ifunless). + +| Subject | Type | Checks | Description | +| --------- | ---------------- | ------------------------------------------ | --------------------------------------- | +| `int` | `string` | `equals`, `greater_than`, `less_than` | A whole number | +| `real` | `string` | `equals`, `greater_than`, `less_than` | A number, whole or fractional | +| `str` | `string` | `equals`, `not_equals`, `contains` | A string | +| `file` | `string` | `exists`, `perms`, `contents` | Path to a file | +| `dir` | `string` | `exists` | Path to a directory | +| `command` | `string` | `output` | A shell command, run to check its result| +| `slist` | `slist` | `equals`, `contains` | A list of strings | +| `ilist` | `ilist` | `equals`, `contains` | A list of whole numbers | +| `rlist` | `rlist` | `equals`, `contains` | A list of numbers | + +| Check | Type | Applies to | Description | +| -------------- | -------- | ---------------------------------- | ------------------------------------------------------ | +| `equals` | * | `int`, `real`, `str`, `*list` | Subject equals this value | +| `not_equals` | `string` | `str` | Subject does not equal this value | +| `greater_than` | `string` | `int`, `real` | Subject is greater than this value | +| `less_than` | `string` | `int`, `real` | Subject is less than this value | +| `contains` | * | `str`, `*list` | Subject contains this substring/element, or (for `*list`) all elements of this list | +| `exists` | `string` | `file`, `dir` | `"true"` or `"false"`: whether the path exists | +| `perms` | `string` | `file` | Permission bits the file must have, e.g. `"0644"` | +| `contents` | `string` | `file` | The file's exact contents | +| `output` | `string` | `command` | Substring the command's combined stdout+stderr must contain | + +Giving `equals` for an `*list` subject requires the matching list type of the same shape (`@(variable)` or an inline `{ ... }`), not a bare scalar cf-agent doesn't type-check custom promise attributes, so a wrong-typed value could otherwise misbehave silently instead of failing clearly. + +### Numeric comparisons + +`int`/`real` values and `ilist`/`rlist` elements are parsed as floats first, since CFEngine's own numeric functions (e.g. `eval()`) return whole numbers as strings like `"7.000000"`; `int`/`ilist` additionally reject a non-whole value and convert it to an int. This means `ilist`/`rlist` elements compare numerically, not as raw strings, so `"2"` and `"2.000000"` are the same value; `slist` elements compare as-is. + +### List `contains` + +For an `*list` subject, `contains` takes either a single value (checks for that one element) or a list (checks that every element of it is present). A failing list check reports only the missing element(s). + +## No subject: gating on `if`/`unless` + +A promise with no subject attribute passes by default -- useful for asserting a class or variable directly with CFEngine's own `if`/`unless`, rather than one of the subjects above. `pass => "false"` inverts it. + +Since `if`/`unless` make cf-agent skip the promise entirely when unmet, this only reports a result when the condition *is* met -- there's no signal for the unmet case: + +```cfengine3 +assert: + "sum is defined" + if => isvariable("sum"); # passes, skipped if sum isn't defined + + "my_class must not be set" + pass => "false", + if => "my_class"; # fails only if my_class is set +``` + +## Example + +```cfengine3 +bundle agent main +{ + vars: + "ports" ilist => { "22", "80", "443" }; + + assert: + "agent version is current" + int => "$(sys.cf_version_major)", + equals => "3"; + + "port 80 open" + ilist => "@(ports)", + contains => "80"; + + "expected ports are open" + ilist => "@(ports)", + contains => {22, 80, 9090}; + + "config file exists" + file => "/etc/myapp/config.yaml", + exists => "true"; + + "config file has the right owner-only permissions" + file => "/etc/myapp/config.yaml", + perms => "0600"; + + "service responds on its health check" + command => "curl -fs http://localhost:8080/health", + output => "ok"; +} +``` + +A failing assertion reports what was expected and what was actually found, e.g.: + +``` +FAIL expected ports are open # [22, 80, 443] does not contain [9090] +``` + +## Limitations + +- `command`'s `output` check matches a substring against the command's combined stdout and stderr; it doesn't support matching stdout and stderr separately, or exact-match semantics. +- `contains` has no "any of" mode for lists, only "one of" (a single value) or "all of" (a list). + +## Authors + +This software was created by the team at [Northern.tech](https://northern.tech), with many contributions from the community. +Thanks everyone! + +## Contribute + +Feel free to open pull requests to expand this documentation, add features, or fix problems. +You can also pick up an existing task or file an issue in [our bug tracker](https://northerntech.atlassian.net/). + +## License + +This software is licensed under the MIT License. See LICENSE in the root of the repository for the full license text. diff --git a/promise-types/assert/assert_promise_type.py b/promise-types/assert/assert_promise_type.py new file mode 100644 index 0000000..368b512 --- /dev/null +++ b/promise-types/assert/assert_promise_type.py @@ -0,0 +1,325 @@ +#!/usr/bin/env python3 +import difflib +import os +import subprocess + +from cfengine_module_library import PromiseModule, ValidationError, Result + +SUBJECT_ATTRIBUTES = { + "int", + "real", + "str", + "file", + "dir", + "command", + "slist", + "ilist", + "rlist", +} +CHECKS_BY_SUBJECT = { + "int": {"equals", "greater_than", "less_than"}, + "real": {"equals", "greater_than", "less_than"}, + "str": {"equals", "not_equals", "contains"}, + "file": {"exists", "perms", "contents"}, + "dir": {"exists"}, + "command": {"output"}, + "slist": {"equals", "contains"}, + "ilist": {"equals", "contains"}, + "rlist": {"equals", "contains"}, +} +BOOLEAN_ATTRIBUTES = {"exists"} +LIST_SUBJECTS = {"slist", "ilist", "rlist"} + + +class CheckFailed(Exception): + pass + + +class AssertModule(PromiseModule): + def __init__(self): + super().__init__("assert_promise_module", "0.1.0") + + def validate_promise(self, promiser, attributes, metadata): + subjects_given = sorted(SUBJECT_ATTRIBUTES.intersection(attributes)) + + if len(subjects_given) > 1: + raise ValidationError( + "Each assert: promise needs at most one of {}, got {}".format( + sorted(SUBJECT_ATTRIBUTES), subjects_given + ) + ) + + if not subjects_given: + # No subject -- this promise exists to be gated by `if`/`unless` + # (cf-agent only sends it to us at all when that condition + # holds), so reaching here should pass by default, unless told + # otherwise with `pass => "false"`. + if "pass" in attributes and attributes["pass"] not in ("true", "false"): + raise ValidationError( + "'pass' must be 'true' or 'false', got '{}'".format( + attributes["pass"] + ) + ) + unknown_attributes = set(attributes) - {"pass"} + if unknown_attributes: + raise ValidationError( + "A subjectless assert: promise only takes 'pass', got {}".format( + sorted(unknown_attributes) + ) + ) + return + + if "pass" in attributes: + raise ValidationError( + "'pass' can't be combined with a subject ('{}')".format( + subjects_given[0] + ) + ) + + subject = subjects_given[0] + allowed_checks = CHECKS_BY_SUBJECT[subject] + + unknown_attributes = set(attributes) - {subject} - allowed_checks + if unknown_attributes: + raise ValidationError( + "'{}' doesn't take {}".format(subject, sorted(unknown_attributes)) + ) + + checks_given = allowed_checks.intersection(attributes) + if subject != "command" and not checks_given: + raise ValidationError( + "'{}' needs at least one of {}".format(subject, sorted(allowed_checks)) + ) + + for name in checks_given.intersection(BOOLEAN_ATTRIBUTES): + if attributes[name] not in ("true", "false"): + raise ValidationError( + "'{}' must be 'true' or 'false', got '{}'".format( + name, attributes[name] + ) + ) + + # cf-agent doesn't type-check custom promise attributes itself, so a + # bare scalar here (e.g. "$(x)" instead of "@(x)") would silently + # misbehave rather than fail -- Python's `in` iterates a str's + # characters, so a wrong-typed slist could even give a false PASS. + if subject in LIST_SUBJECTS: + for name in (subject, "equals"): + if name in attributes and not isinstance(attributes[name], list): + raise ValidationError( + "'{}' must be a list, got {!r}".format(name, attributes[name]) + ) + + def evaluate_promise(self, promiser, attributes, metadata): + if not SUBJECT_ATTRIBUTES.intersection(attributes): + if attributes.get("pass", "true") == "true": + self.log_info("[ASSERT] PASS {}".format(promiser)) + return Result.KEPT + self.log_error("[ASSERT] FAIL {} # pass => false".format(promiser)) + return Result.NOT_KEPT + try: + check_subject(attributes) + except CheckFailed as failure: + self.log_error("[ASSERT] FAIL {} # {}".format(promiser, failure)) + return Result.NOT_KEPT + self.log_info("[ASSERT] PASS {}".format(promiser)) + return Result.KEPT + + +def check_subject(attributes): + if "int" in attributes: + check_number(attributes, "int") + elif "real" in attributes: + check_number(attributes, "real") + elif "str" in attributes: + check_string(attributes) + elif "file" in attributes: + check_file(attributes) + elif "dir" in attributes: + check_directory(attributes) + elif "command" in attributes: + check_command(attributes) + elif "slist" in attributes: + check_list(attributes, "slist") + elif "ilist" in attributes: + check_list(attributes, "ilist") + elif "rlist" in attributes: + check_list(attributes, "rlist") + + +def parse_number(raw_value, subject_name): + # CFEngine's own numeric functions (e.g. eval()) return whole numbers as + # strings like "7.000000", so this always parses as a float first; "int" + # additionally rejects a non-whole result and is then converted to an + # actual int, so it compares and prints as "7", not "7.0". + try: + value = float(raw_value) + except ValueError: + raise CheckFailed("'{}' is not a valid {}".format(raw_value, subject_name)) + if subject_name != "int": + return value + if not value.is_integer(): + raise CheckFailed("'{}' is not a valid int".format(raw_value)) + return int(value) + + +def check_number(attributes, subject_name): + actual = parse_number(attributes[subject_name], subject_name) + + if "equals" in attributes: + expected = parse_number(attributes["equals"], subject_name) + if actual != expected: + raise CheckFailed("expected {}, got {}".format(expected, actual)) + + if "greater_than" in attributes: + threshold = parse_number(attributes["greater_than"], subject_name) + if not actual > threshold: + raise CheckFailed("{} is not greater than {}".format(actual, threshold)) + + if "less_than" in attributes: + threshold = parse_number(attributes["less_than"], subject_name) + if not actual < threshold: + raise CheckFailed("{} is not less than {}".format(actual, threshold)) + + +def check_string(attributes): + actual = attributes["str"] + + if "equals" in attributes and actual != attributes["equals"]: + raise CheckFailed( + "expected {!r}, got {!r}".format(attributes["equals"], actual) + ) + + if "not_equals" in attributes and actual == attributes["not_equals"]: + raise CheckFailed( + "expected not {!r}, but got it".format(attributes["not_equals"]) + ) + + if "contains" in attributes and attributes["contains"] not in actual: + raise CheckFailed( + "{!r} does not contain {!r}".format(actual, attributes["contains"]) + ) + + +def check_file(attributes): + path = attributes["file"] + + if "exists" in attributes: + should_exist = attributes["exists"] == "true" + actually_exists = os.path.isfile(path) + if actually_exists != should_exist: + raise CheckFailed( + "expected exists={}, but exists={}: {}".format( + should_exist, actually_exists, path + ) + ) + + if ("perms" in attributes or "contents" in attributes) and not os.path.isfile(path): + raise CheckFailed("'{}' does not exist".format(path)) + + if "perms" in attributes: + expected_perms = attributes["perms"].lstrip("0") or "0" + try: + actual_perms = oct(os.stat(path).st_mode & 0o7777)[2:] + except OSError as error: + raise CheckFailed(str(error)) + if actual_perms != expected_perms: + raise CheckFailed( + "{} has permissions {}, expected {}".format( + path, actual_perms, expected_perms + ) + ) + + if "contents" in attributes: + try: + with open(path) as file: + actual_contents = file.read() + except OSError as error: + raise CheckFailed(str(error)) + expected_contents = attributes["contents"] + if actual_contents != expected_contents: + diff = "\n".join( + difflib.unified_diff( + expected_contents.splitlines(), + actual_contents.splitlines(), + fromfile="expected", + tofile="actual", + lineterm="", + ) + ) + raise CheckFailed( + "{} contents did not match the expected contents\n{}".format(path, diff) + ) + + +def check_directory(attributes): + path = attributes["dir"] + should_exist = attributes["exists"] == "true" + actually_exists = os.path.isdir(path) + if actually_exists != should_exist: + raise CheckFailed( + "expected exists={}, but exists={} for {}".format( + should_exist, actually_exists, path + ) + ) + + +def check_list(attributes, subject_name): + # ilist/rlist elements arrive as strings just like int/real do (e.g. + # "5" and "5.000000" are the same rlist value in different renderings), + # so they're compared numerically rather than as raw strings; slist + # elements are compared as-is. + def element_value(raw): + if subject_name == "ilist": + return parse_number(raw, "int") + if subject_name == "rlist": + return parse_number(raw, "real") + return raw + + actual_values = [element_value(item) for item in attributes[subject_name]] + + if "equals" in attributes: + expected_values = [element_value(item) for item in attributes["equals"]] + if actual_values != expected_values: + raise CheckFailed( + "expected {!r}, got {!r}".format(expected_values, actual_values) + ) + + if "contains" in attributes: + # A single value checks for that one element; a list checks that + # every element of it is present (an "all of" check). + requested = attributes["contains"] + is_list = isinstance(requested, list) + requested_values = [ + element_value(item) for item in (requested if is_list else [requested]) + ] + missing = [value for value in requested_values if value not in actual_values] + if missing: + shown = missing if is_list else missing[0] + raise CheckFailed("{!r} does not contain {!r}".format(actual_values, shown)) + + +def check_command(attributes): + command = attributes["command"] + try: + result = subprocess.run( + command, shell=True, capture_output=True, text=True, timeout=30 + ) + except subprocess.TimeoutExpired: + raise CheckFailed("'{}' timed out".format(command)) + + if result.returncode != 0: + raise CheckFailed("'{}' exited {}".format(command, result.returncode)) + + if "output" in attributes: + combined_output = result.stdout + result.stderr + if attributes["output"] not in combined_output: + raise CheckFailed( + "{} does not contain {!r}".format( + combined_output.strip(), attributes["output"] + ) + ) + + +if __name__ == "__main__": + AssertModule().start() diff --git a/promise-types/assert/enable.cf b/promise-types/assert/enable.cf new file mode 100644 index 0000000..b914455 --- /dev/null +++ b/promise-types/assert/enable.cf @@ -0,0 +1,6 @@ +promise agent assert +# @brief Define the 'assert' promise type for asserting facts in test policy +{ + path => "$(sys.workdir)/modules/promises/assert_promise_type.py"; + interpreter => "/usr/bin/python3"; +} diff --git a/promise-types/assert/example.cf b/promise-types/assert/example.cf new file mode 100644 index 0000000..9e5147f --- /dev/null +++ b/promise-types/assert/example.cf @@ -0,0 +1,96 @@ +promise agent assert +{ + path => "$(sys.workdir)/modules/promises/assert_promise_type.py"; + interpreter => "/usr/bin/python3"; +} + +bundle agent main +{ + vars: + "fruits" slist => { "apple", "banana", "cherry" }; + "counts" ilist => { "1", "2", "3" }; + "prices" rlist => { "1.50", "2.000000", "3.75" }; + + files: + "/tmp/assert_vocab_fixture.txt" + create => "true", + content => "hello world"; + + assert: + # int + "int_equals_pass" + int => "5", + equals => "5"; + + "int_equals_fail" + int => "5", + equals => "6"; + + "int_greater_than_pass" + int => "5", + greater_than => "1"; + + "int_less_than_fail" + int => "5", + less_than => "1"; + + # real + "real_equals_pass" + real => "3.140000", + equals => "3.14"; + + # str + "str_equals_pass" + str => "hello", + equals => "hello"; + + "str_not_equals_fail" + str => "hello", + not_equals => "hello"; + + "str_contains_pass" + str => "hello world", + contains => "world"; + + # file + "file_exists_pass" + file => "/tmp/assert_vocab_fixture.txt", + exists => "true"; + + "file_contents_fail" + file => "/tmp/assert_vocab_fixture.txt", + contents => "wrong contents"; + + # dir + "dir_exists_pass" + dir => "/tmp", + exists => "true"; + + "dir_exists_fail" + dir => "/this/path/should/not/exist/hopefully", + exists => "true"; + + # command + "command_output_pass" + command => "echo hello", + output => "hello"; + + # slist + "slist_equals_pass" + slist => "@(fruits)", + equals => { "apple", "banana", "cherry" }; + + "slist_contains_fail" + slist => "@(fruits)", + contains => "durian"; + + # ilist (numeric-aware comparison) + "ilist_contains_pass" + ilist => "@(counts)", + contains => "2"; + + # rlist (numeric-aware: "2" should match "2.000000") + "rlist_contains_pass" + rlist => "@(prices)", + contains => "2"; +}