Skip to content

Module examples: selinux

Scripts under examples/modules/selinux/ — one per fact or operation of the selinux module. See the Examples overview for the shared "connects for real" warning and the error-reporting contract every script follows.

The read-only fact-gathering examples (selinux_se_boolean.py reading SEBoolean, selinux_booleans.py reading Booleans, selinux_file_context.py reading FileContext, selinux_file_context_mapping.py reading FileContextMapping, selinux_se_ports.py reading SEPorts — once unfiltered, once narrowed with the protocol/se_type filter arguments — selinux_se_port.py reading SEPort, selinux_mode.py reading Mode, selinux_se_login.py reading SELogin, selinux_se_user.py reading SEUser, selinux_modules.py reading Modules and selinux_permissive_domains.py reading PermissiveDomains, all over gather_fact) are not rendered here — they read a single fact, and the facts themselves are documented on the selinux module page. Like the dnf examples they read the shared project-root inventory.json and guard on the "centos" group: is_centos filters the host list before the gather_fact fan-out, and the operation scripts below carry when=is_centos on every request. Every target is an SELinux (RHEL-family) host, so the semanage/semodule/sestatus shells are meant to run on all of them — point INVENTORY_PATH at your own file of machines before executing.

Host-policy prerequisite for the mutating operations. The tools these scripts drive (semanage, semodule, setsebool, chcon, restorecon from policycoreutils/checkpolicy) are already installed on the demo hosts and every request is correctly flagged sudo=True. What the mutations additionally need is an unconfined sudo domain: they rewrite the root-owned policy store under /var/lib/selinux and relabel root-owned files. On the demo hosts sudo lands in the confined staff_t domain, which cannot access that store, so semanage/semodule fail with "SELinux policy is not managed or store cannot be accessed" and chcon/restorecon with "Permission denied" — even though httpd (and thus /var/www/html) is installed. This is a host-policy prerequisite, not a script gap: the host admin must map the login's sudo role to an unconfined domain (e.g. unconfined_t) or run the target domain permissive before these examples can apply. The read-only fact gathers and selinux.mode (which only reads or calls setenforce) are unaffected. selinux.module also needs its operator-supplied compiled package at /tmp/myhttpd.pp (upload it with files.put first — the library ships no on-host policy compiler).

Enabling a boolean persistently

The boolean operation: selinux.boolean sets one kernel security boolean to on/off, idempotent on the SEBoolean fact — the check-before-executing the enhancement asked for falls out of the fact-driven model, sparing the expensive setsebool -P policy rewrite on an unchanged boolean. The script enables httpd_can_network_connect (the switch that lets httpd open outbound connections) with persistent=True and yields the same request twice — the second is a guaranteed no-op in the apply phase, because the first set is visible to it through the fresh per-step fact cache (in the prepare phase, where nothing is sent, both plan as changes). setsebool writes kernel and policy state, so the requests are flagged sudo=True.

selinux_boolean

Enable an SELinux boolean persistently, with check-before-executing.

This is a usage example, not part of the library. It shows how an application drives reemote's two-phase execution model end to end over a real SSH transport (asyncssh):

  1. Read inventory.json in the project root and parse it as JSON -- an inventory is just a list of dictionaries, which is exactly what JSON expresses -- then validate it up front with validate_inventory so a malformed file fails with an InventoryError naming the offending entry, before any host is materialized or connected.
  2. Build the deploy script as a generator yielding OperationRequest objects, each guarded by a when predicate.
  3. Run the prepare phase to get a plan, and the apply phase to execute it.

Unlike the pytest suite -- which injects the test framework's SimulatedHost via run's host_factory and never touches the network -- this example uses the default factory (Host.from_inventory). Each plain Host lazily opens an asyncssh connection from its connection data, gathers facts and runs commands for real, and run closes those connections when it finishes.

run is a coroutine (both phases are async), so main is async and driven by asyncio.run.

selinux.boolean ports the baseline's operation of the same name: it sets one kernel security boolean -- here httpd_can_network_connect, the switch that lets httpd open outbound network connections (needed by every reverse proxy and web app that talks to a backend) -- to on or off. persistent=True writes the updated policy (setsebool -P) so the value survives reboot; the default persistent=False changes only the running policy.

The operation is idempotent on the SEBoolean fact: it reads the current value word and yields a setsebool only when it differs -- the "check if change is needed before executing" enhancement the boolean proposal asked for falls out of the fact-driven model, sparing the expensive -P policy rewrite on an unchanged boolean. The script yields the same request twice to show that idempotence across the two phases: the prepare phase plans both as changes when the boolean is off (nothing is sent in prepare, so both compare against the same pre-deployment state), while the apply phase sets it on the first and reports the second as a no-op, because the fresh per-step fact cache now sees the value the first step produced.

setsebool writes kernel and policy state, so both requests are flagged sudo=True and the driver elevates their commands over SSH.

Every request carries when=is_centos, the declarative eligibility guard: the predicate reads only static inventory data -- host.group, set by the "group" key of the inventory entry -- and is a stable module-level function, so the prepare and apply phases reach the same skip decision. The driver skips a rejected request without materializing it, so on hosts outside the "centos" group neither the commands nor the facts behind them run, and plan and log render the step as [skipped: when guard is_centos] in its lockstep slot. The path is resolved from __file__, so the example works from any working directory; point INVENTORY_PATH at your own file to target your machines.

Run it with::

uv run python examples/modules/selinux/selinux_boolean.py

Relabelling a file's context components

The file-context operation: selinux.file_context ports the baseline's chcon -t scenario and extends it to the full context — each of se_type, se_user, se_role, se_level is optional, at least one must be given, and only the components differing from the FileContext fact are relabelled. The script labels httpd's index page httpd_sys_content_t twice (the second a guaranteed no-op in apply), then asks for the whole context on the same file — a component-wise no-op once the stock user/role/level already match. chcon changes the runtime label only; a relabel reverts it to the persistent database rule, which is the mapping operation's job below. The requests are flagged sudo=True (the path is root-owned).

selinux_op_file_context

Relabel a file's SELinux context components with selinux.file_context.

This is a usage example, not part of the library. It shows how an application drives reemote's two-phase execution model end to end over a real SSH transport (asyncssh):

  1. Read inventory.json in the project root and parse it as JSON -- an inventory is just a list of dictionaries, which is exactly what JSON expresses -- then validate it up front with validate_inventory so a malformed file fails with an InventoryError naming the offending entry, before any host is materialized or connected.
  2. Build the deploy script as a generator yielding OperationRequest objects, each guarded by a when predicate.
  3. Run the prepare phase to get a plan, and the apply phase to execute it.

Unlike the pytest suite -- which injects the test framework's SimulatedHost via run's host_factory and never touches the network -- this example uses the default factory (Host.from_inventory). Each plain Host lazily opens an asyncssh connection from its connection data, gathers facts and runs commands for real, and run closes those connections when it finishes.

run is a coroutine (both phases are async), so main is async and driven by asyncio.run.

selinux.file_context ports the baseline's operation of the same name and extends it to the full context: the baseline only sets the type (chcon -t), while here each of se_type, se_user, se_role and se_level is optional, at least one must be given, and only the components that differ from the FileContext fact are relabelled. The first two requests are the baseline's exact scenario -- label httpd's index page httpd_sys_content_t so the daemon may serve it -- yielded twice to show idempotence: the prepare phase plans both as changes when the type differs (nothing is sent in prepare), while the apply phase relabels on the first and reports the second as a no-op through the fresh per-step fact cache. The third request asks for the whole context on the same file; after the first request the remaining components already match the stock system_u:object_r:...:s0 label, so it demonstrates the component-wise no-op ("already had the requested context").

chcon changes the runtime label of existing files only -- a relabel (restorecon or a boot-time autorelabel) reverts it to whatever the persistent fcontext database says. The durable rule is selinux.file_context_mapping's job (examples/modules/selinux/selinux_op_file_context_mapping.py). Relabelling a root-owned system path needs root, so every request is flagged sudo=True and the driver elevates its chcon command over SSH.

Every request carries when=is_centos, the declarative eligibility guard: the predicate reads only static inventory data -- host.group, set by the "group" key of the inventory entry -- and is a stable module-level function, so the prepare and apply phases reach the same skip decision. The driver skips a rejected request without materializing it, so on hosts outside the "centos" group neither the commands nor the facts behind them run, and plan and log render the step as [skipped: when guard is_centos] in its lockstep slot. The path is resolved from __file__, so the example works from any working directory; point INVENTORY_PATH at your own file to target your machines.

Run it with::

uv run python examples/modules/selinux/selinux_op_file_context.py

Managing a persistent fcontext rule, then relabelling

The mapping operation: selinux.file_context_mapping manages one persistent rule in the local fcontext database — present=True adds (semanage fcontext -a) or modifies (-m) the rule for a target expression, present=False deletes it (-d) — idempotent on the FileContextMapping fact. The script ensures /web(/.*)? carries httpd_sys_content_t twice (the second a guaranteed no-op in apply), then composes the documented remedy for the operation's own limitation: a database rule never touches labels existing files carry, so a third request runs selinux.restorecon over /web after the mapping step, seeing the new rule through the fresh per-step state — the fact-operation pairing the design recommends. The /web tree must exist on the hosts. All requests are flagged sudo=True.

selinux_op_file_context_mapping

Manage a persistent fcontext rule, then relabel the tree it covers.

This is a usage example, not part of the library. It shows how an application drives reemote's two-phase execution model end to end over a real SSH transport (asyncssh):

  1. Read inventory.json in the project root and parse it as JSON -- an inventory is just a list of dictionaries, which is exactly what JSON expresses -- then validate it up front with validate_inventory so a malformed file fails with an InventoryError naming the offending entry, before any host is materialized or connected.
  2. Build the deploy script as a generator yielding OperationRequest objects, each guarded by a when predicate.
  3. Run the prepare phase to get a plan, and the apply phase to execute it.

Unlike the pytest suite -- which injects the test framework's SimulatedHost via run's host_factory and never touches the network -- this example uses the default factory (Host.from_inventory). Each plain Host lazily opens an asyncssh connection from its connection data, gathers facts and runs commands for real, and run closes those connections when it finishes.

run is a coroutine (both phases are async), so main is async and driven by asyncio.run.

selinux.file_context_mapping ports the baseline's operation of the same name: it manages one persistent rule in the local fcontext database -- here /web(/.*)? labelled httpd_sys_content_t, so a custom web root and everything under it keeps the httpd type across relabels. With present=True (the default) it adds the rule (semanage fcontext -a) or changes an existing rule's type (-m); present=False deletes it (-d). Idempotence reads the FileContextMapping fact: the first two requests carry identical kwargs, so the prepare phase plans both as changes when the rule is absent or mistyped, while the apply phase writes on the first and reports the second as a no-op through the fresh per-step fact cache.

The third request is the documented remedy for the operation's own limitation: semanage fcontext changes the database, never the labels existing files already carry. selinux.restorecon reapplies the database to the tree (restorecon -R /web), and because it runs after the mapping step in the same deploy, the fresh per-step state sees the new rule -- the composition the fact-operation pairing table recommends. The /web tree must exist on the hosts (create it with files.directory first); restorecon is stateless and always reports a change, since reemote has no fact that compares a whole tree against the database.

semanage and restorecon write root-owned policy and labels, so every request is flagged sudo=True and the driver elevates its commands over SSH.

Every request carries when=is_centos, the declarative eligibility guard: the predicate reads only static inventory data -- host.group, set by the "group" key of the inventory entry -- and is a stable module-level function, so the prepare and apply phases reach the same skip decision. The driver skips a rejected request without materializing it, so on hosts outside the "centos" group neither the commands nor the facts behind them run, and plan and log render the step as [skipped: when guard is_centos] in its lockstep slot. The path is resolved from __file__, so the example works from any working directory; point INVENTORY_PATH at your own file to target your machines.

Run it with::

uv run python examples/modules/selinux/selinux_op_file_context_mapping.py

Labelling a network port with modify idempotence

The port operation: selinux.port labels a protocol/port pair and adds the modify capability the baseline's add/remove-only operation lacked — present=True adds an unlabelled port (semanage port -a) or changes a differently-labelled one in place (-m), present=False deletes the label (-d). Idempotence dispatches on tooling: the SEPort fact when sepolicy is installed, otherwise the SEPorts table. The script labels tcp/8080 http_port_t (httpd's alternate port) twice — the second a guaranteed no-op in apply. semanage port writes the policy store, so the requests are flagged sudo=True.

selinux_port

Label a network port for SELinux, with add/modify/delete idempotence.

This is a usage example, not part of the library. It shows how an application drives reemote's two-phase execution model end to end over a real SSH transport (asyncssh):

  1. Read inventory.json in the project root and parse it as JSON -- an inventory is just a list of dictionaries, which is exactly what JSON expresses -- then validate it up front with validate_inventory so a malformed file fails with an InventoryError naming the offending entry, before any host is materialized or connected.
  2. Build the deploy script as a generator yielding OperationRequest objects, each guarded by a when predicate.
  3. Run the prepare phase to get a plan, and the apply phase to execute it.

Unlike the pytest suite -- which injects the test framework's SimulatedHost via run's host_factory and never touches the network -- this example uses the default factory (Host.from_inventory). Each plain Host lazily opens an asyncssh connection from its connection data, gathers facts and runs commands for real, and run closes those connections when it finishes.

run is a coroutine (both phases are async), so main is async and driven by asyncio.run.

selinux.port ports the baseline's operation of the same name and adds the modify capability the port proposal asked for: it labels tcp/8080 http_port_t so httpd may bind the alternate port. With present=True (the default) an unlabelled port is added (semanage port -a) and a port already carrying a different type is modified in place (-m) -- the update scenario the baseline's add/remove-only operation could not express; present=False deletes the label (-d).

Idempotence dispatches on tooling: when sepolicy is installed the operation compares the SEPort fact (the port's specific label), and otherwise falls back to the SEPorts table (semanage port -ln) -- both answer "what type does this port carry now", so the check-before- executing holds either way. The script yields the same request twice: the prepare phase plans both as changes when the label is absent (nothing is sent in prepare), while the apply phase labels on the first and reports the second as a no-op through the fresh per-step fact cache.

semanage port writes the root-owned policy store, so both requests are flagged sudo=True and the driver elevates their commands over SSH.

Every request carries when=is_centos, the declarative eligibility guard: the predicate reads only static inventory data -- host.group, set by the "group" key of the inventory entry -- and is a stable module-level function, so the prepare and apply phases reach the same skip decision. The driver skips a rejected request without materializing it, so on hosts outside the "centos" group neither the commands nor the facts behind them run, and plan and log render the step as [skipped: when guard is_centos] in its lockstep slot. The path is resolved from __file__, so the example works from any working directory; point INVENTORY_PATH at your own file to target your machines.

Run it with::

uv run python examples/modules/selinux/selinux_port.py

Switching the runtime mode

The mode operation: selinux.mode is a reemote original — the baseline ships no mode operation — setting the runtime enforcing state with setenforce, idempotent on the Mode fact. The script ensures enforcing twice (both no-ops on an already-enforcing host; on a permissive host the first switches and the second is the apply-phase no-op). Two boundaries are deliberate: the boot-time mode in /etc/selinux/config is the config operation's job below, and a disabled subsystem raises ValueError — setenforce cannot enable SELinux, the transition needs the config edit, autorelabel and a reboot. The requests are flagged sudo=True.

selinux_op_mode

Switch the SELinux runtime mode with selinux.mode.

This is a usage example, not part of the library. It shows how an application drives reemote's two-phase execution model end to end over a real SSH transport (asyncssh):

  1. Read inventory.json in the project root and parse it as JSON -- an inventory is just a list of dictionaries, which is exactly what JSON expresses -- then validate it up front with validate_inventory so a malformed file fails with an InventoryError naming the offending entry, before any host is materialized or connected.
  2. Build the deploy script as a generator yielding OperationRequest objects, each guarded by a when predicate.
  3. Run the prepare phase to get a plan, and the apply phase to execute it.

Unlike the pytest suite -- which injects the test framework's SimulatedHost via run's host_factory and never touches the network -- this example uses the default factory (Host.from_inventory). Each plain Host lazily opens an asyncssh connection from its connection data, gathers facts and runs commands for real, and run closes those connections when it finishes.

run is a coroutine (both phases are async), so main is async and driven by asyncio.run.

selinux.mode is a reemote original (the baseline ships no mode operation): it sets the runtime enforcing state with setenforce -- here enforcing, the secure default; permissive logs denials without enforcing them, the troubleshooting half-step. The operation is idempotent on the Mode fact: it compares the runtime mode and yields a setenforce only when it differs. The script yields the same request twice: on a permissive host the prepare phase plans both as changes, while the apply phase switches on the first and reports the second as a no-op through the fresh per-step fact cache; on an already-enforcing host both are no-ops in both phases.

Two boundaries are deliberate. The runtime switch does not touch /etc/selinux/config -- the boot-time mode is a file edit, managed by the selinux.config spool-and-install operation in examples/modules/selinux/selinux_config.py. And a disabled subsystem cannot be enabled at runtime at all: setenforce refuses, so the operation raises a ValueError pointing at that operation and at selinux.autorelabel (a disabled-to-enforcing transition needs a full relabel and a reboot). The Mode fact's config_mode field is how a deploy tells "enforcing now but permissive after reboot" apart from a fully enforcing host before deciding to run this operation.

setenforce writes kernel state, so both requests are flagged sudo=True and the driver elevates their commands over SSH.

Every request carries when=is_centos, the declarative eligibility guard: the predicate reads only static inventory data -- host.group, set by the "group" key of the inventory entry -- and is a stable module-level function, so the prepare and apply phases reach the same skip decision. The driver skips a rejected request without materializing it, so on hosts outside the "centos" group neither the commands nor the facts behind them run, and plan and log render the step as [skipped: when guard is_centos] in its lockstep slot. The path is resolved from __file__, so the example works from any working directory; point INVENTORY_PATH at your own file to target your machines.

Run it with::

uv run python examples/modules/selinux/selinux_op_mode.py

Confining a login to an SELinux user

The login operation: selinux.login is a reemote original managing the Linux-user → SELinux-user mapping (semanage login -a/-m/-d), the core of least-privilege confined-user management, idempotent on the SELogin fact comparing both the SELinux user and the MLS/MCS range. The script maps the login kjarvis to staff_u at range s0 — the confined-administrator user whose roles the SEUser fact reports — twice (the second a guaranteed no-op in apply). The mapping takes effect at the user's next login. semanage login writes the policy store, so the requests are flagged sudo=True.

selinux_login

Map a Linux login to a confined SELinux user with selinux.login.

This is a usage example, not part of the library. It shows how an application drives reemote's two-phase execution model end to end over a real SSH transport (asyncssh):

  1. Read inventory.json in the project root and parse it as JSON -- an inventory is just a list of dictionaries, which is exactly what JSON expresses -- then validate it up front with validate_inventory so a malformed file fails with an InventoryError naming the offending entry, before any host is materialized or connected.
  2. Build the deploy script as a generator yielding OperationRequest objects, each guarded by a when predicate.
  3. Run the prepare phase to get a plan, and the apply phase to execute it.

Unlike the pytest suite -- which injects the test framework's SimulatedHost via run's host_factory and never touches the network -- this example uses the default factory (Host.from_inventory). Each plain Host lazily opens an asyncssh connection from its connection data, gathers facts and runs commands for real, and run closes those connections when it finishes.

run is a coroutine (both phases are async), so main is async and driven by asyncio.run.

selinux.login is a reemote original (the baseline ships no login operation): it manages the Linux-user -> SELinux-user mapping, the core of least-privilege confined-user management. Here the login kjarvis is mapped to staff_u with range s0 -- the confined-administrator user whose roles (staff_r, sysadm_r) the SEUser fact reports -- so the account stops landing in the unconfined domain the __default__ fallback assigns. With present=True (the default) an unmapped login is added (semanage login -a) and an existing mapping is modified in place (-m) when its user or range differs; present=False removes the mapping (-d), returning the login to the fallback.

Idempotence reads the SELogin fact and compares both the SELinux user and the MLS/MCS range (the Service column newer semanage releases print is ignored -- the operation never manages it). The script yields the same request twice: the prepare phase plans both as changes when the mapping is absent or different, while the apply phase writes on the first and reports the second as a no-op through the fresh per-step fact cache. The mapping takes effect at the user's next login -- existing sessions keep the context they started with.

semanage login writes the root-owned policy store, so both requests are flagged sudo=True and the driver elevates their commands over SSH.

Every request carries when=is_centos, the declarative eligibility guard: the predicate reads only static inventory data -- host.group, set by the "group" key of the inventory entry -- and is a stable module-level function, so the prepare and apply phases reach the same skip decision. The driver skips a rejected request without materializing it, so on hosts outside the "centos" group neither the commands nor the facts behind them run, and plan and log render the step as [skipped: when guard is_centos] in its lockstep slot. The path is resolved from __file__, so the example works from any working directory; point INVENTORY_PATH at your own file to target your machines.

Run it with::

uv run python examples/modules/selinux/selinux_login.py

Installing a custom policy module

The module operation: selinux.module is a reemote original managing compiled policy modules through a four-state machine — present installs (semodule -X <priority> -i <source>) or re-enables a disabled instance, absent removes (-r), enabled/disabled flip an installed instance (-e/-d) — idempotent on the Modules fact. The parameter is named module_name because op() reserves name for the request label (the baseline's bool_name precedent). The script installs a custom myhttpd package twice (the second a guaranteed no-op in apply); the compiled .pp must already exist on the hosts at /tmp/myhttpd.pp (upload it with files.put first). The absent/enabled/disabled states belong in their own deploys against hosts where the module is already installed: prepare compares every request against the pre-deployment state, and flipping an uninstalled module raises ValueError there. semodule rewrites the policy store, so the requests are flagged sudo=True.

selinux_module

Install a custom SELinux policy module with selinux.module.

This is a usage example, not part of the library. It shows how an application drives reemote's two-phase execution model end to end over a real SSH transport (asyncssh):

  1. Read inventory.json in the project root and parse it as JSON -- an inventory is just a list of dictionaries, which is exactly what JSON expresses -- then validate it up front with validate_inventory so a malformed file fails with an InventoryError naming the offending entry, before any host is materialized or connected.
  2. Build the deploy script as a generator yielding OperationRequest objects, each guarded by a when predicate.
  3. Run the prepare phase to get a plan, and the apply phase to execute it.

Unlike the pytest suite -- which injects the test framework's SimulatedHost via run's host_factory and never touches the network -- this example uses the default factory (Host.from_inventory). Each plain Host lazily opens an asyncssh connection from its connection data, gathers facts and runs commands for real, and run closes those connections when it finishes.

run is a coroutine (both phases are async), so main is async and driven by asyncio.run.

selinux.module is a reemote original (the baseline ships no module operation): it manages compiled policy modules in the semodule store via a four-state machine -- present installs (semodule -X <priority> -i <source>) or re-enables a disabled instance, absent removes (-r), enabled/disabled flip an installed instance (-e/ -d). Here a custom myhttpd package is installed at the default priority 400. The parameter is named module_name rather than name because op() reserves name for the request label (the baseline's bool_name precedent).

Prerequisite: the compiled .pp package must already exist on the host at MODULE_SOURCE -- upload it with files.put first (see examples/modules/files/files_put.py), compiling it locally with checkmodule -M -m and semodule_package. Idempotence reads the Modules fact (semodule -lfull): an instance with the same name at the same priority that is already enabled makes present a no-op. The script yields the same install request twice: the prepare phase plans both as changes when the module is absent, while the apply phase installs on the first and reports the second as a no-op through the fresh per-step fact cache.

The absent/enabled/disabled states belong in their own deploys against hosts where the module is already installed: the prepare phase compares every request against the pre-deployment state, and enabling or disabling a module that is not yet installed raises a ValueError there -- install first, then manage the state in a second run (the two-deploy split examples/demonstrations/composition/two_deploy_split.py explains).

semodule rewrites the root-owned policy store, so both requests are flagged sudo=True and the driver elevates their commands over SSH.

Every request carries when=is_centos, the declarative eligibility guard: the predicate reads only static inventory data -- host.group, set by the "group" key of the inventory entry -- and is a stable module-level function, so the prepare and apply phases reach the same skip decision. The driver skips a rejected request without materializing it, so on hosts outside the "centos" group neither the commands nor the facts behind them run, and plan and log render the step as [skipped: when guard is_centos] in its lockstep slot. The path is resolved from __file__, so the example works from any working directory; point INVENTORY_PATH at your own file to target your machines.

Run it with::

uv run python examples/modules/selinux/selinux_module.py

Carving a per-domain permissive exception

The permissive-domain operation: selinux.permissive_domain is a reemote original adding or removing one domain from the permissive list (semanage permissive -a/-d), idempotent on the PermissiveDomains fact — the safe alternative to global permissive mode when troubleshooting one service: the exception is one domain wide, persistent and auditable. The script runs a full add → no-op → remove cycle on httpd_t, so the hosts are left exactly as they were found — and the two phases tell the story differently: the plan, computed against the pre-deployment snapshot, shows the removal as a no-op when the domain started enforcing, while the apply log's fresh per-step fact cache sees the exception the first step added and removes it. The requests are flagged sudo=True.

selinux_permissive_domain

Carve a per-domain SELinux exception with selinux.permissive_domain.

This is a usage example, not part of the library. It shows how an application drives reemote's two-phase execution model end to end over a real SSH transport (asyncssh):

  1. Read inventory.json in the project root and parse it as JSON -- an inventory is just a list of dictionaries, which is exactly what JSON expresses -- then validate it up front with validate_inventory so a malformed file fails with an InventoryError naming the offending entry, before any host is materialized or connected.
  2. Build the deploy script as a generator yielding OperationRequest objects, each guarded by a when predicate.
  3. Run the prepare phase to get a plan, and the apply phase to execute it.

Unlike the pytest suite -- which injects the test framework's SimulatedHost via run's host_factory and never touches the network -- this example uses the default factory (Host.from_inventory). Each plain Host lazily opens an asyncssh connection from its connection data, gathers facts and runs commands for real, and run closes those connections when it finishes.

run is a coroutine (both phases are async), so main is async and driven by asyncio.run.

selinux.permissive_domain is a reemote original (the baseline ships no permissive-domain operation): it adds or removes one domain from the permissive list -- here httpd_t, the web server's domain -- so its denials are logged but not enforced while the rest of the system stays enforcing. When troubleshooting a service this is the safe alternative to selinux.mode(mode="permissive"), which disables enforcement everywhere: the exception is one domain wide, persistent (it lives in the policy store, not the runtime mode), and auditable through the PermissiveDomains fact.

With present=True (the default) an enforcing domain is added (semanage permissive -a); present=False removes it (-d), returning the domain to full enforcement. Idempotence reads the PermissiveDomains fact: the first two requests carry identical kwargs, so the prepare phase plans both as changes when the domain is enforcing, while the apply phase adds on the first and reports the second as a no-op through the fresh per-step fact cache. The third request removes the exception again, showing the bidirectional pair -- leave it out (or keep only the first request) to keep the domain permissive while you work.

semanage permissive writes the root-owned policy store, so every request is flagged sudo=True and the driver elevates its commands over SSH.

Every request carries when=is_centos, the declarative eligibility guard: the predicate reads only static inventory data -- host.group, set by the "group" key of the inventory entry -- and is a stable module-level function, so the prepare and apply phases reach the same skip decision. The driver skips a rejected request without materializing it, so on hosts outside the "centos" group neither the commands nor the facts behind them run, and plan and log render the step as [skipped: when guard is_centos] in its lockstep slot. The path is resolved from __file__, so the example works from any working directory; point INVENTORY_PATH at your own file to target your machines.

Run it with::

uv run python examples/modules/selinux/selinux_permissive_domain.py

Relabelling a tree (the stateless contract)

The restorecon operation: selinux.restorecon is a reemote original reapplying the persistent fcontext database to existing files (restorecon [-R] [-F] <path>) — the remedy for chcon labels being runtime-only and database rules never touching existing labels. It is deliberately stateless: no fact compares a whole tree's labels against the database, so every request yields and reports a change, in the plan and the log, on the first run and every re-run. The script relabels /var/www/html twice to make that contract visible — unlike the fact-driven operations, the second request is not a no-op; restorecon itself is harmless to re-run (it only writes labels that differ). The requests are flagged sudo=True.

selinux_restorecon

Relabel a tree from the fcontext database with selinux.restorecon.

This is a usage example, not part of the library. It shows how an application drives reemote's two-phase execution model end to end over a real SSH transport (asyncssh):

  1. Read inventory.json in the project root and parse it as JSON -- an inventory is just a list of dictionaries, which is exactly what JSON expresses -- then validate it up front with validate_inventory so a malformed file fails with an InventoryError naming the offending entry, before any host is materialized or connected.
  2. Build the deploy script as a generator yielding OperationRequest objects, each guarded by a when predicate.
  3. Run the prepare phase to get a plan, and the apply phase to execute it.

Unlike the pytest suite -- which injects the test framework's SimulatedHost via run's host_factory and never touches the network -- this example uses the default factory (Host.from_inventory). Each plain Host lazily opens an asyncssh connection from its connection data, gathers facts and runs commands for real, and run closes those connections when it finishes.

run is a coroutine (both phases are async), so main is async and driven by asyncio.run.

selinux.restorecon is a reemote original (the baseline ships no restorecon operation): it reapplies the persistent fcontext database to existing files -- the documented remedy for selinux.file_context's limitation that chcon labels are runtime-only and a relabel reverts them, and for selinux.file_context_mapping's limitation that a database rule never touches labels files already carry. Here /var/www/html is relabelled recursively (restorecon -R, the default recurse=True); force=True would add -F, resetting even the user-customizable user/role/level components, not just the type.

The operation is deliberately stateless: reemote has no fact that compares a whole tree's labels against the database (that would mean walking every file), so every request yields its restorecon and reports a change -- in the prepare plan and in the apply log, on the first run and on every re-run. The script yields the request twice to make that contract visible: unlike the fact-driven operations, the second request is not a no-op. restorecon itself is harmless to re-run (it only writes labels that differ), so the always-changed report is honest about what the tool cannot know in advance. Pair it after a mapping step when the relabel must reflect a rule the same deploy just wrote (see examples/modules/selinux/selinux_op_file_context_mapping.py).

restorecon writes root-owned labels, so both requests are flagged sudo=True and the driver elevates their commands over SSH.

Every request carries when=is_centos, the declarative eligibility guard: the predicate reads only static inventory data -- host.group, set by the "group" key of the inventory entry -- and is a stable module-level function, so the prepare and apply phases reach the same skip decision. The driver skips a rejected request without materializing it, so on hosts outside the "centos" group neither the commands nor the facts behind them run, and plan and log render the step as [skipped: when guard is_centos] in its lockstep slot. The path is resolved from __file__, so the example works from any working directory; point INVENTORY_PATH at your own file to target your machines.

Run it with::

uv run python examples/modules/selinux/selinux_restorecon.py

Scheduling a full-system relabel

The autorelabel operation: selinux.autorelabel is a reemote original creating the /.autorelabel marker (touch /.autorelabel) that tells the next boot to relabel every file from the database — the heavy remedy for a disabled-to-enabled transition (files created while SELinux was off carry no labels) or severely corrupted contexts. Warning: the relabel runs before services start, can take many minutes, and only happens at the next reboot, which the operation does not trigger; for one tree, restorecon is the surgical tool. Idempotent on the marker's existence, the script schedules the relabel twice — the second a guaranteed no-op in apply ("a full relabel is already pending"). Creating a file at / needs root, so the requests are flagged sudo=True.

selinux_autorelabel

Schedule a full-system SELinux relabel with selinux.autorelabel.

This is a usage example, not part of the library. It shows how an application drives reemote's two-phase execution model end to end over a real SSH transport (asyncssh):

  1. Read inventory.json in the project root and parse it as JSON -- an inventory is just a list of dictionaries, which is exactly what JSON expresses -- then validate it up front with validate_inventory so a malformed file fails with an InventoryError naming the offending entry, before any host is materialized or connected.
  2. Build the deploy script as a generator yielding OperationRequest objects, each guarded by a when predicate.
  3. Run the prepare phase to get a plan, and the apply phase to execute it.

Unlike the pytest suite -- which injects the test framework's SimulatedHost via run's host_factory and never touches the network -- this example uses the default factory (Host.from_inventory). Each plain Host lazily opens an asyncssh connection from its connection data, gathers facts and runs commands for real, and run closes those connections when it finishes.

run is a coroutine (both phases are async), so main is async and driven by asyncio.run.

selinux.autorelabel is a reemote original (the baseline ships no autorelabel operation): it creates the /.autorelabel marker file (touch /.autorelabel) that tells the next boot to relabel every file on the system from the fcontext database. Warning -- this is a heavy, disruptive remedy: the relabel runs before services start and can take many minutes on a large filesystem, and it only happens at the next reboot, which this operation does not trigger (reboot deliberately, or with the server.reboot operation, when you are ready). Use it when SELinux is being switched from disabled to enabled -- files created while it was off carry no labels at all -- or when the filesystem's contexts are severely corrupted; for one tree, selinux.restorecon is the surgical tool instead.

The operation is idempotent on the marker's existence: when /.autorelabel is already present a relabel is already pending and the request is a no-op. The script yields the request twice to show that: the prepare phase plans both as changes when the marker is absent (nothing is sent in prepare), while the apply phase creates it on the first and reports the second as a no-op ("a full relabel is already pending").

Creating a file at / needs root, so both requests are flagged sudo=True and the driver elevates their commands over SSH. The disabled-to-enabling flow this operation belongs to is completed by the selinux.config operation in examples/modules/selinux/selinux_config.py (set SELINUX=enforcing for the next boot) followed by a reboot.

Every request carries when=is_centos, the declarative eligibility guard: the predicate reads only static inventory data -- host.group, set by the "group" key of the inventory entry -- and is a stable module-level function, so the prepare and apply phases reach the same skip decision. The driver skips a rejected request without materializing it, so on hosts outside the "centos" group neither the commands nor the facts behind them run, and plan and log render the step as [skipped: when guard is_centos] in its lockstep slot. The path is resolved from __file__, so the example works from any working directory; point INVENTORY_PATH at your own file to target your machines.

Run it with::

uv run python examples/modules/selinux/selinux_autorelabel.py

Managing /etc/selinux/config at boot

The config operation: selinux.config is a reemote original — the baseline ships no config operation — ensuring the boot-time settings in /etc/selinux/config, and it supersedes the files.edit + files.move two-run recipe this example used to demonstrate: the whole spool-and-install dance is now inside config, so one request in one deploy replaces the snapshot gather, the controller-side gate and both runs. The script ensures SELINUX=enforcing and SELINUXTYPE=targeted (the runtime half is selinux.mode's job); each setting is optional — None leaves that key untouched. Internally the operation reads the file's text with the SelinuxConfigFile content fact, drops every existing assignment of each requested key (commented ones included) and appends KEY=value unquoted; a file that already holds exactly the wanted assignments is a no-op. When the content differs, the request sends an unprivileged scp spooling the new text to a deterministic /tmp path (transfers never elevate), then an elevated sh -c that copies it over the file, chmods it 0644 and removes the spool — the request's sudo=True covers the install only. The settings take effect at the next reboot; switching a disabled subsystem to enforcing additionally needs selinux.autorelabel before that reboot.

selinux_config

Manage /etc/selinux/config with the selinux.config operation.

This is a usage example, not part of the library. It shows how an application drives reemote's two-phase execution model end to end over a real SSH transport (asyncssh):

  1. Read inventory.json in the project root and parse it as JSON -- an inventory is just a list of dictionaries, which is exactly what JSON expresses -- then validate it up front with validate_inventory so a malformed file fails with an InventoryError naming the offending entry, before any host is materialized or connected.
  2. Build the deploy script as a generator yielding OperationRequest objects, each guarded by a when predicate.
  3. Run the prepare phase to get a plan, and the apply phase to execute it.

selinux.config is a reemote original (the baseline ships no config operation): it ensures the boot-time settings SELINUX=enforcing and SELINUXTYPE=targeted in /etc/selinux/config. It supersedes the files.edit + files.move two-run recipe this example used to demonstrate -- the whole spool-and-install dance is now inside the operation, so one request in one deploy replaces the snapshot gather, the controller-side gate and both runs. Internally the operation reads the file's current text with the SelinuxConfigFile content fact, drops every existing assignment of each requested key (commented ones included) and appends KEY=value unquoted, the stock convention; a file that already holds exactly the wanted assignments is a no-op, so re-running the script changes nothing. When the content differs, the request sends two commands: an unprivileged scp spooling the new text to a deterministic /tmp path (reemote's transfers never elevate, which is why the root-owned file cannot be written directly), then an elevated sh -c that copies it over /etc/selinux/config, chmods it 0644 and removes the spool. The request's sudo=True applies to the install only. A None setting is left untouched: pass just SELINUX or just SELINUXTYPE to manage one key alone.

The settings are read when the system boots, so the edit takes effect at the next reboot -- the runtime half is selinux.mode's job (setenforce, see examples/modules/selinux/selinux_op_mode.py), and the Mode fact's config_mode field is how a deploy reads the persistent setting back. Warning: switching a disabled subsystem to enforcing additionally requires a full relabel -- request selinux.autorelabel (examples/modules/selinux/selinux_autorelabel.py) before the reboot, or the boot will relabel nothing and services will fail on unlabelled files. Changing SELINUXTYPE requires the matching policy package to be installed.

Every request carries when=is_centos, the declarative eligibility guard: the predicate reads only static inventory data -- host.group, set by the "group" key of the inventory entry -- and is a stable module-level function, so the prepare and apply phases reach the same skip decision. The driver skips a rejected request without materializing it, so on hosts outside the "centos" group neither the commands nor the facts behind them run, and plan and log render the step as [skipped: when guard is_centos] in its lockstep slot. The path is resolved from __file__, so the example works from any working directory; point INVENTORY_PATH at your own file of SELinux hosts (RHEL-family) to target your machines.

Run it with::

uv run python examples/modules/selinux/selinux_config.py