Skip to content

CFE-4686: Add cancel attribute to classes promises to undefine a class#6175

Open
nickanderson wants to merge 2 commits into
cfengine:masterfrom
nickanderson:CFE-4686/classes-cancel-attribute
Open

CFE-4686: Add cancel attribute to classes promises to undefine a class#6175
nickanderson wants to merge 2 commits into
cfengine:masterfrom
nickanderson:CFE-4686/classes-cancel-attribute

Conversation

@nickanderson

Copy link
Copy Markdown
Member

Summary

Adds a cancel attribute to classes promises so policy can undefine a
class. Until now a classes promise could only define a class; the only way
to undefine one from policy was a cancel_* classes body attached to another
promise's outcome. With cancel, the classes promise can undefine its own
promiser directly:

classes:
    # Undefine 'maintenance' once the window has passed
    "maintenance" cancel => "maintenance_window_over";

    # Undefine 'have_config' if the file no longer exists
    "have_config" cancel => not(fileexists("/etc/myapp.conf"));

When the cancel expression (a class expression — string or a function
returning a boolean) evaluates true, the promiser class is undefined if it is
currently defined. False trigger, or an already-undefined class, is a no-op.

Key detail: the class-skipping exception

ExpandDeRefPromise() normally excludes a classes promise whose promiser is
already defined
(an optimization). A cancel promise must run precisely in
that case, so the skip now makes an exception when the cancel attribute is
present. This is the crux of the change — without it the promise would never
actuate.

Behaviour

  • Mutually exclusive with the class-defining attributes (expression, and,
    or, xor, not, dist, select_class) via the existing "Irreconcilable
    constraints" check — cancel is counted like any other class-body attribute.
  • Reserved hard classes cannot be cancelled (warned, left in place),
    consistent with the cancel_* classes body attributes (ENT-7718 / CFE-3647).
  • The evaluate-and-undefine path mirrors the cancel_* classes body
    (DeleteAllClasses): persistent removal + EvalContextClassRemove +
    bundle-frame removal.

Tests

New acceptance tests in tests/acceptance/02_classes/01_basic/:
cancel_attribute.cf, cancel_attribute_hardclass.cf, cancel_attribute_mutex.cf(+.sub).
Full 02_classes suite: 117 passed, 0 failed (3 staging-skipped, 3
pre-existing soft failures).

Docs

Documentation PR: cfengine/documentation (CFE-4686) adds the cancel reference
section.

Ticket: https://northerntech.atlassian.net/browse/CFE-4686

🤖 Generated with Claude Code

Covers undefining a defined class when the cancel expression is true,
retaining the class when the expression is false, function-based triggers,
the no-op case for an undefined class, refusal to cancel reserved hard
classes, and mutual exclusion with the class-defining attributes.

Ticket: CFE-4686
Changelog: None

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@nickanderson

Copy link
Copy Markdown
Member Author

@cf-bottom jenkins please

Comment thread libpromises/verify_classes.c Fixed
Comment thread libpromises/verify_classes.c Fixed
Comment thread libpromises/verify_classes.c Fixed
@cf-bottom

Copy link
Copy Markdown

@basvandervlies

Copy link
Copy Markdown
Contributor

Thanks nice addition!! Much needed

Until now a classes promise could only define a class; the only way to
undefine one from policy was a cancel_* classes body attached to another
promise's outcome. This adds a 'cancel' attribute to the classes promise
itself: when its class expression (a string, or a function returning a
boolean) evaluates true, the promiser class is undefined if it is defined.

The class-skipping optimization in ExpandDeRefPromise() normally excludes a
classes promise whose promiser is already defined. A cancel promise must run
precisely in that case, so the skip now makes an exception when the 'cancel'
attribute is present.

'cancel' is mutually exclusive with the class-defining attributes
(expression, and, or, xor, not, dist, select_class) via the existing
"Irreconcilable constraints" check, since it is counted like any other
class-body attribute. Reserved hard classes cannot be cancelled. The
evaluation and undefine path mirrors the cancel_* classes body behaviour
(DeleteAllClasses).

Ticket: CFE-4686
Changelog: Title

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@nickanderson nickanderson force-pushed the CFE-4686/classes-cancel-attribute branch from d9151e2 to bf85569 Compare June 12, 2026 14:15

@olehermanse olehermanse left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks a bit backwards that cancel has a class expression. To me it reads as if what you put as the value to the cancel attribute should be the class you want to cancel. With the proposed behavior I guess something like cancel_if would be a better name.

But why should it have a class expression? We already have conditions (class expressions) in many places: class guard, if, unless, and expression for classes promises. Why do we need one more?

Some other proposals;

bundle agent example
{
  classes:
    some_condition.linux::
      "my_class"
        # true / false, default to false:
        cancel => true;
}
bundle agent example
{
  classes:
    "my_class"
      # define / undefine, default to define:
      do => "undefine",
      if => "some_condition.linux";
}

@nickanderson

nickanderson commented Jun 12, 2026

Copy link
Copy Markdown
Member Author

It looks a bit backwards that cancel has a class expression. To me it reads as if what you put as the value to the cancel attribute should be the class you want to cancel. With the proposed behavior I guess something like cancel_if would be a better name.

On the naming, yeah right after I pushed it I was thinking to mysefl that maintenance cancel blah read quite poorly. I was thinking a bit about cancel_when.

classes:
    "maintenance" expression  => "maintenance_window_active";
    "maintenance" cancel_when => "maintenance_window_over";

    "have_config" cancel_when => not(fileexists("/etc/myapp.conf"));

That leaves if/unless still available for further compound constraint.

or maybe invalidated_by ? we already have if/ifvarclass so cancel_if just seemed a bit yeuck.

@basvandervlies @ncharles opinions on language here?

But why should it have a class expression? We already have conditions (class expressions) in many places: class guard, if, unless, and expression for classes promises. Why do we need one more?

Most class-defining attributes accepts full class expressions:

bundle agent main
{
      classes:
        # expression -- a single class expression string
        "via_expression"
          expression => "linux.!windows";

        # and -- list of class expressions, ALL must be true
        "via_and"
          and => { "linux.!windows", "any.!solaris" };

        # or -- list of class expressions, ANY must be true
        "via_or"
          or => { "windows.x86_64", "linux.!windows" };

        # not -- a class expression, NEGATED
        "via_not"
          not => "windows.!linux";

        # xor -- list of class expressions, exactly ONE must be true
        "via_xor"
          xor => { "linux.!windows", "windows.!linux" };

        # dist -- probabilistic weights (not a class expression)
        "dist_a"
          dist => { "100", "0", "0" };

        # select_class -- deterministic host selection (not a class expression)
        "sel_a"
          select_class => { "sel_a", "sel_b", "sel_c" };

      reports:
        "expression => linux.!windows : $(with)"
          with => ifelse("via_expression", "DEFINED", "NOT DEFINED");

        "and => { linux.!windows, any.!solaris } : $(with)"
          with => ifelse("via_and", "DEFINED", "NOT DEFINED");

        "or => { windows.x86_64, linux.!windows } : $(with)"
          with => ifelse("via_or", "DEFINED", "NOT DEFINED");

        "not => windows.!linux : $(with)"
          with => ifelse("via_not", "DEFINED", "NOT DEFINED");

        "xor => { linux.!windows, windows.!linux } : $(with)"
          with => ifelse("via_xor", "DEFINED", "NOT DEFINED");

        "dist => { 100, 0, 0 } : $(with)"
          with => ifelse("dist_a", "DEFINED", "NOT DEFINED");

        "select_class => { sel_a, sel_b, sel_c } : $(with)"
          with => ifelse("sel_a", "DEFINED", "NOT DEFINED");
}
R: expression => linux.!windows : DEFINED
R: and => { linux.!windows, any.!solaris } : DEFINED
R: or => { windows.x86_64, linux.!windows } : DEFINED
R: not => windows.!linux : DEFINED
R: xor => { linux.!windows, windows.!linux } : DEFINED
R: dist => { 100, 0, 0 } : DEFINED
R: select_class => { sel_a, sel_b, sel_c } : DEFINED

R: expression => linux.!windows : DEFINED
R: and => { linux.!windows, any.!solaris } : DEFINED
R: or => { windows.x86_64, linux.!windows } : DEFINED
R: not => windows.!linux : DEFINED
R: xor => { linux.!windows, windows.!linux } : DEFINED
R: dist => { 100, 0, 0 } : DEFINED
R: select_class => { sel_a, sel_b, sel_c } : DEFINED

expression, and, or, not, and xor all accept full class expressions (compound boolean strings with . / | / !), not just bare class names. dist and select_class are the exceptions but I think they are very different from canceling as well.

@nickanderson

Copy link
Copy Markdown
Member Author

Another idea, expire_when

@nickanderson

Copy link
Copy Markdown
Member Author

expire is growing on me (I don't need the _when suffix, but im ok with it or even _if I guess.)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

5 participants