From d9eac27fae277b9afe038995dec34e6f967f590e Mon Sep 17 00:00:00 2001 From: epicrazzmatazz Date: Thu, 25 Jun 2026 18:05:24 -0400 Subject: [PATCH 01/25] WIP s6 support --- pyinfra-metadata.toml | 6 ++ src/pyinfra/facts/s6.py | 65 +++++++++++++++++++ src/pyinfra/operations/s6.py | 27 ++++++++ .../nonstandard_repository_location.yaml | 11 ++++ tests/facts/s6.S6RCSets/standard.yaml | 8 +++ .../s6.S6RCStatus/no_running_services.yaml | 14 ++++ .../s6.S6RCStatus/no_stopped_services.yaml | 40 ++++++++++++ tests/facts/s6.S6RCStatus/standard.yaml | 48 ++++++++++++++ 8 files changed, 219 insertions(+) create mode 100644 src/pyinfra/facts/s6.py create mode 100644 src/pyinfra/operations/s6.py create mode 100644 tests/facts/s6.S6RCSets/nonstandard_repository_location.yaml create mode 100644 tests/facts/s6.S6RCSets/standard.yaml create mode 100644 tests/facts/s6.S6RCStatus/no_running_services.yaml create mode 100644 tests/facts/s6.S6RCStatus/no_stopped_services.yaml create mode 100644 tests/facts/s6.S6RCStatus/standard.yaml diff --git a/pyinfra-metadata.toml b/pyinfra-metadata.toml index 88e4361ea..7ae88dba4 100644 --- a/pyinfra-metadata.toml +++ b/pyinfra-metadata.toml @@ -275,6 +275,12 @@ path = "src/pyinfra/facts/launchd.py" type = "fact" tags = ["service-management", "system"] +[pyinfra.plugins."s6-facts"] +name = "s6" +path = "src/pyinfra/facts/s6.py" +type = "fact" +tags = ["service-management", "system"] + [pyinfra.plugins."systemd-ops"] name = "systemd" path = "src/pyinfra/operations/systemd.py" diff --git a/src/pyinfra/facts/s6.py b/src/pyinfra/facts/s6.py new file mode 100644 index 000000000..18051b719 --- /dev/null +++ b/src/pyinfra/facts/s6.py @@ -0,0 +1,65 @@ +from pyinfra.api import FactBase + + +# all sets in the repository +class S6RCSets(FactBase[list[str]]): + """Returns the name of every set in a repository.""" + + def requires_command(self, respository=None): + return "s6-rc-repo-list" + + def command(self, repository=None): + if repository: + return f"s6-rc-repo-list -r {repository}" + else: + return "s6-rc-repo-list" + + def process(self, output): + return output + + +class S6RCEnabled(FactBase[dict[str, str]]): + """Returns a dict of name -> rx (prescription) for each service in a given set.""" + + def requires_command(self): + return "s6-rc-set-status" + + def command(self, set, repository=None): + if repository: + return f"s6-rc-set-status -r {repository} {set}" + else: + return f"s6-rc-set-status {set}" + + def process(self, output): + return {triplet[0]: triplet[-1] for triplet in map(lambda line: line.partition("/"))} + + +class S6RCStatus(FactBase[dict[str, bool]]): + """ + Returns a dict of name -> status for each service in the live state. + + True means s6 is trying to keep the service up; False means the service is not managed by s6. + """ + + # default = dict + + def requires_command(self): + return "s6-rc" + + def check_preconditions(self): + pass + + def command(self): + return r"{ s6-rc -a list && echo -e 'GROUP SEPARATOR' && s6-rc -da list ; } || exit 1" + + def process(self, output): + status = {} + + gs_index = output.index("GROUP SEPARATOR") + enabled_services = output[:gs_index] + disabled_services = output[gs_index + 1 :] + + status.update([(srv, True) for srv in enabled_services]) + status.update([(srv, False) for srv in disabled_services]) + + return status diff --git a/src/pyinfra/operations/s6.py b/src/pyinfra/operations/s6.py new file mode 100644 index 000000000..3beab4fb6 --- /dev/null +++ b/src/pyinfra/operations/s6.py @@ -0,0 +1,27 @@ +"""Manage s6-rc services (https://www.skarnet.org/software/s6-rc/).""" + +from pyinfra import host +from pyinfra.api import operation + +from .util.service import handle_service_control + +@operation() +def service(): + """ + Manage the state of s6-supervised services. + + + service: + + running: + + restarted: + + reloaded: + + enabled: + """ + # reloaded? that would be service-dependent signal. accept it anyway, implement as SIGHUP + # TODO statuses argument fact + yield from handle_service_control( + host, + service, + host.get_fact(S6Status) , + "", + + ) diff --git a/tests/facts/s6.S6RCSets/nonstandard_repository_location.yaml b/tests/facts/s6.S6RCSets/nonstandard_repository_location.yaml new file mode 100644 index 000000000..d1f0f0225 --- /dev/null +++ b/tests/facts/s6.S6RCSets/nonstandard_repository_location.yaml @@ -0,0 +1,11 @@ +arg: + - "/etc/s6/repo" +command: "s6-rc-repo-list -r /etc/s6/repo" +requires_command: "s6-rc-repo-list" +output: | + default + current + normal +fact: + [ "default", "current", "normal" ] + diff --git a/tests/facts/s6.S6RCSets/standard.yaml b/tests/facts/s6.S6RCSets/standard.yaml new file mode 100644 index 000000000..524ef3b16 --- /dev/null +++ b/tests/facts/s6.S6RCSets/standard.yaml @@ -0,0 +1,8 @@ +command: "s6-rc-repo-list" +requires_command: "s6-rc-repo-list" +output: | + default + current + normal +fact: + [ "default", "current", "normal" ] diff --git a/tests/facts/s6.S6RCStatus/no_running_services.yaml b/tests/facts/s6.S6RCStatus/no_running_services.yaml new file mode 100644 index 000000000..8c1ef7368 --- /dev/null +++ b/tests/facts/s6.S6RCStatus/no_running_services.yaml @@ -0,0 +1,14 @@ +command: "{ s6-rc -a list && echo -e 'GROUP SEPARATOR' && s6-rc -da list ; } || exit 1" +requires_command: "s6-rc" +output: | + GROUP SEPARATOR + cupsd + usbguard + avahi-daemon-log + avahi-daemon-srv +fact: + cupsd: false + usbguard: false + avahi-daemon-log: false + avahi-daemon-srv: false + diff --git a/tests/facts/s6.S6RCStatus/no_stopped_services.yaml b/tests/facts/s6.S6RCStatus/no_stopped_services.yaml new file mode 100644 index 000000000..194d81426 --- /dev/null +++ b/tests/facts/s6.S6RCStatus/no_stopped_services.yaml @@ -0,0 +1,40 @@ +command: "{ s6-rc -a list && echo -e 'GROUP SEPARATOR' && s6-rc -da list ; } || exit 1" +requires_command: "s6-rc" +output: | + NetworkManager-log + NetworkManager-srv + bluetoothd-log + bluetoothd-srv + dbus-log + dbus-srv + seatd-log + seatd-srv + tty1 + tty2 + tty3 + mount-net + network-detection + modules + net-lo + swap + udevadm + GROUP SEPARATOR +fact: + NetworkManager-log: true + NetworkManager-srv: true + bluetoothd-log: true + bluetoothd-srv: true + dbus-log: true + dbus-srv: true + seatd-log: true + seatd-srv: true + tty1: true + tty2: true + tty3: true + mount-net: true + network-detection: true + modules: true + net-lo: true + swap: true + udevadm: true + diff --git a/tests/facts/s6.S6RCStatus/standard.yaml b/tests/facts/s6.S6RCStatus/standard.yaml new file mode 100644 index 000000000..a41d33978 --- /dev/null +++ b/tests/facts/s6.S6RCStatus/standard.yaml @@ -0,0 +1,48 @@ +command: "{ s6-rc -a list && echo -e 'GROUP SEPARATOR' && s6-rc -da list ; } || exit 1" +requires_command: "s6-rc" +output: | + NetworkManager-log + NetworkManager-srv + bluetoothd-log + bluetoothd-srv + dbus-log + dbus-srv + seatd-log + seatd-srv + tty1 + tty2 + tty3 + mount-net + network-detection + modules + net-lo + swap + udevadm + GROUP SEPARATOR + cupsd + usbguard + avahi-daemon-log + avahi-daemon-srv +fact: + NetworkManager-log: true + NetworkManager-srv: true + bluetoothd-log: true + bluetoothd-srv: true + dbus-log: true + dbus-srv: true + seatd-log: true + seatd-srv: true + tty1: true + tty2: true + tty3: true + mount-net: true + network-detection: true + modules: true + net-lo: true + swap: true + udevadm: true + cupsd: false + usbguard: false + avahi-daemon-log: false + avahi-daemon-srv: false + From b4722ee704d29928a4b31ab2034ce6efc98af34f Mon Sep 17 00:00:00 2001 From: epicrazzmatazz Date: Fri, 26 Jun 2026 18:20:50 -0400 Subject: [PATCH 02/25] WIP s6 support --- src/pyinfra/facts/s6.py | 4 +-- .../nonstandard_repository_location.yaml | 25 +++++++++++++++++++ tests/facts/s6.S6RCEnabled/standard.yaml | 24 ++++++++++++++++++ 3 files changed, 51 insertions(+), 2 deletions(-) create mode 100644 tests/facts/s6.S6RCEnabled/nonstandard_repository_location.yaml create mode 100644 tests/facts/s6.S6RCEnabled/standard.yaml diff --git a/src/pyinfra/facts/s6.py b/src/pyinfra/facts/s6.py index 18051b719..b41a1fa08 100644 --- a/src/pyinfra/facts/s6.py +++ b/src/pyinfra/facts/s6.py @@ -21,7 +21,7 @@ def process(self, output): class S6RCEnabled(FactBase[dict[str, str]]): """Returns a dict of name -> rx (prescription) for each service in a given set.""" - def requires_command(self): + def requires_command(self, set, repository=None): return "s6-rc-set-status" def command(self, set, repository=None): @@ -31,7 +31,7 @@ def command(self, set, repository=None): return f"s6-rc-set-status {set}" def process(self, output): - return {triplet[0]: triplet[-1] for triplet in map(lambda line: line.partition("/"))} + return {triplet[0]: triplet[-1] for triplet in map(lambda line: line.partition("/"), output)} class S6RCStatus(FactBase[dict[str, bool]]): diff --git a/tests/facts/s6.S6RCEnabled/nonstandard_repository_location.yaml b/tests/facts/s6.S6RCEnabled/nonstandard_repository_location.yaml new file mode 100644 index 000000000..fe85b5566 --- /dev/null +++ b/tests/facts/s6.S6RCEnabled/nonstandard_repository_location.yaml @@ -0,0 +1,25 @@ +arg: + - "default" + - "/etc/s6/repo" +command: "s6-rc-set-status -r /etc/s6/repo default" +requires_command: "s6-rc-set-status" +output: | + swap/always + dmesg-srv/always + dmesg-log/always + tty1/active + mount-net/active + network-detection/active + tlp/usable + avahi-daemon-srv/usable + avahi-daemon-log/usable +fact: + swap: always + dmesg-srv: always + dmesg-log: always + tty1: active + mount-net: active + network-detection: active + tlp: usable + avahi-daemon-srv: usable + avahi-daemon-log: usable diff --git a/tests/facts/s6.S6RCEnabled/standard.yaml b/tests/facts/s6.S6RCEnabled/standard.yaml new file mode 100644 index 000000000..a5df184c9 --- /dev/null +++ b/tests/facts/s6.S6RCEnabled/standard.yaml @@ -0,0 +1,24 @@ +arg: + - "default" +command: "s6-rc-set-status default" +requires_command: "s6-rc-set-status" +output: | + swap/always + dmesg-srv/always + dmesg-log/always + tty1/active + mount-net/active + network-detection/active + tlp/usable + avahi-daemon-srv/usable + avahi-daemon-log/usable +fact: + swap: always + dmesg-srv: always + dmesg-log: always + tty1: active + mount-net: active + network-detection: active + tlp: usable + avahi-daemon-srv: usable + avahi-daemon-log: usable From 526c8e0a89e2afed8afd02d210b3694f94eb96cf Mon Sep 17 00:00:00 2001 From: epicrazzmatazz Date: Fri, 26 Jun 2026 22:06:28 -0400 Subject: [PATCH 03/25] WIP s6 support --- src/pyinfra/facts/s6.py | 101 ++++++++++++------ src/pyinfra/operations/s6.py | 59 ++++++++-- .../s6.S6LiveStatus/no_running_services.yaml | 13 +++ .../s6.S6LiveStatus/no_stopped_services.yaml | 12 +++ tests/facts/s6.S6LiveStatus/standard.yaml | 13 +++ .../nonstandard_repository_location.yaml | 25 ----- tests/facts/s6.S6RCEnabled/standard.yaml | 24 ----- tests/facts/s6.S6RCSets/standard.yaml | 8 -- .../s6.S6RCStatus/no_running_services.yaml | 14 --- .../s6.S6RCStatus/no_stopped_services.yaml | 40 ------- tests/facts/s6.S6RCStatus/standard.yaml | 48 --------- .../nonstandard_repository.yaml} | 10 +- tests/facts/s6.S6RepositoryList/standard.yaml | 9 ++ .../nonstandard_repository.yaml | 16 +++ .../nonstandard_repository_set.yaml | 13 +++ .../facts/s6.S6SetStatus/nonstandard_set.yaml | 12 +++ tests/facts/s6.S6SetStatus/standard.yaml | 14 +++ 17 files changed, 224 insertions(+), 207 deletions(-) create mode 100644 tests/facts/s6.S6LiveStatus/no_running_services.yaml create mode 100644 tests/facts/s6.S6LiveStatus/no_stopped_services.yaml create mode 100644 tests/facts/s6.S6LiveStatus/standard.yaml delete mode 100644 tests/facts/s6.S6RCEnabled/nonstandard_repository_location.yaml delete mode 100644 tests/facts/s6.S6RCEnabled/standard.yaml delete mode 100644 tests/facts/s6.S6RCSets/standard.yaml delete mode 100644 tests/facts/s6.S6RCStatus/no_running_services.yaml delete mode 100644 tests/facts/s6.S6RCStatus/no_stopped_services.yaml delete mode 100644 tests/facts/s6.S6RCStatus/standard.yaml rename tests/facts/{s6.S6RCSets/nonstandard_repository_location.yaml => s6.S6RepositoryList/nonstandard_repository.yaml} (60%) create mode 100644 tests/facts/s6.S6RepositoryList/standard.yaml create mode 100644 tests/facts/s6.S6SetStatus/nonstandard_repository.yaml create mode 100644 tests/facts/s6.S6SetStatus/nonstandard_repository_set.yaml create mode 100644 tests/facts/s6.S6SetStatus/nonstandard_set.yaml create mode 100644 tests/facts/s6.S6SetStatus/standard.yaml diff --git a/src/pyinfra/facts/s6.py b/src/pyinfra/facts/s6.py index b41a1fa08..9a2597146 100644 --- a/src/pyinfra/facts/s6.py +++ b/src/pyinfra/facts/s6.py @@ -1,65 +1,102 @@ from pyinfra.api import FactBase -# all sets in the repository -class S6RCSets(FactBase[list[str]]): +class S6RepositoryList(FactBase[list[str]]): """Returns the name of every set in a repository.""" - def requires_command(self, respository=None): - return "s6-rc-repo-list" + def check_preconditions(self, state, host): + from pyinfra.facts.files import File + if not host.get_fact(File("/etc/s6/frontend.conf")): + return "/etc/s6/frontend.conf doesn't exist" + + def requires_command(self, repository=None): + # "s6" only sees the repository configured in /etc/s6-frontend.conf + if repository: + return "s6-rc-repo-list" + + return "s6" def command(self, repository=None): + """ + + repository: path of the repository to inspect, default the one configured in `/etc/s6-frontend.conf`. + """ if repository: return f"s6-rc-repo-list -r {repository}" - else: - return "s6-rc-repo-list" + + return "s6 repository list" def process(self, output): + # "s6" command doesn't list the set named "current", while s6-rc-repo-list does. this + # try-except normalizes the output. + try: + del output[output.index("current")] + except ValueError: + pass + return output -class S6RCEnabled(FactBase[dict[str, str]]): +class S6SetStatus(FactBase[dict[str, str]]): """Returns a dict of name -> rx (prescription) for each service in a given set.""" - def requires_command(self, set, repository=None): - return "s6-rc-set-status" + def check_preconditions(self, state, host): + from pyinfra.facts.files import File + if not host.get_fact(File("/etc/s6/frontend.conf")): + return "/etc/s6/frontend.conf doesn't exist" + + def requires_command(self, repository=None, set=None): + if repository or set: + return "s6-rc-set-status" + + return "s6" + + def command(self, set=None, repository=None): + """ + + set: the set to inspect, default `None` which resolves to the current working set "current". + + repository: path of the repository to inspect, default `None` which resolves the following way: If `set` is unspecified, the repository in `/etc/s6-frontend.conf` will be used. If `set` is specified, the compiled-in default `/var/lib/s6-rc/repository` will be used. + """ + if set: + if repository: + return f"s6-rc-set-status -r {repository} {set}" - def command(self, set, repository=None): - if repository: - return f"s6-rc-set-status -r {repository} {set}" - else: return f"s6-rc-set-status {set}" + if repository: + if set: + return f"s6-rc-set-status -r {repository} {set}" + + return f"s6-rc-set-status -r {repository} current" + + return "s6 set status" + def process(self, output): - return {triplet[0]: triplet[-1] for triplet in map(lambda line: line.partition("/"), output)} + return { + triplet[0]: triplet[-1] for triplet in map(lambda line: line.partition("/"), output) + } -class S6RCStatus(FactBase[dict[str, bool]]): +class S6LiveStatus(FactBase[dict[str, bool]]): """ Returns a dict of name -> status for each service in the live state. - True means s6 is trying to keep the service up; False means the service is not managed by s6. + True when the service is "running", meaning the service is managed by an `s6-supervise`s, False + otherwise. """ - # default = dict - + # could also rewrite this using the "s6 live status" command def requires_command(self): - return "s6-rc" + return "s6" - def check_preconditions(self): - pass + def check_preconditions(self, state, host): + from pyinfra.facts.files import File + if not host.get_fact(File("/etc/s6/frontend.conf")): + return "/etc/s6/frontend.conf doesn't exist" def command(self): - return r"{ s6-rc -a list && echo -e 'GROUP SEPARATOR' && s6-rc -da list ; } || exit 1" + return "s6 live status" def process(self, output): - status = {} - - gs_index = output.index("GROUP SEPARATOR") - enabled_services = output[:gs_index] - disabled_services = output[gs_index + 1 :] - - status.update([(srv, True) for srv in enabled_services]) - status.update([(srv, False) for srv in disabled_services]) - - return status + return { + triple[0]: True if triple[2] == "up" else False + for triple in map(lambda line: line.partition("/"), output) + } diff --git a/src/pyinfra/operations/s6.py b/src/pyinfra/operations/s6.py index 3beab4fb6..cfb881b2b 100644 --- a/src/pyinfra/operations/s6.py +++ b/src/pyinfra/operations/s6.py @@ -2,26 +2,63 @@ from pyinfra import host from pyinfra.api import operation +from pyinfra.facts import S6RCStatus from .util.service import handle_service_control + @operation() -def service(): +def service( + service: str, + running: bool = True, + restarted: bool = False, + reloaded: bool = False, + command: str | None = None, + enabled: bool | None = None, + managed: bool = True, + live: str | None = None, + servicedir: str = "/etc/sv", +): """ Manage the state of s6-supervised services. - + service: - + running: - + restarted: - + reloaded: - + enabled: + + service: name of the service to manage + + running: whether the service should be under an s6-supervise. + + restarted: whether the service should be restarted (with `s6-rc -d change service && s6-rc -u change service`) + + reloaded: whether the service should be reloaded by sending a SIGHUP. + + command: TODO + + enabled: whether the service should be given an "active" or "usable" prescription + + live: path to the live state directory, using the compiled-in value by default (which is probably /run/s6-rc). + + """ + # TODO "always" rx, "masked" rx # reloaded? that would be service-dependent signal. accept it anyway, implement as SIGHUP # TODO statuses argument fact yield from handle_service_control( - host, - service, - host.get_fact(S6Status) , - "", + host, + service, + host.get_fact(S6RCStatus), + "s6-rc {0} change", + running, + restarted, + reloaded, + command, + ) + + #host: Host, + #name: str, + #statuses: dict[str, bool], + #formatter: str, + #running: bool | None = None, + #restarted: bool | None = None, + #reloaded: bool | None = None, + #command: str | None = None, + #status_argument="status", + + # enable: add it to a set, and commit that set. + if isinstance(enabled, bool): + yield StringCommand(f"s6-rc-set-change ") - ) +# running: s6-rc -d/-u change +# restarted: s6-rc -d change && s6-rc -u change +# reloaded: s6-svc -h diff --git a/tests/facts/s6.S6LiveStatus/no_running_services.yaml b/tests/facts/s6.S6LiveStatus/no_running_services.yaml new file mode 100644 index 000000000..60112dff2 --- /dev/null +++ b/tests/facts/s6.S6LiveStatus/no_running_services.yaml @@ -0,0 +1,13 @@ +command: "s6 live status" +requires_command: "s6" +output: | + NetworkManager-srv/down + NetworkManager-log/down + avahi-daemon-log/down + avahi-daemon-srv/down +fact: + NetworkManager-srv: false + NetworkManager-log: false + avahi-daemon-log: false + avahi-daemon-srv: false + diff --git a/tests/facts/s6.S6LiveStatus/no_stopped_services.yaml b/tests/facts/s6.S6LiveStatus/no_stopped_services.yaml new file mode 100644 index 000000000..c31915dff --- /dev/null +++ b/tests/facts/s6.S6LiveStatus/no_stopped_services.yaml @@ -0,0 +1,12 @@ +command: "s6 live status" +requires_command: "s6" +output: | + NetworkManager-log/up + NetworkManager-srv/up + avahi-daemon-srv/up + avahi-daemon-log/up +fact: + NetworkManager-srv: true + NetworkManager-log: true + avahi-daemon-srv: true + avahi-daemon-log: true diff --git a/tests/facts/s6.S6LiveStatus/standard.yaml b/tests/facts/s6.S6LiveStatus/standard.yaml new file mode 100644 index 000000000..7edc2ab7c --- /dev/null +++ b/tests/facts/s6.S6LiveStatus/standard.yaml @@ -0,0 +1,13 @@ +command: "s6 live status" +requires_command: "s6" +output: | + NetworkManager-log/up + NetworkManager-srv/up + avahi-daemon-srv/down + avahi-daemon-log/down +fact: + NetworkManager-log: true + NetworkManager-srv: true + avahi-daemon-log: false + avahi-daemon-srv: false + diff --git a/tests/facts/s6.S6RCEnabled/nonstandard_repository_location.yaml b/tests/facts/s6.S6RCEnabled/nonstandard_repository_location.yaml deleted file mode 100644 index fe85b5566..000000000 --- a/tests/facts/s6.S6RCEnabled/nonstandard_repository_location.yaml +++ /dev/null @@ -1,25 +0,0 @@ -arg: - - "default" - - "/etc/s6/repo" -command: "s6-rc-set-status -r /etc/s6/repo default" -requires_command: "s6-rc-set-status" -output: | - swap/always - dmesg-srv/always - dmesg-log/always - tty1/active - mount-net/active - network-detection/active - tlp/usable - avahi-daemon-srv/usable - avahi-daemon-log/usable -fact: - swap: always - dmesg-srv: always - dmesg-log: always - tty1: active - mount-net: active - network-detection: active - tlp: usable - avahi-daemon-srv: usable - avahi-daemon-log: usable diff --git a/tests/facts/s6.S6RCEnabled/standard.yaml b/tests/facts/s6.S6RCEnabled/standard.yaml deleted file mode 100644 index a5df184c9..000000000 --- a/tests/facts/s6.S6RCEnabled/standard.yaml +++ /dev/null @@ -1,24 +0,0 @@ -arg: - - "default" -command: "s6-rc-set-status default" -requires_command: "s6-rc-set-status" -output: | - swap/always - dmesg-srv/always - dmesg-log/always - tty1/active - mount-net/active - network-detection/active - tlp/usable - avahi-daemon-srv/usable - avahi-daemon-log/usable -fact: - swap: always - dmesg-srv: always - dmesg-log: always - tty1: active - mount-net: active - network-detection: active - tlp: usable - avahi-daemon-srv: usable - avahi-daemon-log: usable diff --git a/tests/facts/s6.S6RCSets/standard.yaml b/tests/facts/s6.S6RCSets/standard.yaml deleted file mode 100644 index 524ef3b16..000000000 --- a/tests/facts/s6.S6RCSets/standard.yaml +++ /dev/null @@ -1,8 +0,0 @@ -command: "s6-rc-repo-list" -requires_command: "s6-rc-repo-list" -output: | - default - current - normal -fact: - [ "default", "current", "normal" ] diff --git a/tests/facts/s6.S6RCStatus/no_running_services.yaml b/tests/facts/s6.S6RCStatus/no_running_services.yaml deleted file mode 100644 index 8c1ef7368..000000000 --- a/tests/facts/s6.S6RCStatus/no_running_services.yaml +++ /dev/null @@ -1,14 +0,0 @@ -command: "{ s6-rc -a list && echo -e 'GROUP SEPARATOR' && s6-rc -da list ; } || exit 1" -requires_command: "s6-rc" -output: | - GROUP SEPARATOR - cupsd - usbguard - avahi-daemon-log - avahi-daemon-srv -fact: - cupsd: false - usbguard: false - avahi-daemon-log: false - avahi-daemon-srv: false - diff --git a/tests/facts/s6.S6RCStatus/no_stopped_services.yaml b/tests/facts/s6.S6RCStatus/no_stopped_services.yaml deleted file mode 100644 index 194d81426..000000000 --- a/tests/facts/s6.S6RCStatus/no_stopped_services.yaml +++ /dev/null @@ -1,40 +0,0 @@ -command: "{ s6-rc -a list && echo -e 'GROUP SEPARATOR' && s6-rc -da list ; } || exit 1" -requires_command: "s6-rc" -output: | - NetworkManager-log - NetworkManager-srv - bluetoothd-log - bluetoothd-srv - dbus-log - dbus-srv - seatd-log - seatd-srv - tty1 - tty2 - tty3 - mount-net - network-detection - modules - net-lo - swap - udevadm - GROUP SEPARATOR -fact: - NetworkManager-log: true - NetworkManager-srv: true - bluetoothd-log: true - bluetoothd-srv: true - dbus-log: true - dbus-srv: true - seatd-log: true - seatd-srv: true - tty1: true - tty2: true - tty3: true - mount-net: true - network-detection: true - modules: true - net-lo: true - swap: true - udevadm: true - diff --git a/tests/facts/s6.S6RCStatus/standard.yaml b/tests/facts/s6.S6RCStatus/standard.yaml deleted file mode 100644 index a41d33978..000000000 --- a/tests/facts/s6.S6RCStatus/standard.yaml +++ /dev/null @@ -1,48 +0,0 @@ -command: "{ s6-rc -a list && echo -e 'GROUP SEPARATOR' && s6-rc -da list ; } || exit 1" -requires_command: "s6-rc" -output: | - NetworkManager-log - NetworkManager-srv - bluetoothd-log - bluetoothd-srv - dbus-log - dbus-srv - seatd-log - seatd-srv - tty1 - tty2 - tty3 - mount-net - network-detection - modules - net-lo - swap - udevadm - GROUP SEPARATOR - cupsd - usbguard - avahi-daemon-log - avahi-daemon-srv -fact: - NetworkManager-log: true - NetworkManager-srv: true - bluetoothd-log: true - bluetoothd-srv: true - dbus-log: true - dbus-srv: true - seatd-log: true - seatd-srv: true - tty1: true - tty2: true - tty3: true - mount-net: true - network-detection: true - modules: true - net-lo: true - swap: true - udevadm: true - cupsd: false - usbguard: false - avahi-daemon-log: false - avahi-daemon-srv: false - diff --git a/tests/facts/s6.S6RCSets/nonstandard_repository_location.yaml b/tests/facts/s6.S6RepositoryList/nonstandard_repository.yaml similarity index 60% rename from tests/facts/s6.S6RCSets/nonstandard_repository_location.yaml rename to tests/facts/s6.S6RepositoryList/nonstandard_repository.yaml index d1f0f0225..9ca57adfa 100644 --- a/tests/facts/s6.S6RCSets/nonstandard_repository_location.yaml +++ b/tests/facts/s6.S6RepositoryList/nonstandard_repository.yaml @@ -1,11 +1,11 @@ -arg: +arg: - "/etc/s6/repo" command: "s6-rc-repo-list -r /etc/s6/repo" requires_command: "s6-rc-repo-list" output: | default - current - normal + minimal + recovery + kiosk fact: - [ "default", "current", "normal" ] - + [ "default", "minimal", "recovery", "kiosk" ] diff --git a/tests/facts/s6.S6RepositoryList/standard.yaml b/tests/facts/s6.S6RepositoryList/standard.yaml new file mode 100644 index 000000000..e0b3413ca --- /dev/null +++ b/tests/facts/s6.S6RepositoryList/standard.yaml @@ -0,0 +1,9 @@ +command: "s6 repository list" +requires_command: "s6" +output: | + default + minimal + recovery + kiosk +fact: + [ "default", "minimal", "recovery", "kiosk" ] diff --git a/tests/facts/s6.S6SetStatus/nonstandard_repository.yaml b/tests/facts/s6.S6SetStatus/nonstandard_repository.yaml new file mode 100644 index 000000000..a48b2dd84 --- /dev/null +++ b/tests/facts/s6.S6SetStatus/nonstandard_repository.yaml @@ -0,0 +1,16 @@ +arg: + repository: "/etc/s6/repo" +command: "s6-rc-set-status -r /etc/s6/repo current" +requires_command: "s6-rc-set-status" +output: | + swap/always + NetworkManager-srv/active + NetworkManager-log/active + avahi-daemon-srv/usable + avahi-daemon-log/usable +fact: + swap: always + NetworkManager-srv: active + NetworkManager-log: active + avahi-daemon-srv: usable + avahi-daemon-log: usable diff --git a/tests/facts/s6.S6SetStatus/nonstandard_repository_set.yaml b/tests/facts/s6.S6SetStatus/nonstandard_repository_set.yaml new file mode 100644 index 000000000..69b3ad451 --- /dev/null +++ b/tests/facts/s6.S6SetStatus/nonstandard_repository_set.yaml @@ -0,0 +1,13 @@ +arg: + set: "recovery" + repository: "/etc/s6/repo" +command: "s6-rc-set-status -r /etc/s6/repo recovery" +requires_command: "s6-rc-set-status" +output: | + swap/always + tty1/active + ttyS/active +fact: + swap: always + tty1: active + ttyS: active diff --git a/tests/facts/s6.S6SetStatus/nonstandard_set.yaml b/tests/facts/s6.S6SetStatus/nonstandard_set.yaml new file mode 100644 index 000000000..19b883b16 --- /dev/null +++ b/tests/facts/s6.S6SetStatus/nonstandard_set.yaml @@ -0,0 +1,12 @@ +arg: + set: "recovery" +command: "s6-rc-set-status recovery" +requires_command: "s6-rc-set-status" +output: | + swap/always + tty1/active + ttyS/active +fact: + swap: always + tty1: active + ttyS: active diff --git a/tests/facts/s6.S6SetStatus/standard.yaml b/tests/facts/s6.S6SetStatus/standard.yaml new file mode 100644 index 000000000..ec4540425 --- /dev/null +++ b/tests/facts/s6.S6SetStatus/standard.yaml @@ -0,0 +1,14 @@ +command: "s6 set status" +requires_command: "s6" +output: | + swap/always + NetworkManager-srv/active + NetworkManager-log/active + avahi-daemon-srv/usable + avahi-daemon-log/usable +fact: + swap: always + NetworkManager-srv: active + NetworkManager-log: active + avahi-daemon-srv: usable + avahi-daemon-log: usable From 0c6b22f9f1593f6fd9aba9d7bc24f850db25ad8e Mon Sep 17 00:00:00 2001 From: epicrazzmatazz Date: Sat, 27 Jun 2026 13:09:40 -0400 Subject: [PATCH 04/25] WIP s6 support --- pyinfra-metadata.toml | 6 + src/pyinfra/facts/s6.py | 31 ++- src/pyinfra/operations/s6.py | 213 ++++++++++++++---- .../nonstandard_repository.yaml | 2 +- tests/operations/s6.service/bring_down.yaml | 7 + tests/operations/s6.service/bring_up.yaml | 7 + .../s6.service/dont_restart_if_stopped.yaml | 8 + tests/operations/s6.service/enabled.yaml | 0 .../s6.service/multi_bring_down.yaml | 0 .../operations/s6.service/multi_bring_up.yaml | 0 .../operations/s6.service/multi_enabled.yaml | 0 tests/operations/s6.service/multi_reload.yaml | 0 .../operations/s6.service/multi_restart.yaml | 0 .../s6.service/nonstandard_reload_signal.yaml | 10 + tests/operations/s6.service/reload.yaml | 9 + tests/operations/s6.service/restart.yaml | 9 + 16 files changed, 248 insertions(+), 54 deletions(-) create mode 100644 tests/operations/s6.service/bring_down.yaml create mode 100644 tests/operations/s6.service/bring_up.yaml create mode 100644 tests/operations/s6.service/dont_restart_if_stopped.yaml create mode 100644 tests/operations/s6.service/enabled.yaml create mode 100644 tests/operations/s6.service/multi_bring_down.yaml create mode 100644 tests/operations/s6.service/multi_bring_up.yaml create mode 100644 tests/operations/s6.service/multi_enabled.yaml create mode 100644 tests/operations/s6.service/multi_reload.yaml create mode 100644 tests/operations/s6.service/multi_restart.yaml create mode 100644 tests/operations/s6.service/nonstandard_reload_signal.yaml create mode 100644 tests/operations/s6.service/reload.yaml create mode 100644 tests/operations/s6.service/restart.yaml diff --git a/pyinfra-metadata.toml b/pyinfra-metadata.toml index 7ae88dba4..aeea935c8 100644 --- a/pyinfra-metadata.toml +++ b/pyinfra-metadata.toml @@ -275,6 +275,12 @@ path = "src/pyinfra/facts/launchd.py" type = "fact" tags = ["service-management", "system"] +[pyinfra.plugins."s6-ops"] +name = "s6" +path = "src/pyinfra/operations/s6.py" +type = "operation" +tags = ["service-management"] + [pyinfra.plugins."s6-facts"] name = "s6" path = "src/pyinfra/facts/s6.py" diff --git a/src/pyinfra/facts/s6.py b/src/pyinfra/facts/s6.py index 9a2597146..e3b437175 100644 --- a/src/pyinfra/facts/s6.py +++ b/src/pyinfra/facts/s6.py @@ -1,4 +1,5 @@ -from pyinfra.api import FactBase +from pyinfra.api import FactBase, QuoteString +from pyinfra.api.command import make_formatted_string_command class S6RepositoryList(FactBase[list[str]]): @@ -6,8 +7,10 @@ class S6RepositoryList(FactBase[list[str]]): def check_preconditions(self, state, host): from pyinfra.facts.files import File + + # TODO allow passing S6_FRONTEND_CONF envvar if not host.get_fact(File("/etc/s6/frontend.conf")): - return "/etc/s6/frontend.conf doesn't exist" + return "couldn't read /etc/s6/frontend.conf or it doesn't exist" def requires_command(self, repository=None): # "s6" only sees the repository configured in /etc/s6-frontend.conf @@ -21,7 +24,7 @@ def command(self, repository=None): + repository: path of the repository to inspect, default the one configured in `/etc/s6-frontend.conf`. """ if repository: - return f"s6-rc-repo-list -r {repository}" + return make_formatted_string_command("s6-rc-repo-list -r {0}", QuoteString(repository)) return "s6 repository list" @@ -41,8 +44,10 @@ class S6SetStatus(FactBase[dict[str, str]]): def check_preconditions(self, state, host): from pyinfra.facts.files import File + + # TODO allow passing S6_FRONTEND_CONF envvar if not host.get_fact(File("/etc/s6/frontend.conf")): - return "/etc/s6/frontend.conf doesn't exist" + return "couldn't read /etc/s6/frontend.conf or it doesn't exist" def requires_command(self, repository=None, set=None): if repository or set: @@ -57,16 +62,18 @@ def command(self, set=None, repository=None): """ if set: if repository: - return f"s6-rc-set-status -r {repository} {set}" + return make_formatted_string_command( + "s6-rc-set-status -r {0} {1}", QuoteString(repository), QuoteString(set) + ) - return f"s6-rc-set-status {set}" + return make_formatted_string_command("s6-rc-set-status {0}", QuoteString(set)) if repository: - if set: - return f"s6-rc-set-status -r {repository} {set}" - - return f"s6-rc-set-status -r {repository} current" + return make_formatted_string_command( + "s6-rc-set-status -r {0} current", QuoteString(repository) + ) + # TODO consider case where util-linux triggers column pretty printing return "s6 set status" def process(self, output): @@ -89,8 +96,10 @@ def requires_command(self): def check_preconditions(self, state, host): from pyinfra.facts.files import File + + # TODO allow passing S6_FRONTEND_CONF envvar if not host.get_fact(File("/etc/s6/frontend.conf")): - return "/etc/s6/frontend.conf doesn't exist" + return "couldn't read /etc/s6/frontend.conf or it doesn't exist" def command(self): return "s6 live status" diff --git a/src/pyinfra/operations/s6.py b/src/pyinfra/operations/s6.py index cfb881b2b..8a09d78ab 100644 --- a/src/pyinfra/operations/s6.py +++ b/src/pyinfra/operations/s6.py @@ -1,64 +1,193 @@ """Manage s6-rc services (https://www.skarnet.org/software/s6-rc/).""" +from operator import itemgetter +from collections.abc import Iterable + from pyinfra import host -from pyinfra.api import operation -from pyinfra.facts import S6RCStatus +from pyinfra.api import QuoteString, operation +from pyinfra.api.command import make_formatted_string_command +from pyinfra.facts.s6 import S6LiveStatus + -from .util.service import handle_service_control +# for now, no support for custom repository; only the s6-frontend one. +# but should get this at some point, as it allows for user-managed (i.e. non-root) services +@operation() +def set( + set: str = "current", + present: bool = True, + force_save: bool = False, + backup: bool = True, +): + """ + Manage sets in a repository. + + + set: name of the set to manage. + + present: whether the set should be present in the repository. + + force_save: whether to overwrite existing sets. + + backup: whether to backup overwritten sets by appending the date to the directory name. + """ + if not present: + yield make_formatted_string_command("s6 set delete {0}", QuoteString(set)) + if force_save: + yield make_formatted_string_command("s6 set save -f {0}", QuoteString(set)) + else: + yield make_formatted_string_command("s6 set save {0}", QuoteString(set)) + + "s6-rc-set-new" + "s6-rc-set-copy" + "s6-rc-set-delete" + # run after each update to check consistency, but don't autofix + "s6-rc-set-fix" + + +# TODO operation for set commit? + + +# TODO server.service compatibility (must use a string for services in that implementation) @operation() def service( - service: str, + services: str | Iterable[str], running: bool = True, restarted: bool = False, reloaded: bool = False, command: str | None = None, enabled: bool | None = None, - managed: bool = True, - live: str | None = None, - servicedir: str = "/etc/sv", + reload_signal: str = "SIGHUP", + repo: str | None = None, + set: str | None = None, + enabled_rx: str = "active", + disabled_rx: str = "usable", ): """ Manage the state of s6-supervised services. - + service: name of the service to manage - + running: whether the service should be under an s6-supervise. - + restarted: whether the service should be restarted (with `s6-rc -d change service && s6-rc -u change service`) - + reloaded: whether the service should be reloaded by sending a SIGHUP. + + services: name(s) of the service(s) to manage. + + running: whether the service(s) should be under an s6-supervise. + + restarted: whether the service(s) should be restarted (with `s6-rc -d change service && s6-rc -u change service`) + + reloaded: whether the service(s) should be reloaded by sending a SIGHUP. Whether the service is reloaded depends on how it handles SIGHUP. + command: TODO + enabled: whether the service should be given an "active" or "usable" prescription - + live: path to the live state directory, using the compiled-in value by default (which is probably /run/s6-rc). - + + + reload_signal: the signal to send to the service(s) when a reload is desired. + + repo: name of the repository to use when managing enabled status, using the one configured in s6-frontend.conf by default. + + set: name of the set to use when managing enabled status, using the set named "current" by default. + + enabled_rx: name of the prescription to assign to the service(s) when enabled, which could be either "active" or "always" + + disabled_rx: name of the prescription to assign to the service(s) when disabled, which could be either "usable" or "masked" + + If multiple services are specified, s6 will automatically handle dependency management. """ - # TODO "always" rx, "masked" rx - # reloaded? that would be service-dependent signal. accept it anyway, implement as SIGHUP - # TODO statuses argument fact - yield from handle_service_control( - host, - service, - host.get_fact(S6RCStatus), - "s6-rc {0} change", - running, - restarted, - reloaded, - command, + + if enabled_rx not in {"active", "always"}: + raise ValueError('enabled_rx must be either "active" or "always"') + if disabled_rx not in {"usable", "masked"}: + raise ValueError('disabled_rx must be either "usable" or "masked"') + + # because iterable unpacking is used + if isinstance(services, str): + services = (services,) + + all_status = host.get_fact(S6LiveStatus).data + # Tuple[bool] of status of each service in services arg + specified_status = ( + itemgetter(*services)(all_status) if len(all_status) != 1 else (all_status[services[0]]), ) + all_running = True if all(specified_status) else False + # all_running = True if all(itemgetter(*services)(all_status)) else False + + services_concat_string = QuoteString(" ".join(services)) + + # breakpoint() + + # === + # idempotency logic + # === + + if not running: + if all_running: + yield make_formatted_string_command("s6 live stop {0}", services_concat_string) + elif len(services) == 1: + host.noop(f"service {' '.join(services)} is stopped") + else: + host.noop(f"services {' '.join(services)} are stopped") + + if running: + if not all_running: + yield make_formatted_string_command("s6 live start {0}", services_concat_string) + elif len(services) == 1: + host.noop(f"service {' '.join(services)} is running") + else: + host.noop(f"service {' '.join(services)} are running") + + # TODO if restart requested, only restart the already running services + if restarted and all_running: + yield make_formatted_string_command("s6 live restart {0}", services_concat_string) + + if reloaded and all_running: + yield make_formatted_string_command( + "s6 process kill -s {0} {1}", reload_signal, services_concat_string + ) + + # === + # enable/disable services + # === + + # TODO case "unmasked" + enabled_subcommand = "make-essential" if enabled_rx == "always" else "enable" + disabled_subcommand = "mask" if disabled_rx == "masked" else "disable" + + if enabled: + if repo and set: + yield make_formatted_string_command( + "s6-rc-set-change -r {0} {1} {2} {3}", + QuoteString(repo), + QuoteString(set), + QuoteString(enabled_rx), + services_concat_string, + ) + elif not repo and set: + yield make_formatted_string_command( + "s6-rc-set-change {0} {1} {2}", + QuoteString(set), + QuoteString(enabled_rx), + services_concat_string, + ) + elif repo and not set: + yield make_formatted_string_command( + "s6-rc-set-change -r {0} current {1} {2}", + QuoteString(repo), + QuoteString(enabled_rx), + services_concat_string, + ) + else: + yield make_formatted_string_command( + "s6 set {0} {1}", enabled_subcommand, services_concat_string + ) - #host: Host, - #name: str, - #statuses: dict[str, bool], - #formatter: str, - #running: bool | None = None, - #restarted: bool | None = None, - #reloaded: bool | None = None, - #command: str | None = None, - #status_argument="status", - - # enable: add it to a set, and commit that set. - if isinstance(enabled, bool): - yield StringCommand(f"s6-rc-set-change ") - -# running: s6-rc -d/-u change -# restarted: s6-rc -d change && s6-rc -u change -# reloaded: s6-svc -h + elif enabled is False: + if repo and set: + yield make_formatted_string_command( + "s6-rc-set-change -r {0} {1} {2} {3}", + QuoteString(repo), + QuoteString(set), + QuoteString(disabled_rx), + services_concat_string, + ) + elif not repo and set: + yield make_formatted_string_command( + "s6-rc-set-change {0} {1} {2}", + QuoteString(set), + QuoteString(disabled_rx), + services_concat_string, + ) + elif repo and not set: + yield make_formatted_string_command( + "s6-rc-set-change -r {0} current {1} {2}", + QuoteString(repo), + QuoteString(disabled_rx), + services_concat_string, + ) + else: + yield make_formatted_string_command( + "s6 set {0} {1}", disabled_subcommand, services_concat_string + ) diff --git a/tests/facts/s6.S6RepositoryList/nonstandard_repository.yaml b/tests/facts/s6.S6RepositoryList/nonstandard_repository.yaml index 9ca57adfa..4b63dc4da 100644 --- a/tests/facts/s6.S6RepositoryList/nonstandard_repository.yaml +++ b/tests/facts/s6.S6RepositoryList/nonstandard_repository.yaml @@ -1,5 +1,5 @@ arg: - - "/etc/s6/repo" + repository: "/etc/s6/repo" command: "s6-rc-repo-list -r /etc/s6/repo" requires_command: "s6-rc-repo-list" output: | diff --git a/tests/operations/s6.service/bring_down.yaml b/tests/operations/s6.service/bring_down.yaml new file mode 100644 index 000000000..701f3921b --- /dev/null +++ b/tests/operations/s6.service/bring_down.yaml @@ -0,0 +1,7 @@ +args: + - tipidee +facts: + s6.S6LiveStatus: + tipidee: true +commands: + - "s6 live stop tipidee" diff --git a/tests/operations/s6.service/bring_up.yaml b/tests/operations/s6.service/bring_up.yaml new file mode 100644 index 000000000..904cf4783 --- /dev/null +++ b/tests/operations/s6.service/bring_up.yaml @@ -0,0 +1,7 @@ +args: + - tipidee +facts: + s6.S6LiveStatus: + tipidee: false +commands: + - "s6 live start tipidee" diff --git a/tests/operations/s6.service/dont_restart_if_stopped.yaml b/tests/operations/s6.service/dont_restart_if_stopped.yaml new file mode 100644 index 000000000..1c54a190e --- /dev/null +++ b/tests/operations/s6.service/dont_restart_if_stopped.yaml @@ -0,0 +1,8 @@ +args: + - tipidee +kwargs: + restarted: true +facts: + s6.S6LiveStatus: + tipidee: false +commands: null diff --git a/tests/operations/s6.service/enabled.yaml b/tests/operations/s6.service/enabled.yaml new file mode 100644 index 000000000..e69de29bb diff --git a/tests/operations/s6.service/multi_bring_down.yaml b/tests/operations/s6.service/multi_bring_down.yaml new file mode 100644 index 000000000..e69de29bb diff --git a/tests/operations/s6.service/multi_bring_up.yaml b/tests/operations/s6.service/multi_bring_up.yaml new file mode 100644 index 000000000..e69de29bb diff --git a/tests/operations/s6.service/multi_enabled.yaml b/tests/operations/s6.service/multi_enabled.yaml new file mode 100644 index 000000000..e69de29bb diff --git a/tests/operations/s6.service/multi_reload.yaml b/tests/operations/s6.service/multi_reload.yaml new file mode 100644 index 000000000..e69de29bb diff --git a/tests/operations/s6.service/multi_restart.yaml b/tests/operations/s6.service/multi_restart.yaml new file mode 100644 index 000000000..e69de29bb diff --git a/tests/operations/s6.service/nonstandard_reload_signal.yaml b/tests/operations/s6.service/nonstandard_reload_signal.yaml new file mode 100644 index 000000000..bc0ef2769 --- /dev/null +++ b/tests/operations/s6.service/nonstandard_reload_signal.yaml @@ -0,0 +1,10 @@ +args: + - tipidee +kwargs: + reloaded: true + reload_signal: SIGUSR1 +facts: + s6.S6LiveStatus: + tipidee: true +commands: + - s6 process kill -s SIGUSR1 tipidee diff --git a/tests/operations/s6.service/reload.yaml b/tests/operations/s6.service/reload.yaml new file mode 100644 index 000000000..a4fa1f49c --- /dev/null +++ b/tests/operations/s6.service/reload.yaml @@ -0,0 +1,9 @@ +args: + - tipidee +kwargs: + reloaded: true +facts: + s6.S6LiveStatus: + tipidee: true +commands: + - s6 process kill -s SIGHUP tipidee diff --git a/tests/operations/s6.service/restart.yaml b/tests/operations/s6.service/restart.yaml new file mode 100644 index 000000000..93a45cab5 --- /dev/null +++ b/tests/operations/s6.service/restart.yaml @@ -0,0 +1,9 @@ +args: + - tipidee +kwargs: + restarted: true +facts: + s6.S6LiveStatus: + tipidee: true +commands: + - "s6 live restart tipidee" From 0b167846446adf4e60b9e918be6f333fc4535e66 Mon Sep 17 00:00:00 2001 From: epicrazzmatazz Date: Sat, 27 Jun 2026 20:15:10 -0400 Subject: [PATCH 05/25] WIP s6 support --- src/pyinfra/operations/s6.py | 106 +++++++++++++----- .../s6.S6LiveStatus/no_running_services.yaml | 4 +- .../s6.S6LiveStatus/no_stopped_services.yaml | 4 +- tests/facts/s6.S6LiveStatus/standard.yaml | 4 +- .../nonstandard_repository.yaml | 8 +- tests/facts/s6.S6RepositoryList/standard.yaml | 6 +- .../nonstandard_repository.yaml | 6 +- .../nonstandard_repository_set.yaml | 8 +- .../facts/s6.S6SetStatus/nonstandard_set.yaml | 6 +- tests/facts/s6.S6SetStatus/standard.yaml | 4 +- tests/operations/s6.service/bring_down.yaml | 4 +- tests/operations/s6.service/bring_up.yaml | 2 +- .../s6.service/dont_restart_if_stopped.yaml | 4 +- .../s6.service/multi_bring_down.yaml | 11 ++ .../operations/s6.service/multi_bring_up.yaml | 9 ++ .../s6.service/multi_partial_restart.yaml | 12 ++ tests/operations/s6.service/multi_reload.yaml | 11 ++ .../operations/s6.service/multi_restart.yaml | 11 ++ tests/operations/s6.service/restart.yaml | 2 +- 19 files changed, 167 insertions(+), 55 deletions(-) create mode 100644 tests/operations/s6.service/multi_partial_restart.yaml diff --git a/src/pyinfra/operations/s6.py b/src/pyinfra/operations/s6.py index 8a09d78ab..ca9d30699 100644 --- a/src/pyinfra/operations/s6.py +++ b/src/pyinfra/operations/s6.py @@ -2,6 +2,7 @@ from operator import itemgetter from collections.abc import Iterable +from itertools import chain from pyinfra import host from pyinfra.api import QuoteString, operation @@ -87,46 +88,99 @@ def service( if isinstance(services, str): services = (services,) - all_status = host.get_fact(S6LiveStatus).data # Tuple[bool] of status of each service in services arg - specified_status = ( - itemgetter(*services)(all_status) if len(all_status) != 1 else (all_status[services[0]]), - ) - all_running = True if all(specified_status) else False + # specified_status = ( + # itemgetter(*services)(all_status) if len(all_status) != 1 else (all_status[services[0]]), + # ) + # dict[str, bool] of status of each service in services arg # all_running = True if all(itemgetter(*services)(all_status)) else False - services_concat_string = QuoteString(" ".join(services)) + # dict[str, bool] whether the services given in the services arg are running. + statuses = {srv: host.get_fact(S6LiveStatus).data[srv] for srv in services} + all_up = all(statuses.values()) + some_up = any(statuses.values()) - # breakpoint() + services_concat_string = QuoteString(" ".join(services)) + running_services_concat_string = QuoteString( + " ".join([srv for srv, status in statuses.items() if status]) + ) # === # idempotency logic # === - if not running: - if all_running: - yield make_formatted_string_command("s6 live stop {0}", services_concat_string) - elif len(services) == 1: - host.noop(f"service {' '.join(services)} is stopped") - else: - host.noop(f"services {' '.join(services)} are stopped") + all_down_services = [srv for srv, stat in statuses.items() if not stat] + all_up_services = [srv for srv, stat in statuses.items() if stat] + # requested to bring up given services + # bring up all specified services that are down if running: - if not all_running: - yield make_formatted_string_command("s6 live start {0}", services_concat_string) - elif len(services) == 1: - host.noop(f"service {' '.join(services)} is running") + if not all_up: + yield make_formatted_string_command( + # e.g. "s6 live start {0} {1} {2} {3}" if there are 4 down services + "s6 live start " + " ".join([f"{{{i}}}" for i in range(len(all_down_services))]), + *map(QuoteString, all_down_services), + ) + else: + host.noop(f"all specified services are already up: {services}") + + # requested to bring down given services + # bring down all specified services that are up + else: + if some_up: + yield make_formatted_string_command( + "s6 live stop " + " ".join([f"{{{i}}}" for i in range(len(all_up_services))]), + *map(QuoteString, all_up_services), + ) else: - host.noop(f"service {' '.join(services)} are running") + host.noop(f"all specified services are already down: {services}") - # TODO if restart requested, only restart the already running services - if restarted and all_running: - yield make_formatted_string_command("s6 live restart {0}", services_concat_string) + # only restart services that are up + if restarted: + if some_up: + yield make_formatted_string_command( + "s6 live restart " + " ".join([f"{{{i}}}" for i in range(len(all_up_services))]), + *map(QuoteString, all_up_services), + ) + else: + host.noop(f"all specified services are down: {services}") - if reloaded and all_running: - yield make_formatted_string_command( - "s6 process kill -s {0} {1}", reload_signal, services_concat_string - ) + # only reload services that are up + if reloaded: + if some_up: + yield make_formatted_string_command( + "s6 process kill -s {0} " + + " ".join([f"{{{i+1}}}" for i in range(len(all_up_services))]), + QuoteString(reload_signal), + *map(QuoteString, all_up_services), + ) + else: + host.noop(f"all specified services are down: {services}") + + # if not running: + # if all_up: + # yield make_formatted_string_command("s6 live stop {0}", services_concat_string) + # elif len(services) == 1: + # host.noop(f"service {' '.join(services)} is stopped") + # else: + # host.noop(f"services {' '.join(services)} are stopped") + + # if running: + # if not all_up: + # yield make_formatted_string_command("s6 live start {0}", services_concat_string) + # elif len(services) == 1: + # host.noop(f"service {' '.join(services)} is running") + # else: + # host.noop(f"service {' '.join(services)} are running") + + # if restarted and some_up: + # # restarts only the running services + # yield make_formatted_string_command("s6 live restart {0}", running_services_concat_string) + + # if reloaded and all_up: + # yield make_formatted_string_command( + # "s6 process kill -s {0} {1}", reload_signal, services_concat_string + # ) # === # enable/disable services diff --git a/tests/facts/s6.S6LiveStatus/no_running_services.yaml b/tests/facts/s6.S6LiveStatus/no_running_services.yaml index 60112dff2..c7ee5e582 100644 --- a/tests/facts/s6.S6LiveStatus/no_running_services.yaml +++ b/tests/facts/s6.S6LiveStatus/no_running_services.yaml @@ -1,5 +1,5 @@ -command: "s6 live status" -requires_command: "s6" +command: s6 live status +requires_command: s6 output: | NetworkManager-srv/down NetworkManager-log/down diff --git a/tests/facts/s6.S6LiveStatus/no_stopped_services.yaml b/tests/facts/s6.S6LiveStatus/no_stopped_services.yaml index c31915dff..6992300a0 100644 --- a/tests/facts/s6.S6LiveStatus/no_stopped_services.yaml +++ b/tests/facts/s6.S6LiveStatus/no_stopped_services.yaml @@ -1,5 +1,5 @@ -command: "s6 live status" -requires_command: "s6" +command: s6 live status +requires_command: s6 output: | NetworkManager-log/up NetworkManager-srv/up diff --git a/tests/facts/s6.S6LiveStatus/standard.yaml b/tests/facts/s6.S6LiveStatus/standard.yaml index 7edc2ab7c..06ef46f71 100644 --- a/tests/facts/s6.S6LiveStatus/standard.yaml +++ b/tests/facts/s6.S6LiveStatus/standard.yaml @@ -1,5 +1,5 @@ -command: "s6 live status" -requires_command: "s6" +command: s6 live status +requires_command: s6 output: | NetworkManager-log/up NetworkManager-srv/up diff --git a/tests/facts/s6.S6RepositoryList/nonstandard_repository.yaml b/tests/facts/s6.S6RepositoryList/nonstandard_repository.yaml index 4b63dc4da..97fb73897 100644 --- a/tests/facts/s6.S6RepositoryList/nonstandard_repository.yaml +++ b/tests/facts/s6.S6RepositoryList/nonstandard_repository.yaml @@ -1,11 +1,11 @@ arg: - repository: "/etc/s6/repo" -command: "s6-rc-repo-list -r /etc/s6/repo" -requires_command: "s6-rc-repo-list" + repository: /etc/s6/repo +command: s6-rc-repo-list -r /etc/s6/repo +requires_command: s6-rc-repo-list output: | default minimal recovery kiosk fact: - [ "default", "minimal", "recovery", "kiosk" ] + [ default, minimal, recovery, kiosk ] diff --git a/tests/facts/s6.S6RepositoryList/standard.yaml b/tests/facts/s6.S6RepositoryList/standard.yaml index e0b3413ca..82cf09dd6 100644 --- a/tests/facts/s6.S6RepositoryList/standard.yaml +++ b/tests/facts/s6.S6RepositoryList/standard.yaml @@ -1,9 +1,9 @@ -command: "s6 repository list" -requires_command: "s6" +command: s6 repository list +requires_command: s6 output: | default minimal recovery kiosk fact: - [ "default", "minimal", "recovery", "kiosk" ] + [ default, minimal, recovery, kiosk ] diff --git a/tests/facts/s6.S6SetStatus/nonstandard_repository.yaml b/tests/facts/s6.S6SetStatus/nonstandard_repository.yaml index a48b2dd84..dbd552eea 100644 --- a/tests/facts/s6.S6SetStatus/nonstandard_repository.yaml +++ b/tests/facts/s6.S6SetStatus/nonstandard_repository.yaml @@ -1,7 +1,7 @@ arg: - repository: "/etc/s6/repo" -command: "s6-rc-set-status -r /etc/s6/repo current" -requires_command: "s6-rc-set-status" + repository: /etc/s6/repo +command: s6-rc-set-status -r /etc/s6/repo current +requires_command: s6-rc-set-status output: | swap/always NetworkManager-srv/active diff --git a/tests/facts/s6.S6SetStatus/nonstandard_repository_set.yaml b/tests/facts/s6.S6SetStatus/nonstandard_repository_set.yaml index 69b3ad451..d2b7c4508 100644 --- a/tests/facts/s6.S6SetStatus/nonstandard_repository_set.yaml +++ b/tests/facts/s6.S6SetStatus/nonstandard_repository_set.yaml @@ -1,8 +1,8 @@ arg: - set: "recovery" - repository: "/etc/s6/repo" -command: "s6-rc-set-status -r /etc/s6/repo recovery" -requires_command: "s6-rc-set-status" + set: recovery + repository: /etc/s6/repo +command: s6-rc-set-status -r /etc/s6/repo recovery +requires_command: s6-rc-set-status output: | swap/always tty1/active diff --git a/tests/facts/s6.S6SetStatus/nonstandard_set.yaml b/tests/facts/s6.S6SetStatus/nonstandard_set.yaml index 19b883b16..c4f7203cc 100644 --- a/tests/facts/s6.S6SetStatus/nonstandard_set.yaml +++ b/tests/facts/s6.S6SetStatus/nonstandard_set.yaml @@ -1,7 +1,7 @@ arg: - set: "recovery" -command: "s6-rc-set-status recovery" -requires_command: "s6-rc-set-status" + set: recovery +command: s6-rc-set-status recovery +requires_command: s6-rc-set-status output: | swap/always tty1/active diff --git a/tests/facts/s6.S6SetStatus/standard.yaml b/tests/facts/s6.S6SetStatus/standard.yaml index ec4540425..8cbd243e8 100644 --- a/tests/facts/s6.S6SetStatus/standard.yaml +++ b/tests/facts/s6.S6SetStatus/standard.yaml @@ -1,5 +1,5 @@ -command: "s6 set status" -requires_command: "s6" +command: s6 set status +requires_command: s6 output: | swap/always NetworkManager-srv/active diff --git a/tests/operations/s6.service/bring_down.yaml b/tests/operations/s6.service/bring_down.yaml index 701f3921b..c3ced0550 100644 --- a/tests/operations/s6.service/bring_down.yaml +++ b/tests/operations/s6.service/bring_down.yaml @@ -1,7 +1,9 @@ args: - tipidee +kwargs: + running: false facts: s6.S6LiveStatus: tipidee: true commands: - - "s6 live stop tipidee" + - s6 live stop tipidee diff --git a/tests/operations/s6.service/bring_up.yaml b/tests/operations/s6.service/bring_up.yaml index 904cf4783..412731084 100644 --- a/tests/operations/s6.service/bring_up.yaml +++ b/tests/operations/s6.service/bring_up.yaml @@ -4,4 +4,4 @@ facts: s6.S6LiveStatus: tipidee: false commands: - - "s6 live start tipidee" + - s6 live start tipidee diff --git a/tests/operations/s6.service/dont_restart_if_stopped.yaml b/tests/operations/s6.service/dont_restart_if_stopped.yaml index 1c54a190e..4ae28ee1d 100644 --- a/tests/operations/s6.service/dont_restart_if_stopped.yaml +++ b/tests/operations/s6.service/dont_restart_if_stopped.yaml @@ -5,4 +5,6 @@ kwargs: facts: s6.S6LiveStatus: tipidee: false -commands: null +commands: + # restarted implies bring up if stopped + - s6 live start tipidee diff --git a/tests/operations/s6.service/multi_bring_down.yaml b/tests/operations/s6.service/multi_bring_down.yaml index e69de29bb..c959f5aeb 100644 --- a/tests/operations/s6.service/multi_bring_down.yaml +++ b/tests/operations/s6.service/multi_bring_down.yaml @@ -0,0 +1,11 @@ +args: + - [ nftables, mysqld, tipidee ] +kwargs: + running: false +facts: + s6.S6LiveStatus: + nftables: true + mysqld: true + tipidee: true +commands: + - s6 live stop nftables mysqld tipidee diff --git a/tests/operations/s6.service/multi_bring_up.yaml b/tests/operations/s6.service/multi_bring_up.yaml index e69de29bb..81266493f 100644 --- a/tests/operations/s6.service/multi_bring_up.yaml +++ b/tests/operations/s6.service/multi_bring_up.yaml @@ -0,0 +1,9 @@ +args: + - [ nftables, mysqld, tipidee ] +facts: + s6.S6LiveStatus: + nftables: false + mysqld: false + tipidee: false +commands: + - s6 live start nftables mysqld tipidee diff --git a/tests/operations/s6.service/multi_partial_restart.yaml b/tests/operations/s6.service/multi_partial_restart.yaml new file mode 100644 index 000000000..b00bf1c4b --- /dev/null +++ b/tests/operations/s6.service/multi_partial_restart.yaml @@ -0,0 +1,12 @@ +args: + - [ nftables, mysqld, tipidee ] +kwargs: + restarted: true +facts: + s6.S6LiveStatus: + nftables: true + mysqld: false + tipidee: true +commands: + - s6 live start mysqld + - s6 live restart nftables tipidee diff --git a/tests/operations/s6.service/multi_reload.yaml b/tests/operations/s6.service/multi_reload.yaml index e69de29bb..7cff5d2e6 100644 --- a/tests/operations/s6.service/multi_reload.yaml +++ b/tests/operations/s6.service/multi_reload.yaml @@ -0,0 +1,11 @@ +args: + - [ nftables, mysqld, tipidee ] +kwargs: + reloaded: true +facts: + s6.S6LiveStatus: + nftables: true + mysqld: true + tipidee: true +commands: + - s6 process kill -s SIGHUP nftables mysqld tipidee diff --git a/tests/operations/s6.service/multi_restart.yaml b/tests/operations/s6.service/multi_restart.yaml index e69de29bb..beab531a6 100644 --- a/tests/operations/s6.service/multi_restart.yaml +++ b/tests/operations/s6.service/multi_restart.yaml @@ -0,0 +1,11 @@ +args: + - [ nftables, mysqld, tipidee ] +kwargs: + restarted: true +facts: + s6.S6LiveStatus: + nftables: true + mysqld: true + tipidee: true +commands: + - s6 live restart nftables mysqld tipidee diff --git a/tests/operations/s6.service/restart.yaml b/tests/operations/s6.service/restart.yaml index 93a45cab5..ac7f3ef50 100644 --- a/tests/operations/s6.service/restart.yaml +++ b/tests/operations/s6.service/restart.yaml @@ -6,4 +6,4 @@ facts: s6.S6LiveStatus: tipidee: true commands: - - "s6 live restart tipidee" + - s6 live restart tipidee From 0fa5dff4063d82e85d3f140964d775265afc9691 Mon Sep 17 00:00:00 2001 From: epicrazzmatazz Date: Sun, 28 Jun 2026 10:12:22 -0400 Subject: [PATCH 06/25] WIP s6 support --- src/pyinfra/operations/s6.py | 306 +++++++----------- tests/operations/s6.service/all_at_once.yaml | 25 ++ tests/operations/s6.service/disable.yaml | 12 + tests/operations/s6.service/enable.yaml | 12 + tests/operations/s6.service/enabled.yaml | 0 .../operations/s6.service/multi_disable.yaml | 14 + tests/operations/s6.service/multi_enable.yaml | 14 + .../operations/s6.service/multi_enabled.yaml | 0 .../s6.service/multi_partial_disable.yaml | 14 + .../s6.service/multi_partial_enable.yaml | 14 + 10 files changed, 227 insertions(+), 184 deletions(-) create mode 100644 tests/operations/s6.service/all_at_once.yaml create mode 100644 tests/operations/s6.service/disable.yaml create mode 100644 tests/operations/s6.service/enable.yaml delete mode 100644 tests/operations/s6.service/enabled.yaml create mode 100644 tests/operations/s6.service/multi_disable.yaml create mode 100644 tests/operations/s6.service/multi_enable.yaml delete mode 100644 tests/operations/s6.service/multi_enabled.yaml create mode 100644 tests/operations/s6.service/multi_partial_disable.yaml create mode 100644 tests/operations/s6.service/multi_partial_enable.yaml diff --git a/src/pyinfra/operations/s6.py b/src/pyinfra/operations/s6.py index ca9d30699..0caeddd0e 100644 --- a/src/pyinfra/operations/s6.py +++ b/src/pyinfra/operations/s6.py @@ -1,62 +1,45 @@ """Manage s6-rc services (https://www.skarnet.org/software/s6-rc/).""" -from operator import itemgetter from collections.abc import Iterable -from itertools import chain from pyinfra import host -from pyinfra.api import QuoteString, operation +from pyinfra.api import QuoteString, StringCommand, operation from pyinfra.api.command import make_formatted_string_command -from pyinfra.facts.s6 import S6LiveStatus +from pyinfra.facts.s6 import S6LiveStatus, S6SetStatus -# for now, no support for custom repository; only the s6-frontend one. -# but should get this at some point, as it allows for user-managed (i.e. non-root) services -@operation() -def set( - set: str = "current", - present: bool = True, - force_save: bool = False, - backup: bool = True, -): +def _make_live_command(op: str, services: Iterable): """ - Manage sets in a repository. - - + set: name of the set to manage. - + present: whether the set should be present in the repository. - + force_save: whether to overwrite existing sets. - + backup: whether to backup overwritten sets by appending the date to the directory name. + + op: the operation, e.g. "start", "stop", "restart". + + services: the service(s) to operate on. """ - if not present: - yield make_formatted_string_command("s6 set delete {0}", QuoteString(set)) - - if force_save: - yield make_formatted_string_command("s6 set save -f {0}", QuoteString(set)) - else: - yield make_formatted_string_command("s6 set save {0}", QuoteString(set)) - - "s6-rc-set-new" - "s6-rc-set-copy" - "s6-rc-set-delete" - # run after each update to check consistency, but don't autofix - "s6-rc-set-fix" + s = " ".join([f"{{{i}}}" for i in range(len(services))]) + yield make_formatted_string_command(f"s6 live {op} " + s, *map(QuoteString, services)) -# TODO operation for set commit? +def _make_set_rx_command(op: str, services: Iterable): + """ + + rx: the operation, one of "enable", "disable", "mask", "unmask", "make-essential". + + services: the service(s) to operate on. + """ + s = " ".join([f"{{{i}}}" for i in range(len(services))]) + yield make_formatted_string_command(f"s6 set {op} " + s, *map(QuoteString, services)) # TODO server.service compatibility (must use a string for services in that implementation) @operation() def service( services: str | Iterable[str], - running: bool = True, + # optional, to separate live management vs set management + running: bool | None = True, restarted: bool = False, reloaded: bool = False, - command: str | None = None, enabled: bool | None = None, reload_signal: str = "SIGHUP", + # TODO repo repo: str | None = None, + # TODO set set: str | None = None, enabled_rx: str = "active", disabled_rx: str = "usable", @@ -68,7 +51,6 @@ def service( + running: whether the service(s) should be under an s6-supervise. + restarted: whether the service(s) should be restarted (with `s6-rc -d change service && s6-rc -u change service`) + reloaded: whether the service(s) should be reloaded by sending a SIGHUP. Whether the service is reloaded depends on how it handles SIGHUP. - + command: TODO + enabled: whether the service should be given an "active" or "usable" prescription + reload_signal: the signal to send to the service(s) when a reload is desired. + repo: name of the repository to use when managing enabled status, using the one configured in s6-frontend.conf by default. @@ -76,7 +58,7 @@ def service( + enabled_rx: name of the prescription to assign to the service(s) when enabled, which could be either "active" or "always" + disabled_rx: name of the prescription to assign to the service(s) when disabled, which could be either "usable" or "masked" - If multiple services are specified, s6 will automatically handle dependency management. + Specifying multiple services is preferred: fewer commands will be executed, especially in the case of changing the enabled status of the service, where the service database is recompiled per command. """ if enabled_rx not in {"active", "always"}: @@ -95,153 +77,109 @@ def service( # dict[str, bool] of status of each service in services arg # all_running = True if all(itemgetter(*services)(all_status)) else False - # dict[str, bool] whether the services given in the services arg are running. - statuses = {srv: host.get_fact(S6LiveStatus).data[srv] for srv in services} - all_up = all(statuses.values()) - some_up = any(statuses.values()) - - services_concat_string = QuoteString(" ".join(services)) - running_services_concat_string = QuoteString( - " ".join([srv for srv, status in statuses.items() if status]) - ) - - # === - # idempotency logic - # === - - all_down_services = [srv for srv, stat in statuses.items() if not stat] - all_up_services = [srv for srv, stat in statuses.items() if stat] - - # requested to bring up given services - # bring up all specified services that are down - if running: - if not all_up: - yield make_formatted_string_command( - # e.g. "s6 live start {0} {1} {2} {3}" if there are 4 down services - "s6 live start " + " ".join([f"{{{i}}}" for i in range(len(all_down_services))]), - *map(QuoteString, all_down_services), - ) - else: - host.noop(f"all specified services are already up: {services}") + # live state management + if running is not None: + + # dict[str, bool] whether the services given in the services arg are running. + live_statuses = {srv: host.get_fact(S6LiveStatus).data[srv] for srv in services} + all_up = all(live_statuses.values()) + some_up = any(live_statuses.values()) + all_down_services = [srv for srv, stat in live_statuses.items() if not stat] + all_up_services = [srv for srv, stat in live_statuses.items() if stat] + + if running: + if not all_up: + yield from _make_live_command("start", all_down_services) + else: + host.noop(f"all specified services are already up: {services}") - # requested to bring down given services - # bring down all specified services that are up - else: - if some_up: - yield make_formatted_string_command( - "s6 live stop " + " ".join([f"{{{i}}}" for i in range(len(all_up_services))]), - *map(QuoteString, all_up_services), - ) - else: - host.noop(f"all specified services are already down: {services}") - - # only restart services that are up - if restarted: - if some_up: - yield make_formatted_string_command( - "s6 live restart " + " ".join([f"{{{i}}}" for i in range(len(all_up_services))]), - *map(QuoteString, all_up_services), - ) - else: - host.noop(f"all specified services are down: {services}") - - # only reload services that are up - if reloaded: - if some_up: - yield make_formatted_string_command( - "s6 process kill -s {0} " - + " ".join([f"{{{i+1}}}" for i in range(len(all_up_services))]), - QuoteString(reload_signal), - *map(QuoteString, all_up_services), - ) - else: - host.noop(f"all specified services are down: {services}") - - # if not running: - # if all_up: - # yield make_formatted_string_command("s6 live stop {0}", services_concat_string) - # elif len(services) == 1: - # host.noop(f"service {' '.join(services)} is stopped") - # else: - # host.noop(f"services {' '.join(services)} are stopped") - - # if running: - # if not all_up: - # yield make_formatted_string_command("s6 live start {0}", services_concat_string) - # elif len(services) == 1: - # host.noop(f"service {' '.join(services)} is running") - # else: - # host.noop(f"service {' '.join(services)} are running") - - # if restarted and some_up: - # # restarts only the running services - # yield make_formatted_string_command("s6 live restart {0}", running_services_concat_string) - - # if reloaded and all_up: - # yield make_formatted_string_command( - # "s6 process kill -s {0} {1}", reload_signal, services_concat_string - # ) - - # === - # enable/disable services - # === - - # TODO case "unmasked" - enabled_subcommand = "make-essential" if enabled_rx == "always" else "enable" - disabled_subcommand = "mask" if disabled_rx == "masked" else "disable" - - if enabled: - if repo and set: - yield make_formatted_string_command( - "s6-rc-set-change -r {0} {1} {2} {3}", - QuoteString(repo), - QuoteString(set), - QuoteString(enabled_rx), - services_concat_string, - ) - elif not repo and set: - yield make_formatted_string_command( - "s6-rc-set-change {0} {1} {2}", - QuoteString(set), - QuoteString(enabled_rx), - services_concat_string, - ) - elif repo and not set: - yield make_formatted_string_command( - "s6-rc-set-change -r {0} current {1} {2}", - QuoteString(repo), - QuoteString(enabled_rx), - services_concat_string, - ) else: - yield make_formatted_string_command( - "s6 set {0} {1}", enabled_subcommand, services_concat_string - ) - - elif enabled is False: - if repo and set: - yield make_formatted_string_command( - "s6-rc-set-change -r {0} {1} {2} {3}", - QuoteString(repo), - QuoteString(set), - QuoteString(disabled_rx), - services_concat_string, - ) - elif not repo and set: - yield make_formatted_string_command( - "s6-rc-set-change {0} {1} {2}", - QuoteString(set), - QuoteString(disabled_rx), - services_concat_string, - ) - elif repo and not set: - yield make_formatted_string_command( - "s6-rc-set-change -r {0} current {1} {2}", - QuoteString(repo), - QuoteString(disabled_rx), - services_concat_string, - ) + if some_up: + yield from _make_live_command("stop", all_up_services) + else: + host.noop(f"all specified services are already down: {services}") + + if restarted: + if some_up: + yield from _make_live_command("restart", all_up_services) + else: + host.noop(f"all specified services are down: {services}") + + if reloaded: + if some_up: + yield make_formatted_string_command( + "s6 process kill -s {0} " + + " ".join([f"{{{i + 1}}}" for i in range(len(all_up_services))]), + QuoteString(reload_signal), + *map(QuoteString, all_up_services), + ) + else: + host.noop(f"all specified services are down: {services}") + + # TODO: if a service is masked, s6 live will always fail to do anything to that service; + # potential solution is to split enabled into another operation + # ERROR CONDITION: a masked service is present in `services` arg. + + # offline set management + if enabled is not None: + + set_statuses = {srv: host.get_fact(S6SetStatus).data[srv] for srv in services} + all_enabled_services = [ + srv for srv, stat in set_statuses.items() if stat in {"active", "always"} + ] + all_disabled_services = [ + srv for srv, stat in set_statuses.items() if stat in {"usable", "masked"} + ] + + if enabled: + if len(all_disabled_services) != 0: + yield from _make_set_rx_command("enable", all_disabled_services) + yield StringCommand("s6 set check -F") + yield StringCommand("s6 set commit") + else: + host.noop(f"all services are already enabled: {services}") + else: - yield make_formatted_string_command( - "s6 set {0} {1}", disabled_subcommand, services_concat_string - ) + if len(all_enabled_services) != 0: + yield from _make_set_rx_command("disable", all_enabled_services) + yield StringCommand("s6 set check -F") + yield StringCommand("s6 set commit") + else: + host.noop(f"all services are already disabled: {services}") + + +# TODO s6 live install is analagous to systemd daemon-reload +# for now, no support for custom repository; only the s6-frontend one. +# but should get this at some point, as it allows for user-managed (i.e. non-root) services +@operation() +def set( + set: str = "current", + present: bool = True, + force_save: bool = False, + backup: bool = True, +): + """ + Manage sets in a repository. + + + set: name of the set to manage. + + present: whether the set should be present in the repository. + + force_save: whether to overwrite existing sets. + + backup: whether to backup overwritten sets by appending the date to the directory name. + """ + + if not present: + yield make_formatted_string_command("s6 set delete {0}", QuoteString(set)) + + if force_save: + yield make_formatted_string_command("s6 set save -f {0}", QuoteString(set)) + else: + yield make_formatted_string_command("s6 set save {0}", QuoteString(set)) + + "s6-rc-set-new" + "s6-rc-set-copy" + "s6-rc-set-delete" + # run after each update to check consistency, but don't autofix + "s6-rc-set-fix" + + +# TODO operation for set commit? diff --git a/tests/operations/s6.service/all_at_once.yaml b/tests/operations/s6.service/all_at_once.yaml new file mode 100644 index 000000000..c420f39e0 --- /dev/null +++ b/tests/operations/s6.service/all_at_once.yaml @@ -0,0 +1,25 @@ +args: + - [ nftables, mysqld, php-fpm, tipidee ] +kwargs: + running: true + restarted: true + reloaded: true + enabled: true +facts: + s6.S6SetStatus: + nftables: "always" + mysqld: "active" + php-fpm: "masked" + tipidee: "usable" + s6.S6LiveStatus: + nftables: true + mysqld: false + php-fpm: false + tipidee: false +commands: + - s6 live start mysqld php-fpm tipidee + - s6 live restart nftables + - s6 process kill -s SIGHUP nftables + - s6 set enable php-fpm tipidee + - s6 set check -F + - s6 set commit diff --git a/tests/operations/s6.service/disable.yaml b/tests/operations/s6.service/disable.yaml new file mode 100644 index 000000000..68a883f94 --- /dev/null +++ b/tests/operations/s6.service/disable.yaml @@ -0,0 +1,12 @@ +args: + - tipidee +kwargs: + running: null + enabled: false +facts: + s6.S6SetStatus: + tipidee: "active" +commands: + - s6 set disable tipidee + - s6 set check -F + - s6 set commit diff --git a/tests/operations/s6.service/enable.yaml b/tests/operations/s6.service/enable.yaml new file mode 100644 index 000000000..f9053fe16 --- /dev/null +++ b/tests/operations/s6.service/enable.yaml @@ -0,0 +1,12 @@ +args: + - tipidee +kwargs: + running: null + enabled: true +facts: + s6.S6SetStatus: + tipidee: "usable" +commands: + - s6 set enable tipidee + - s6 set check -F + - s6 set commit diff --git a/tests/operations/s6.service/enabled.yaml b/tests/operations/s6.service/enabled.yaml deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/operations/s6.service/multi_disable.yaml b/tests/operations/s6.service/multi_disable.yaml new file mode 100644 index 000000000..a4364adee --- /dev/null +++ b/tests/operations/s6.service/multi_disable.yaml @@ -0,0 +1,14 @@ +args: + - [ nftables, mysqld, tipidee ] +kwargs: + running: null + enabled: false +facts: + s6.S6SetStatus: + nftables: "active" + mysqld: "active" + tipidee: "active" +commands: + - s6 set disable nftables mysqld tipidee + - s6 set check -F + - s6 set commit diff --git a/tests/operations/s6.service/multi_enable.yaml b/tests/operations/s6.service/multi_enable.yaml new file mode 100644 index 000000000..fa4ae8340 --- /dev/null +++ b/tests/operations/s6.service/multi_enable.yaml @@ -0,0 +1,14 @@ +args: + - [ nftables, mysqld, tipidee ] +kwargs: + running: null + enabled: true +facts: + s6.S6SetStatus: + nftables: "usable" + mysqld: "usable" + tipidee: "usable" +commands: + - s6 set enable nftables mysqld tipidee + - s6 set check -F + - s6 set commit diff --git a/tests/operations/s6.service/multi_enabled.yaml b/tests/operations/s6.service/multi_enabled.yaml deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/operations/s6.service/multi_partial_disable.yaml b/tests/operations/s6.service/multi_partial_disable.yaml new file mode 100644 index 000000000..a5f1c2c06 --- /dev/null +++ b/tests/operations/s6.service/multi_partial_disable.yaml @@ -0,0 +1,14 @@ +args: + - [ nftables, mysqld, tipidee ] +kwargs: + running: null + enabled: false +facts: + s6.S6SetStatus: + nftables: "active" + mysqld: "usable" + tipidee: "active" +commands: + - s6 set disable nftables tipidee + - s6 set check -F + - s6 set commit diff --git a/tests/operations/s6.service/multi_partial_enable.yaml b/tests/operations/s6.service/multi_partial_enable.yaml new file mode 100644 index 000000000..66e20e231 --- /dev/null +++ b/tests/operations/s6.service/multi_partial_enable.yaml @@ -0,0 +1,14 @@ +args: + - [ nftables, mysqld, tipidee ] +kwargs: + running: null + enabled: true +facts: + s6.S6SetStatus: + nftables: "usable" + mysqld: "active" + tipidee: "usable" +commands: + - s6 set enable nftables tipidee + - s6 set check -F + - s6 set commit From ebd963319352af49ef76c7d2455465cad6ff831e Mon Sep 17 00:00:00 2001 From: epicrazzmatazz Date: Sun, 28 Jun 2026 13:56:34 -0400 Subject: [PATCH 07/25] WIP s6 support --- src/pyinfra/operations/s6.py | 30 +++++++++++-------- src/pyinfra/operations/server.py | 6 +++- tests/operations/server.service/invalid.json | 5 ++-- .../server.service/start_initd.json | 3 +- .../server.service/start_initd_service.json | 3 +- .../operations/server.service/start_rcd.json | 3 +- ...tart_rcd_freebsd_with_service_command.json | 3 +- .../server.service/start_rcd_netbsd.json | 3 +- tests/operations/server.service/start_s6.yaml | 19 ++++++++++++ 9 files changed, 55 insertions(+), 20 deletions(-) create mode 100644 tests/operations/server.service/start_s6.yaml diff --git a/src/pyinfra/operations/s6.py b/src/pyinfra/operations/s6.py index 0caeddd0e..8f5b6774a 100644 --- a/src/pyinfra/operations/s6.py +++ b/src/pyinfra/operations/s6.py @@ -30,11 +30,13 @@ def _make_set_rx_command(op: str, services: Iterable): # TODO server.service compatibility (must use a string for services in that implementation) @operation() def service( - services: str | Iterable[str], + service: str | Iterable[str], # optional, to separate live management vs set management running: bool | None = True, restarted: bool = False, reloaded: bool = False, + # TODO command + command: str | None = None, enabled: bool | None = None, reload_signal: str = "SIGHUP", # TODO repo @@ -49,8 +51,9 @@ def service( + services: name(s) of the service(s) to manage. + running: whether the service(s) should be under an s6-supervise. - + restarted: whether the service(s) should be restarted (with `s6-rc -d change service && s6-rc -u change service`) + + restarted: whether the service(s) should be restarted + reloaded: whether the service(s) should be reloaded by sending a SIGHUP. Whether the service is reloaded depends on how it handles SIGHUP. + + command: custom command to run after the auto-computed commands. + enabled: whether the service should be given an "active" or "usable" prescription + reload_signal: the signal to send to the service(s) when a reload is desired. + repo: name of the repository to use when managing enabled status, using the one configured in s6-frontend.conf by default. @@ -67,8 +70,8 @@ def service( raise ValueError('disabled_rx must be either "usable" or "masked"') # because iterable unpacking is used - if isinstance(services, str): - services = (services,) + if isinstance(service, str): + service = (service,) # Tuple[bool] of status of each service in services arg # specified_status = ( @@ -81,7 +84,7 @@ def service( if running is not None: # dict[str, bool] whether the services given in the services arg are running. - live_statuses = {srv: host.get_fact(S6LiveStatus).data[srv] for srv in services} + live_statuses = {srv: host.get_fact(S6LiveStatus).data[srv] for srv in service} all_up = all(live_statuses.values()) some_up = any(live_statuses.values()) all_down_services = [srv for srv, stat in live_statuses.items() if not stat] @@ -91,19 +94,19 @@ def service( if not all_up: yield from _make_live_command("start", all_down_services) else: - host.noop(f"all specified services are already up: {services}") + host.noop(f"all specified services are already up: {service}") else: if some_up: yield from _make_live_command("stop", all_up_services) else: - host.noop(f"all specified services are already down: {services}") + host.noop(f"all specified services are already down: {service}") if restarted: if some_up: yield from _make_live_command("restart", all_up_services) else: - host.noop(f"all specified services are down: {services}") + host.noop(f"all specified services are down: {service}") if reloaded: if some_up: @@ -114,7 +117,7 @@ def service( *map(QuoteString, all_up_services), ) else: - host.noop(f"all specified services are down: {services}") + host.noop(f"all specified services are down: {service}") # TODO: if a service is masked, s6 live will always fail to do anything to that service; # potential solution is to split enabled into another operation @@ -123,7 +126,7 @@ def service( # offline set management if enabled is not None: - set_statuses = {srv: host.get_fact(S6SetStatus).data[srv] for srv in services} + set_statuses = {srv: host.get_fact(S6SetStatus).data[srv] for srv in service} all_enabled_services = [ srv for srv, stat in set_statuses.items() if stat in {"active", "always"} ] @@ -137,7 +140,7 @@ def service( yield StringCommand("s6 set check -F") yield StringCommand("s6 set commit") else: - host.noop(f"all services are already enabled: {services}") + host.noop(f"all services are already enabled: {service}") else: if len(all_enabled_services) != 0: @@ -145,7 +148,10 @@ def service( yield StringCommand("s6 set check -F") yield StringCommand("s6 set commit") else: - host.noop(f"all services are already disabled: {services}") + host.noop(f"all services are already disabled: {service}") + + if command: + yield StringCommand(command) # TODO s6 live install is analagous to systemd daemon-reload diff --git a/src/pyinfra/operations/server.py b/src/pyinfra/operations/server.py index 448f36d1a..7fbf2ceb2 100644 --- a/src/pyinfra/operations/server.py +++ b/src/pyinfra/operations/server.py @@ -47,6 +47,7 @@ pacman, pkg, runit, + s6, systemd, sysvinit, upstart, @@ -716,6 +717,9 @@ def service( elif host.get_fact(Which, command="sv"): service_operation = runit.service + elif host.get_fact(Which, command="s6"): + service_operation = s6.service + # NOTE: must run before the sysvinit check: BSDs ship `service` in base (distinct from the # Linux sysvinit wrapper), so matching on Which command="service" first would misroute BSD # hosts to sysvinit. See https://github.com/pyinfra-dev/pyinfra/issues/1496. @@ -736,7 +740,7 @@ def service( else: raise OperationError( - ("No init system found (no systemctl, initctl, /etc/init.d or /etc/rc.d found)"), + ("No init system found (no systemctl, rc-service, initctl, sv, s6, /etc/init.d or /etc/rc.d found)"), ) yield from service_operation._inner( diff --git a/tests/operations/server.service/invalid.json b/tests/operations/server.service/invalid.json index d8b0fd902..066c6b05a 100644 --- a/tests/operations/server.service/invalid.json +++ b/tests/operations/server.service/invalid.json @@ -6,7 +6,8 @@ "command=initctl": false, "command=rc-service": false, "command=sv": false, - "command=service": false + "command=service": false, + "command=s6": false }, "files.Directory": { "path=/etc/init.d": false, @@ -19,6 +20,6 @@ }, "exception": { "name": "OperationError", - "message": "No init system found (no systemctl, initctl, /etc/init.d or /etc/rc.d found)" + "message": "No init system found (no systemctl, rc-service, initctl, sv, s6, /etc/init.d or /etc/rc.d found)" } } diff --git a/tests/operations/server.service/start_initd.json b/tests/operations/server.service/start_initd.json index 848c4ff3f..0a5b46b1d 100644 --- a/tests/operations/server.service/start_initd.json +++ b/tests/operations/server.service/start_initd.json @@ -10,7 +10,8 @@ "command=initctl": false, "command=rc-service": false, "command=sv": false, - "command=service": false + "command=service": false, + "command=s6": false }, "files.Directory": { "path=/etc/init.d": true, diff --git a/tests/operations/server.service/start_initd_service.json b/tests/operations/server.service/start_initd_service.json index dc76c44e6..54e3f61b8 100644 --- a/tests/operations/server.service/start_initd_service.json +++ b/tests/operations/server.service/start_initd_service.json @@ -10,7 +10,8 @@ "command=initctl": false, "command=rc-service": false, "command=sv": false, - "command=service": false + "command=service": false, + "command=s6": false }, "files.Link": { "path=/etc/init.d": false diff --git a/tests/operations/server.service/start_rcd.json b/tests/operations/server.service/start_rcd.json index f10673108..3e8ae224d 100644 --- a/tests/operations/server.service/start_rcd.json +++ b/tests/operations/server.service/start_rcd.json @@ -10,7 +10,8 @@ "command=initctl": false, "command=rc-service": false, "command=sv": false, - "command=service": false + "command=service": false, + "command=s6": false }, "files.Directory": { "path=/etc/init.d": false, diff --git a/tests/operations/server.service/start_rcd_freebsd_with_service_command.json b/tests/operations/server.service/start_rcd_freebsd_with_service_command.json index b78f5d3aa..724c193e4 100644 --- a/tests/operations/server.service/start_rcd_freebsd_with_service_command.json +++ b/tests/operations/server.service/start_rcd_freebsd_with_service_command.json @@ -10,7 +10,8 @@ "command=initctl": false, "command=rc-service": false, "command=sv": false, - "command=service": "/usr/sbin/service" + "command=service": "/usr/sbin/service", + "command=s6": false }, "files.Directory": { "path=/etc/init.d": false, diff --git a/tests/operations/server.service/start_rcd_netbsd.json b/tests/operations/server.service/start_rcd_netbsd.json index f27130762..427c8fe85 100644 --- a/tests/operations/server.service/start_rcd_netbsd.json +++ b/tests/operations/server.service/start_rcd_netbsd.json @@ -10,7 +10,8 @@ "command=initctl": false, "command=rc-service": false, "command=sv": false, - "command=service": "/usr/sbin/service" + "command=service": "/usr/sbin/service", + "command=s6": false }, "files.Directory": { "path=/etc/init.d": false, diff --git a/tests/operations/server.service/start_s6.yaml b/tests/operations/server.service/start_s6.yaml new file mode 100644 index 000000000..4ce84f966 --- /dev/null +++ b/tests/operations/server.service/start_s6.yaml @@ -0,0 +1,19 @@ +args: + - tipidee +kwargs: + running: true +facts: + server.Which: + command=systemctl: null + command=rc-service: null + command=initctl: null + command=sv: null + command=service: null + command=s6: /usr/bin/s6 + files.Directory: + path=/etc/init.d: false + path=/etc/rc.d: false + s6.S6LiveStatus: + tipidee: false +commands: + - s6 live start tipidee From 610d24baa7cfbfacffb29fb44e4437dad8883fc2 Mon Sep 17 00:00:00 2001 From: epicrazzmatazz Date: Sun, 28 Jun 2026 17:50:59 -0400 Subject: [PATCH 08/25] WIP s6 support --- src/pyinfra/operations/s6.py | 167 +++++++++++++++--- tests/operations/s6.set/backup.yaml | 0 tests/operations/s6.set/commit.yaml | 9 + tests/operations/s6.set/delete.yaml | 0 tests/operations/s6.set/disable.yaml | 14 ++ tests/operations/s6.set/enable.yaml | 14 ++ .../s6.set/enforce_prescriptions.yaml | 17 ++ tests/operations/s6.set/multi_disable.yaml | 18 ++ tests/operations/s6.set/multi_enable.yaml | 18 ++ tests/operations/s6.set/multi_mixed.yaml | 23 +++ tests/operations/s6.set/noop.yaml | 17 ++ tests/operations/s6.set/save.yaml | 0 tests/operations/s6.set/standard.yaml | 14 ++ 13 files changed, 284 insertions(+), 27 deletions(-) create mode 100644 tests/operations/s6.set/backup.yaml create mode 100644 tests/operations/s6.set/commit.yaml create mode 100644 tests/operations/s6.set/delete.yaml create mode 100644 tests/operations/s6.set/disable.yaml create mode 100644 tests/operations/s6.set/enable.yaml create mode 100644 tests/operations/s6.set/enforce_prescriptions.yaml create mode 100644 tests/operations/s6.set/multi_disable.yaml create mode 100644 tests/operations/s6.set/multi_enable.yaml create mode 100644 tests/operations/s6.set/multi_mixed.yaml create mode 100644 tests/operations/s6.set/noop.yaml create mode 100644 tests/operations/s6.set/save.yaml create mode 100644 tests/operations/s6.set/standard.yaml diff --git a/src/pyinfra/operations/s6.py b/src/pyinfra/operations/s6.py index 8f5b6774a..7d6e95025 100644 --- a/src/pyinfra/operations/s6.py +++ b/src/pyinfra/operations/s6.py @@ -1,11 +1,15 @@ """Manage s6-rc services (https://www.skarnet.org/software/s6-rc/).""" +import builtins +import re from collections.abc import Iterable from pyinfra import host from pyinfra.api import QuoteString, StringCommand, operation from pyinfra.api.command import make_formatted_string_command from pyinfra.facts.s6 import S6LiveStatus, S6SetStatus +from pyinfra.facts.files import FindInFile +from pyinfra.operations import files def _make_live_command(op: str, services: Iterable): @@ -43,6 +47,7 @@ def service( repo: str | None = None, # TODO set set: str | None = None, + # TODO implement this enabled_rx: str = "active", disabled_rx: str = "usable", ): @@ -69,20 +74,13 @@ def service( if disabled_rx not in {"usable", "masked"}: raise ValueError('disabled_rx must be either "usable" or "masked"') - # because iterable unpacking is used + # `service` is treated as an iterable of strings; if it is a string itself (i.e. one service + # specified), undesired iteration over characters will occur. if isinstance(service, str): service = (service,) - # Tuple[bool] of status of each service in services arg - # specified_status = ( - # itemgetter(*services)(all_status) if len(all_status) != 1 else (all_status[services[0]]), - # ) - # dict[str, bool] of status of each service in services arg - # all_running = True if all(itemgetter(*services)(all_status)) else False - # live state management if running is not None: - # dict[str, bool] whether the services given in the services arg are running. live_statuses = {srv: host.get_fact(S6LiveStatus).data[srv] for srv in service} all_up = all(live_statuses.values()) @@ -123,10 +121,11 @@ def service( # potential solution is to split enabled into another operation # ERROR CONDITION: a masked service is present in `services` arg. + # TODO call s6.set operation, don't implement set-based logic here + # offline set management if enabled is not None: - - set_statuses = {srv: host.get_fact(S6SetStatus).data[srv] for srv in service} + set_statuses = {srv: host.get_fact(S6SetStatus, set).data[srv] for srv in service} all_enabled_services = [ srv for srv, stat in set_statuses.items() if stat in {"active", "always"} ] @@ -154,38 +153,152 @@ def service( yield StringCommand(command) -# TODO s6 live install is analagous to systemd daemon-reload # for now, no support for custom repository; only the s6-frontend one. # but should get this at some point, as it allows for user-managed (i.e. non-root) services -@operation() +# TODO multiple sets at once +@operation( + is_idempotent=False, + idempotent_notice="If `commit=True`, the operation is stateless due to an unconditional `s6 set check -F` and `s6 set commit`. Otherwise it is idempotent.", +) def set( - set: str = "current", + set: str, + prescriptions: dict[str] | None = None, + enforce_prescriptions: bool = False, present: bool = True, + save: bool = False, + save_name: str = set, force_save: bool = False, backup: bool = True, + commit: bool = True, + # TODO configurable s6-frontend.conf location ): """ Manage sets in a repository. + set: name of the set to manage. + + prescriptions: the prescriptions to ensure in the set. A map of service name -> prescription, where the prescription is any of "always", "active", "usable", "masked". May be `None`, which allows management of set presence only. + + enforce_prescriptions: whether the `prescriptions` should be the *only* prescriptions in the set (i.e. other services will be removed) + present: whether the set should be present in the repository. + + save: whether to save the set to the repository. + + save_name: name for the saved set. + force_save: whether to overwrite existing sets. - + backup: whether to backup overwritten sets by appending the date to the directory name. + + backup: whether to backup overwritten sets by appending the timestamp to the directory name. + + commit: whether to commit the current(ly loaded) set. Delaying this step can allow for other operations to modify the current set, with the final result being committed at the end. + """ - if not present: - yield make_formatted_string_command("s6 set delete {0}", QuoteString(set)) + if set == "current": + raise ValueError('set name cannot be "current"') + + if prescriptions: + if not (builtins.set(prescriptions.values()) <= {"always", "active", "usable", "masked"}): + raise ValueError( + 'prescriptions can only take values "always", "active", "usable", or "masked"' + ) + + wanted_always = [srv for srv, rx in prescriptions.items() if rx == "always"] + wanted_active = [srv for srv, rx in prescriptions.items() if rx == "active"] + wanted_usable = [srv for srv, rx in prescriptions.items() if rx == "usable"] + wanted_masked = [srv for srv, rx in prescriptions.items() if rx == "masked"] + + if present: + # prescription of every service in the set + curr_rxs = host.get_fact(S6SetStatus, set) + if enforce_prescriptions: + # mask all services not present in `prescriptions` arg + wanted_masked.extend([srv for srv in curr_rxs if srv not in prescriptions]) + # TODO there has to be a way to reduce boilerplate + if prescriptions and prescriptions != curr_rxs: + yield make_formatted_string_command("s6 set load {0}", QuoteString(set)) + + if wanted_always: + service_subset = [] + for srv in wanted_always: + try: + if curr_rxs[srv] != "always": + service_subset.append(srv) + except KeyError: + service_subset.append(srv) + if service_subset: + yield from _make_set_rx_command("make-essential", service_subset) + if wanted_active: + service_subset = [] + for srv in wanted_active: + try: + if curr_rxs[srv] != "active": + service_subset.append(srv) + except KeyError: + service_subset.append(srv) + if service_subset: + yield from _make_set_rx_command("enable", service_subset) + if wanted_usable: + service_subset = [] + for srv in wanted_usable: + try: + if curr_rxs[srv] != "usable": + service_subset.append(srv) + except KeyError: + service_subset.append(srv) + if service_subset: + yield from _make_set_rx_command("disable", service_subset) + if wanted_masked: + service_subset = [] + for srv in wanted_masked: + try: + if curr_rxs[srv] != "masked": + service_subset.append(srv) + except KeyError: + service_subset.append(srv) + if service_subset: + yield from _make_set_rx_command("mask", service_subset) + + if save: + if save_name: + yield make_formatted_string_command("s6 set save {0}", QuoteString(save_name)) + else: + yield StringCommand("s6 set save") + + + elif prescriptions and not commit: + host.noop("all services specified match the desired prescriptions and commit not requested") + + if force_save: + if backup: + # will break if repodir key pair in /etc/s6-frontend.conf spans several lines + lines = host.get_fact( + FindInFile, + "/etc/s6-frontend.conf", + r"repodir\s*=", + interpolate_variables=False, + extended_regex=True, + ).data + if lines is None: + raise RuntimeError( + "no repodir found in /etc/s6-frontend.conf, or file doesn't exist" + ) + if len(lines) != 1: + raise RuntimeWarning( + "multiple repodir definitions found in /etc/s6-frontend.conf, using the first one" + ) + + # https://skarnet.org/software/execline/envfile.html#syntax + repodir = re.fullmatch(r'^\s*repodir\s*=\s*(/[^\s]*|"/.*")\s*$', lines[0])[1] + + yield from files.directory._inner( + path=repodir, present=False, force=True, force_backup=True + ) - if force_save: - yield make_formatted_string_command("s6 set save -f {0}", QuoteString(set)) - else: - yield make_formatted_string_command("s6 set save {0}", QuoteString(set)) + yield make_formatted_string_command("s6 set save -f {0}", QuoteString(set)) - "s6-rc-set-new" - "s6-rc-set-copy" - "s6-rc-set-delete" - # run after each update to check consistency, but don't autofix - "s6-rc-set-fix" + # TODO mark stateless + if commit: + yield StringCommand("s6 set check -F") + yield StringCommand("s6 set commit") + + # present=False + else: + yield make_formatted_string_command("s6 set delete {0}", QuoteString(set)) -# TODO operation for set commit? +# TODO s6 set commit op +# TODO s6 live install is analagous to systemd daemon-reload diff --git a/tests/operations/s6.set/backup.yaml b/tests/operations/s6.set/backup.yaml new file mode 100644 index 000000000..e69de29bb diff --git a/tests/operations/s6.set/commit.yaml b/tests/operations/s6.set/commit.yaml new file mode 100644 index 000000000..8992e6d96 --- /dev/null +++ b/tests/operations/s6.set/commit.yaml @@ -0,0 +1,9 @@ +args: + - default +facts: + s6.S6SetStatus: + repository=None, set=default: + tipidee: "active" +commands: + - s6 set check -F + - s6 set commit diff --git a/tests/operations/s6.set/delete.yaml b/tests/operations/s6.set/delete.yaml new file mode 100644 index 000000000..e69de29bb diff --git a/tests/operations/s6.set/disable.yaml b/tests/operations/s6.set/disable.yaml new file mode 100644 index 000000000..0e4c9145c --- /dev/null +++ b/tests/operations/s6.set/disable.yaml @@ -0,0 +1,14 @@ +args: + - default +kwargs: + prescriptions: + tipidee: "usable" +facts: + s6.S6SetStatus: + repository=None, set=default: + tipidee: "active" +commands: + - s6 set load default + - s6 set disable tipidee + - s6 set check -F + - s6 set commit diff --git a/tests/operations/s6.set/enable.yaml b/tests/operations/s6.set/enable.yaml new file mode 100644 index 000000000..45e3ded9a --- /dev/null +++ b/tests/operations/s6.set/enable.yaml @@ -0,0 +1,14 @@ +args: + - default +kwargs: + prescriptions: + tipidee: "active" +facts: + s6.S6SetStatus: + repository=None, set=default: + tipidee: "usable" +commands: + - s6 set load default + - s6 set enable tipidee + - s6 set check -F + - s6 set commit diff --git a/tests/operations/s6.set/enforce_prescriptions.yaml b/tests/operations/s6.set/enforce_prescriptions.yaml new file mode 100644 index 000000000..d26e3d256 --- /dev/null +++ b/tests/operations/s6.set/enforce_prescriptions.yaml @@ -0,0 +1,17 @@ +args: + - default +kwargs: + prescriptions: + tipidee: "usable" + enforce_prescriptions: true +facts: + s6.S6SetStatus: + repository=None, set=default: + nftables: "usable" + mysqld: "masked" + tipidee: "usable" +commands: + - s6 set load default + - s6 set mask nftables + - s6 set check -F + - s6 set commit diff --git a/tests/operations/s6.set/multi_disable.yaml b/tests/operations/s6.set/multi_disable.yaml new file mode 100644 index 000000000..f11b57c65 --- /dev/null +++ b/tests/operations/s6.set/multi_disable.yaml @@ -0,0 +1,18 @@ +args: + - default +kwargs: + prescriptions: + nftables: "usable" + mysqld: "usable" + tipidee: "usable" +facts: + s6.S6SetStatus: + repository=None, set=default: + nftables: "active" + mysqld: "active" + tipidee: "active" +commands: + - s6 set load default + - s6 set disable nftables mysqld tipidee + - s6 set check -F + - s6 set commit diff --git a/tests/operations/s6.set/multi_enable.yaml b/tests/operations/s6.set/multi_enable.yaml new file mode 100644 index 000000000..051277e8b --- /dev/null +++ b/tests/operations/s6.set/multi_enable.yaml @@ -0,0 +1,18 @@ +args: + - default +kwargs: + prescriptions: + nftables: "active" + mysqld: "active" + tipidee: "active" +facts: + s6.S6SetStatus: + repository=None, set=default: + nftables: "usable" + mysqld: "usable" + tipidee: "usable" +commands: + - s6 set load default + - s6 set enable nftables mysqld tipidee + - s6 set check -F + - s6 set commit diff --git a/tests/operations/s6.set/multi_mixed.yaml b/tests/operations/s6.set/multi_mixed.yaml new file mode 100644 index 000000000..44b85da0a --- /dev/null +++ b/tests/operations/s6.set/multi_mixed.yaml @@ -0,0 +1,23 @@ +args: + - default +kwargs: + prescriptions: + nftables: "always" + mysqld: "active" + php-fpm: "masked" + tipidee: "usable" +facts: + s6.S6SetStatus: + repository=None, set=default: + nftables: "active" + mysqld: "usable" + php-fpm: "usable" + tipidee: "active" +commands: + - s6 set load default + - s6 set make-essential nftables + - s6 set enable mysqld + - s6 set disable tipidee + - s6 set mask php-fpm + - s6 set check -F + - s6 set commit diff --git a/tests/operations/s6.set/noop.yaml b/tests/operations/s6.set/noop.yaml new file mode 100644 index 000000000..af75c721f --- /dev/null +++ b/tests/operations/s6.set/noop.yaml @@ -0,0 +1,17 @@ +args: + - default +kwargs: + prescriptions: + nftables: "active" + mysqld: "masked" + tipidee: "usable" + commit: false +facts: + s6.S6SetStatus: + repository=None, set=default: + nftables: "active" + mysqld: "masked" + tipidee: "usable" +commands: [] +noop_description: all services specified match the desired prescriptions and commit not requested + diff --git a/tests/operations/s6.set/save.yaml b/tests/operations/s6.set/save.yaml new file mode 100644 index 000000000..e69de29bb diff --git a/tests/operations/s6.set/standard.yaml b/tests/operations/s6.set/standard.yaml new file mode 100644 index 000000000..45e3ded9a --- /dev/null +++ b/tests/operations/s6.set/standard.yaml @@ -0,0 +1,14 @@ +args: + - default +kwargs: + prescriptions: + tipidee: "active" +facts: + s6.S6SetStatus: + repository=None, set=default: + tipidee: "usable" +commands: + - s6 set load default + - s6 set enable tipidee + - s6 set check -F + - s6 set commit From d565b215b2b6da6e401de8f01fb9a2182e83d71a Mon Sep 17 00:00:00 2001 From: epicrazzmatazz Date: Thu, 2 Jul 2026 22:37:06 -0400 Subject: [PATCH 09/25] WIP s6 support --- src/pyinfra/facts/s6.py | 10 +- src/pyinfra/operations/s6.py | 365 +++++++++--------- tests/operations/s6.service/all_at_once.yaml | 18 +- tests/operations/s6.service/bring_up.yaml | 2 + tests/operations/s6.service/disable.yaml | 3 +- .../s6.service/dont_restart_if_stopped.yaml | 4 +- tests/operations/s6.service/enable.yaml | 4 +- .../operations/s6.service/multi_bring_up.yaml | 2 + .../operations/s6.service/multi_disable.yaml | 7 +- tests/operations/s6.service/multi_enable.yaml | 7 +- .../s6.service/multi_partial_disable.yaml | 7 +- .../s6.service/multi_partial_enable.yaml | 7 +- .../s6.service/multi_partial_restart.yaml | 7 +- 13 files changed, 234 insertions(+), 209 deletions(-) diff --git a/src/pyinfra/facts/s6.py b/src/pyinfra/facts/s6.py index e3b437175..d01a15dbe 100644 --- a/src/pyinfra/facts/s6.py +++ b/src/pyinfra/facts/s6.py @@ -49,18 +49,18 @@ def check_preconditions(self, state, host): if not host.get_fact(File("/etc/s6/frontend.conf")): return "couldn't read /etc/s6/frontend.conf or it doesn't exist" - def requires_command(self, repository=None, set=None): - if repository or set: + def requires_command(self, set="current", repository=None): + if repository or set != "current": return "s6-rc-set-status" return "s6" - def command(self, set=None, repository=None): + def command(self, set="current", repository=None): """ - + set: the set to inspect, default `None` which resolves to the current working set "current". + + set: the set to inspect. + repository: path of the repository to inspect, default `None` which resolves the following way: If `set` is unspecified, the repository in `/etc/s6-frontend.conf` will be used. If `set` is specified, the compiled-in default `/var/lib/s6-rc/repository` will be used. """ - if set: + if set != "current": if repository: return make_formatted_string_command( "s6-rc-set-status -r {0} {1}", QuoteString(repository), QuoteString(set) diff --git a/src/pyinfra/operations/s6.py b/src/pyinfra/operations/s6.py index 7d6e95025..27717c0c5 100644 --- a/src/pyinfra/operations/s6.py +++ b/src/pyinfra/operations/s6.py @@ -11,6 +11,13 @@ from pyinfra.facts.files import FindInFile from pyinfra.operations import files +_rx_to_subcommand = { + "always": "make-essential", + "active": "enable", + "usable": "disable", + "masked": "mask", +} + def _make_live_command(op: str, services: Iterable): """ @@ -22,146 +29,37 @@ def _make_live_command(op: str, services: Iterable): yield make_formatted_string_command(f"s6 live {op} " + s, *map(QuoteString, services)) -def _make_set_rx_command(op: str, services: Iterable): +def _make_set_command(services: list, curr_rxs: dict, wanted_rx: str): """ - + rx: the operation, one of "enable", "disable", "mask", "unmask", "make-essential". - + services: the service(s) to operate on. + + services: the services to be assigned a specific prescription. + + curr_rxs: the current prescriptions for all services (from the S6SetStatus fact). + + wanted_rx: the prescription to assign to each service. """ - s = " ".join([f"{{{i}}}" for i in range(len(services))]) - yield make_formatted_string_command(f"s6 set {op} " + s, *map(QuoteString, services)) + # services that need their prescription changed (not all of them; those that are already in the + # desired state are not in this list) + service_subset = [] + for srv in services: + try: + if curr_rxs[srv] != wanted_rx: + service_subset.append(srv) + except KeyError: + service_subset.append(srv) -# TODO server.service compatibility (must use a string for services in that implementation) -@operation() -def service( - service: str | Iterable[str], - # optional, to separate live management vs set management - running: bool | None = True, - restarted: bool = False, - reloaded: bool = False, - # TODO command - command: str | None = None, - enabled: bool | None = None, - reload_signal: str = "SIGHUP", - # TODO repo - repo: str | None = None, - # TODO set - set: str | None = None, - # TODO implement this - enabled_rx: str = "active", - disabled_rx: str = "usable", -): - """ - Manage the state of s6-supervised services. - - + services: name(s) of the service(s) to manage. - + running: whether the service(s) should be under an s6-supervise. - + restarted: whether the service(s) should be restarted - + reloaded: whether the service(s) should be reloaded by sending a SIGHUP. Whether the service is reloaded depends on how it handles SIGHUP. - + command: custom command to run after the auto-computed commands. - + enabled: whether the service should be given an "active" or "usable" prescription - + reload_signal: the signal to send to the service(s) when a reload is desired. - + repo: name of the repository to use when managing enabled status, using the one configured in s6-frontend.conf by default. - + set: name of the set to use when managing enabled status, using the set named "current" by default. - + enabled_rx: name of the prescription to assign to the service(s) when enabled, which could be either "active" or "always" - + disabled_rx: name of the prescription to assign to the service(s) when disabled, which could be either "usable" or "masked" - - Specifying multiple services is preferred: fewer commands will be executed, especially in the case of changing the enabled status of the service, where the service database is recompiled per command. - """ - - if enabled_rx not in {"active", "always"}: - raise ValueError('enabled_rx must be either "active" or "always"') - if disabled_rx not in {"usable", "masked"}: - raise ValueError('disabled_rx must be either "usable" or "masked"') - - # `service` is treated as an iterable of strings; if it is a string itself (i.e. one service - # specified), undesired iteration over characters will occur. - if isinstance(service, str): - service = (service,) - - # live state management - if running is not None: - # dict[str, bool] whether the services given in the services arg are running. - live_statuses = {srv: host.get_fact(S6LiveStatus).data[srv] for srv in service} - all_up = all(live_statuses.values()) - some_up = any(live_statuses.values()) - all_down_services = [srv for srv, stat in live_statuses.items() if not stat] - all_up_services = [srv for srv, stat in live_statuses.items() if stat] - - if running: - if not all_up: - yield from _make_live_command("start", all_down_services) - else: - host.noop(f"all specified services are already up: {service}") - - else: - if some_up: - yield from _make_live_command("stop", all_up_services) - else: - host.noop(f"all specified services are already down: {service}") - - if restarted: - if some_up: - yield from _make_live_command("restart", all_up_services) - else: - host.noop(f"all specified services are down: {service}") - - if reloaded: - if some_up: - yield make_formatted_string_command( - "s6 process kill -s {0} " - + " ".join([f"{{{i + 1}}}" for i in range(len(all_up_services))]), - QuoteString(reload_signal), - *map(QuoteString, all_up_services), - ) - else: - host.noop(f"all specified services are down: {service}") - - # TODO: if a service is masked, s6 live will always fail to do anything to that service; - # potential solution is to split enabled into another operation - # ERROR CONDITION: a masked service is present in `services` arg. - - # TODO call s6.set operation, don't implement set-based logic here - - # offline set management - if enabled is not None: - set_statuses = {srv: host.get_fact(S6SetStatus, set).data[srv] for srv in service} - all_enabled_services = [ - srv for srv, stat in set_statuses.items() if stat in {"active", "always"} - ] - all_disabled_services = [ - srv for srv, stat in set_statuses.items() if stat in {"usable", "masked"} - ] - - if enabled: - if len(all_disabled_services) != 0: - yield from _make_set_rx_command("enable", all_disabled_services) - yield StringCommand("s6 set check -F") - yield StringCommand("s6 set commit") - else: - host.noop(f"all services are already enabled: {service}") - - else: - if len(all_enabled_services) != 0: - yield from _make_set_rx_command("disable", all_enabled_services) - yield StringCommand("s6 set check -F") - yield StringCommand("s6 set commit") - else: - host.noop(f"all services are already disabled: {service}") - - if command: - yield StringCommand(command) + if service_subset: + op = _rx_to_subcommand[wanted_rx] + s = " ".join([f"{{{i}}}" for i in range(len(service_subset))]) + yield make_formatted_string_command(f"s6 set {op} " + s, *map(QuoteString, service_subset)) # for now, no support for custom repository; only the s6-frontend one. # but should get this at some point, as it allows for user-managed (i.e. non-root) services -# TODO multiple sets at once @operation( is_idempotent=False, idempotent_notice="If `commit=True`, the operation is stateless due to an unconditional `s6 set check -F` and `s6 set commit`. Otherwise it is idempotent.", ) def set( - set: str, + the_set: str = "current", prescriptions: dict[str] | None = None, enforce_prescriptions: bool = False, present: bool = True, @@ -187,9 +85,6 @@ def set( """ - if set == "current": - raise ValueError('set name cannot be "current"') - if prescriptions: if not (builtins.set(prescriptions.values()) <= {"always", "active", "usable", "masked"}): raise ValueError( @@ -203,54 +98,64 @@ def set( if present: # prescription of every service in the set - curr_rxs = host.get_fact(S6SetStatus, set) + curr_rxs = host.get_fact(S6SetStatus, the_set) if enforce_prescriptions: # mask all services not present in `prescriptions` arg wanted_masked.extend([srv for srv in curr_rxs if srv not in prescriptions]) + # TODO there has to be a way to reduce boilerplate if prescriptions and prescriptions != curr_rxs: - yield make_formatted_string_command("s6 set load {0}", QuoteString(set)) + if the_set != "current": + yield make_formatted_string_command("s6 set load {0}", QuoteString(the_set)) if wanted_always: - service_subset = [] - for srv in wanted_always: - try: - if curr_rxs[srv] != "always": - service_subset.append(srv) - except KeyError: - service_subset.append(srv) - if service_subset: - yield from _make_set_rx_command("make-essential", service_subset) + yield from _make_set_command(wanted_always, curr_rxs, "always") + # yield from _s6_set_helper(wanted_always, curr_rxs, "always") + # service_subset = [] + # for srv in wanted_always: + # try: + # if curr_rxs[srv] != "always": + # service_subset.append(srv) + # except KeyError: + # service_subset.append(srv) + # if service_subset: + # yield from _make_set_rx_command("make-essential", service_subset) if wanted_active: - service_subset = [] - for srv in wanted_active: - try: - if curr_rxs[srv] != "active": - service_subset.append(srv) - except KeyError: - service_subset.append(srv) - if service_subset: - yield from _make_set_rx_command("enable", service_subset) + yield from _make_set_command(wanted_active, curr_rxs, "active") + # yield from _s6_set_helper(wanted_active, curr_rxs, "active") + # service_subset = [] + # for srv in wanted_active: + # try: + # if curr_rxs[srv] != "active": + # service_subset.append(srv) + # except KeyError: + # service_subset.append(srv) + # if service_subset: + # yield from _make_set_rx_command("enable", service_subset) if wanted_usable: - service_subset = [] - for srv in wanted_usable: - try: - if curr_rxs[srv] != "usable": - service_subset.append(srv) - except KeyError: - service_subset.append(srv) - if service_subset: - yield from _make_set_rx_command("disable", service_subset) + yield from _make_set_command(wanted_usable, curr_rxs, "usable") + # yield from _s6_set_helper(wanted_usable, curr_rxs, "usable") + # service_subset = [] + # for srv in wanted_usable: + # try: + # if curr_rxs[srv] != "usable": + # service_subset.append(srv) + # except KeyError: + # service_subset.append(srv) + # if service_subset: + # yield from _make_set_rx_command("disable", service_subset) if wanted_masked: - service_subset = [] - for srv in wanted_masked: - try: - if curr_rxs[srv] != "masked": - service_subset.append(srv) - except KeyError: - service_subset.append(srv) - if service_subset: - yield from _make_set_rx_command("mask", service_subset) + yield from _make_set_command(wanted_masked, curr_rxs, "masked") + # yield from _s6_set_helper(wanted_masked, curr_rxs, "masked") + # service_subset = [] + # for srv in wanted_masked: + # try: + # if curr_rxs[srv] != "masked": + # service_subset.append(srv) + # except KeyError: + # service_subset.append(srv) + # if service_subset: + # yield from _make_set_rx_command("mask", service_subset) if save: if save_name: @@ -258,9 +163,10 @@ def set( else: yield StringCommand("s6 set save") - elif prescriptions and not commit: - host.noop("all services specified match the desired prescriptions and commit not requested") + host.noop( + "all services specified match the desired prescriptions and commit not requested" + ) if force_save: if backup: @@ -271,7 +177,7 @@ def set( r"repodir\s*=", interpolate_variables=False, extended_regex=True, - ).data + ) if lines is None: raise RuntimeError( "no repodir found in /etc/s6-frontend.conf, or file doesn't exist" @@ -288,17 +194,124 @@ def set( path=repodir, present=False, force=True, force_backup=True ) - yield make_formatted_string_command("s6 set save -f {0}", QuoteString(set)) + yield make_formatted_string_command("s6 set save -f {0}", QuoteString(the_set)) - # TODO mark stateless + # TODO make this not do anything if not needed? how? separate operation? if commit: yield StringCommand("s6 set check -F") yield StringCommand("s6 set commit") # present=False else: - yield make_formatted_string_command("s6 set delete {0}", QuoteString(set)) + yield make_formatted_string_command("s6 set delete {0}", QuoteString(the_set)) + + +# TODO server.service compatibility (must use a string for services in that implementation) +@operation() +def service( + service: str | Iterable[str], + running: bool | None = None, + restarted: bool | None = None, + reloaded: bool | None = None, + # TODO command + command: str | None = None, + enabled: bool | None = None, + reload_signal: str = "SIGHUP", + the_set: str = "current", + enabled_rx: str = "active", + disabled_rx: str = "usable", + commit_set: bool = False, + install_set: bool = False, +): + """ + Manage the state of s6-supervised services. + + + services: name(s) of the service(s) to manage. + + running: whether the service(s) should be under an s6-supervise. + + restarted: whether the service(s) should be restarted + + reloaded: whether the service(s) should be reloaded by sending a SIGHUP. Whether the service is reloaded depends on how it handles SIGHUP. + + command: custom command to run after the auto-computed commands. + + enabled: whether the service should be given an "active" or "usable" prescription + + reload_signal: the signal to send to the service(s) when a reload is desired. + + repo: name of the repository to use when managing enabled status, using the one configured in s6-frontend.conf by default. + + the_set: name of the set to use when managing enabled status, using the set named "current" by default. + + enabled_rx: name of the prescription to assign to the service(s) when enabled, which could be either "active" or "always" + + disabled_rx: name of the prescription to assign to the service(s) when disabled, which could be either "usable" or "masked" + + commit_set: whether to commit the current(ly loaded) set. Delaying this step can allow for other operations to modify the current set, with the final result being committed at the end. + + install_set: whether to install the compiled service database (the result of a commit operation) into the live state. This is analagous to systemd's daemon-reload, but not completely: systemd recognizes changes to service files after a reboot, but s6 does not. It only recognizes changes when an s6 live install command is executed. Live state replacement and enablement/disablement of services are coupled in s6. + + Specifying multiple services is preferred: fewer commands will be executed, especially in the + case of changing the enabled status of the service, where the service database is recompiled per + command. Note that this operation does not give as granular control over prescriptions as the + set operation does; all services will be assigned the same prescription. + """ + + if enabled_rx not in {"active", "always"}: + raise ValueError('enabled_rx must be either "active" or "always"') + if disabled_rx not in {"usable", "masked"}: + raise ValueError('disabled_rx must be either "usable" or "masked"') + + # `service` is treated as an iterable of strings; if it is a string itself (i.e. one service + # specified), undesired iteration over characters will occur. + if isinstance(service, str): + service = (service,) + # live state management + if (running, restarted, reloaded) != (None,) * 3: + # dict[str, bool] whether the services given in the services arg are running. + live_statuses = {srv: host.get_fact(S6LiveStatus)[srv] for srv in service} + all_up = all(live_statuses.values()) + some_up = any(live_statuses.values()) + all_down_services = [srv for srv, stat in live_statuses.items() if not stat] + all_up_services = [srv for srv, stat in live_statuses.items() if stat] + + if running is False: + if some_up: + yield from _make_live_command("stop", all_up_services) + else: + host.noop(f"all specified services are already down: {service}") + + if running is True: + if not all_up: + yield from _make_live_command("start", all_down_services) + else: + host.noop(f"all specified services are already up: {service}") + + if restarted: + if some_up: + yield from _make_live_command("restart", all_up_services) + else: + host.noop(f"all specified services are down: {service}") -# TODO s6 set commit op -# TODO s6 live install is analagous to systemd daemon-reload + if reloaded: + if some_up: + yield make_formatted_string_command( + "s6 process kill -s {0} " + + " ".join([f"{{{i + 1}}}" for i in range(len(all_up_services))]), + QuoteString(reload_signal), + *map(QuoteString, all_up_services), + ) + else: + host.noop(f"all specified services are down: {service}") + + # TODO: test masked services present in `services` arg on a real system + # offline set management + if enabled is not None: + if enabled is True: + yield from set._inner(the_set=the_set, prescriptions={srv: enabled_rx for srv in service}) + + if enabled is False: + yield from set._inner(the_set=the_set, prescriptions={srv: disabled_rx for srv in service}) + + # s6.set operation already handles s6 set load + # TODO look at how systemd daemon-reload handles this, or maybe a daemon-reload like + # operation not necessary + if commit_set: + yield StringCommand("s6 set check -F") + yield StringCommand("s6 set commit") + if install_set: + yield StringCommand("s6 live install") + + # TODO + if command: + yield StringCommand(command) diff --git a/tests/operations/s6.service/all_at_once.yaml b/tests/operations/s6.service/all_at_once.yaml index c420f39e0..b8c10782d 100644 --- a/tests/operations/s6.service/all_at_once.yaml +++ b/tests/operations/s6.service/all_at_once.yaml @@ -5,21 +5,27 @@ kwargs: restarted: true reloaded: true enabled: true + the_set: webserver + enabled_rx: active facts: - s6.S6SetStatus: - nftables: "always" - mysqld: "active" - php-fpm: "masked" - tipidee: "usable" s6.S6LiveStatus: nftables: true mysqld: false php-fpm: false tipidee: false + s6.S6SetStatus: + repository=None, set=webserver: + nftables: always + mysqld: active + php-fpm: masked + tipidee: usable commands: - s6 live start mysqld php-fpm tipidee - s6 live restart nftables - s6 process kill -s SIGHUP nftables - - s6 set enable php-fpm tipidee + - s6 set load webserver + # enabled argument applies enabled_rx to all services, hence why nftables is here, despite having + # "always" rx + - s6 set enable nftables php-fpm tipidee - s6 set check -F - s6 set commit diff --git a/tests/operations/s6.service/bring_up.yaml b/tests/operations/s6.service/bring_up.yaml index 412731084..011cca817 100644 --- a/tests/operations/s6.service/bring_up.yaml +++ b/tests/operations/s6.service/bring_up.yaml @@ -1,5 +1,7 @@ args: - tipidee +kwargs: + running: true facts: s6.S6LiveStatus: tipidee: false diff --git a/tests/operations/s6.service/disable.yaml b/tests/operations/s6.service/disable.yaml index 68a883f94..9efd01d76 100644 --- a/tests/operations/s6.service/disable.yaml +++ b/tests/operations/s6.service/disable.yaml @@ -5,7 +5,8 @@ kwargs: enabled: false facts: s6.S6SetStatus: - tipidee: "active" + repository=None, set=current: + tipidee: "active" commands: - s6 set disable tipidee - s6 set check -F diff --git a/tests/operations/s6.service/dont_restart_if_stopped.yaml b/tests/operations/s6.service/dont_restart_if_stopped.yaml index 4ae28ee1d..7a4279108 100644 --- a/tests/operations/s6.service/dont_restart_if_stopped.yaml +++ b/tests/operations/s6.service/dont_restart_if_stopped.yaml @@ -5,6 +5,4 @@ kwargs: facts: s6.S6LiveStatus: tipidee: false -commands: - # restarted implies bring up if stopped - - s6 live start tipidee +commands: [] diff --git a/tests/operations/s6.service/enable.yaml b/tests/operations/s6.service/enable.yaml index f9053fe16..67fe0e071 100644 --- a/tests/operations/s6.service/enable.yaml +++ b/tests/operations/s6.service/enable.yaml @@ -1,11 +1,11 @@ args: - tipidee kwargs: - running: null enabled: true facts: s6.S6SetStatus: - tipidee: "usable" + repository=None, set=current: + tipidee: "usable" commands: - s6 set enable tipidee - s6 set check -F diff --git a/tests/operations/s6.service/multi_bring_up.yaml b/tests/operations/s6.service/multi_bring_up.yaml index 81266493f..08f0f3245 100644 --- a/tests/operations/s6.service/multi_bring_up.yaml +++ b/tests/operations/s6.service/multi_bring_up.yaml @@ -1,5 +1,7 @@ args: - [ nftables, mysqld, tipidee ] +kwargs: + running: true facts: s6.S6LiveStatus: nftables: false diff --git a/tests/operations/s6.service/multi_disable.yaml b/tests/operations/s6.service/multi_disable.yaml index a4364adee..dde6e3bbc 100644 --- a/tests/operations/s6.service/multi_disable.yaml +++ b/tests/operations/s6.service/multi_disable.yaml @@ -5,9 +5,10 @@ kwargs: enabled: false facts: s6.S6SetStatus: - nftables: "active" - mysqld: "active" - tipidee: "active" + repository=None, set=current: + nftables: "active" + mysqld: "active" + tipidee: "active" commands: - s6 set disable nftables mysqld tipidee - s6 set check -F diff --git a/tests/operations/s6.service/multi_enable.yaml b/tests/operations/s6.service/multi_enable.yaml index fa4ae8340..8234a641d 100644 --- a/tests/operations/s6.service/multi_enable.yaml +++ b/tests/operations/s6.service/multi_enable.yaml @@ -5,9 +5,10 @@ kwargs: enabled: true facts: s6.S6SetStatus: - nftables: "usable" - mysqld: "usable" - tipidee: "usable" + repository=None, set=current: + nftables: "usable" + mysqld: "usable" + tipidee: "usable" commands: - s6 set enable nftables mysqld tipidee - s6 set check -F diff --git a/tests/operations/s6.service/multi_partial_disable.yaml b/tests/operations/s6.service/multi_partial_disable.yaml index a5f1c2c06..bc6dfe23c 100644 --- a/tests/operations/s6.service/multi_partial_disable.yaml +++ b/tests/operations/s6.service/multi_partial_disable.yaml @@ -5,9 +5,10 @@ kwargs: enabled: false facts: s6.S6SetStatus: - nftables: "active" - mysqld: "usable" - tipidee: "active" + repository=None, set=current: + nftables: "active" + mysqld: "usable" + tipidee: "active" commands: - s6 set disable nftables tipidee - s6 set check -F diff --git a/tests/operations/s6.service/multi_partial_enable.yaml b/tests/operations/s6.service/multi_partial_enable.yaml index 66e20e231..5b58ee628 100644 --- a/tests/operations/s6.service/multi_partial_enable.yaml +++ b/tests/operations/s6.service/multi_partial_enable.yaml @@ -5,9 +5,10 @@ kwargs: enabled: true facts: s6.S6SetStatus: - nftables: "usable" - mysqld: "active" - tipidee: "usable" + repository=None, set=current: + nftables: "usable" + mysqld: "active" + tipidee: "usable" commands: - s6 set enable nftables tipidee - s6 set check -F diff --git a/tests/operations/s6.service/multi_partial_restart.yaml b/tests/operations/s6.service/multi_partial_restart.yaml index b00bf1c4b..11dae3171 100644 --- a/tests/operations/s6.service/multi_partial_restart.yaml +++ b/tests/operations/s6.service/multi_partial_restart.yaml @@ -4,9 +4,8 @@ kwargs: restarted: true facts: s6.S6LiveStatus: - nftables: true - mysqld: false - tipidee: true + nftables: true + mysqld: false + tipidee: true commands: - - s6 live start mysqld - s6 live restart nftables tipidee From d9168c7b4ef1e5e0c2b399d2fe9d47c78e633db9 Mon Sep 17 00:00:00 2001 From: epicrazzmatazz Date: Fri, 3 Jul 2026 13:27:56 -0400 Subject: [PATCH 10/25] WIP s6 support --- src/pyinfra/facts/s6.py | 19 +++++++++++++------ .../facts/s6.S6SetStatus/nonexistent_set.yaml | 5 +++++ .../nonstandard_repository.yaml | 3 ++- .../nonstandard_repository_set.yaml | 3 ++- .../facts/s6.S6SetStatus/nonstandard_set.yaml | 3 ++- tests/facts/s6.S6SetStatus/standard.yaml | 3 ++- 6 files changed, 26 insertions(+), 10 deletions(-) create mode 100644 tests/facts/s6.S6SetStatus/nonexistent_set.yaml diff --git a/src/pyinfra/facts/s6.py b/src/pyinfra/facts/s6.py index d01a15dbe..e7d8c9905 100644 --- a/src/pyinfra/facts/s6.py +++ b/src/pyinfra/facts/s6.py @@ -40,7 +40,10 @@ def process(self, output): class S6SetStatus(FactBase[dict[str, str]]): - """Returns a dict of name -> rx (prescription) for each service in a given set.""" + """Returns a dict of name -> rx (prescription) for each service in a given set. + + If the set does not exist, nothing is returned. + """ def check_preconditions(self, state, host): from pyinfra.facts.files import File @@ -63,22 +66,26 @@ def command(self, set="current", repository=None): if set != "current": if repository: return make_formatted_string_command( - "s6-rc-set-status -r {0} {1}", QuoteString(repository), QuoteString(set) + "s6-rc-set-status -r {0} {1}; echo EXIT CODE: $?", QuoteString(repository), QuoteString(set) ) - return make_formatted_string_command("s6-rc-set-status {0}", QuoteString(set)) + return make_formatted_string_command("s6-rc-set-status {0}; echo EXIT CODE: $?", QuoteString(set)) if repository: return make_formatted_string_command( - "s6-rc-set-status -r {0} current", QuoteString(repository) + "s6-rc-set-status -r {0} current; echo EXIT CODE: $?", QuoteString(repository) ) # TODO consider case where util-linux triggers column pretty printing - return "s6 set status" + return "s6 set status; echo EXIT CODE: $?" def process(self, output): + # exit code 3: nonexistent set + if output[-1] == "EXIT CODE: 3": + return + return { - triplet[0]: triplet[-1] for triplet in map(lambda line: line.partition("/"), output) + triplet[0]: triplet[-1] for triplet in map(lambda line: line.partition("/"), output[:-1]) } diff --git a/tests/facts/s6.S6SetStatus/nonexistent_set.yaml b/tests/facts/s6.S6SetStatus/nonexistent_set.yaml new file mode 100644 index 000000000..e9a579b94 --- /dev/null +++ b/tests/facts/s6.S6SetStatus/nonexistent_set.yaml @@ -0,0 +1,5 @@ +command: "s6 set status; echo EXIT CODE: $?" +requires_command: s6 +output: | + EXIT CODE: 3 +fact: null diff --git a/tests/facts/s6.S6SetStatus/nonstandard_repository.yaml b/tests/facts/s6.S6SetStatus/nonstandard_repository.yaml index dbd552eea..4123c0e8f 100644 --- a/tests/facts/s6.S6SetStatus/nonstandard_repository.yaml +++ b/tests/facts/s6.S6SetStatus/nonstandard_repository.yaml @@ -1,6 +1,6 @@ arg: repository: /etc/s6/repo -command: s6-rc-set-status -r /etc/s6/repo current +command: "s6-rc-set-status -r /etc/s6/repo current; echo EXIT CODE: $?" requires_command: s6-rc-set-status output: | swap/always @@ -8,6 +8,7 @@ output: | NetworkManager-log/active avahi-daemon-srv/usable avahi-daemon-log/usable + EXIT CODE: 0 fact: swap: always NetworkManager-srv: active diff --git a/tests/facts/s6.S6SetStatus/nonstandard_repository_set.yaml b/tests/facts/s6.S6SetStatus/nonstandard_repository_set.yaml index d2b7c4508..5997eba33 100644 --- a/tests/facts/s6.S6SetStatus/nonstandard_repository_set.yaml +++ b/tests/facts/s6.S6SetStatus/nonstandard_repository_set.yaml @@ -1,12 +1,13 @@ arg: set: recovery repository: /etc/s6/repo -command: s6-rc-set-status -r /etc/s6/repo recovery +command: "s6-rc-set-status -r /etc/s6/repo recovery; echo EXIT CODE: $?" requires_command: s6-rc-set-status output: | swap/always tty1/active ttyS/active + EXIT CODE: 0 fact: swap: always tty1: active diff --git a/tests/facts/s6.S6SetStatus/nonstandard_set.yaml b/tests/facts/s6.S6SetStatus/nonstandard_set.yaml index c4f7203cc..c7204a2d0 100644 --- a/tests/facts/s6.S6SetStatus/nonstandard_set.yaml +++ b/tests/facts/s6.S6SetStatus/nonstandard_set.yaml @@ -1,11 +1,12 @@ arg: set: recovery -command: s6-rc-set-status recovery +command: "s6-rc-set-status recovery; echo EXIT CODE: $?" requires_command: s6-rc-set-status output: | swap/always tty1/active ttyS/active + EXIT CODE: 0 fact: swap: always tty1: active diff --git a/tests/facts/s6.S6SetStatus/standard.yaml b/tests/facts/s6.S6SetStatus/standard.yaml index 8cbd243e8..3b7b3bd45 100644 --- a/tests/facts/s6.S6SetStatus/standard.yaml +++ b/tests/facts/s6.S6SetStatus/standard.yaml @@ -1,4 +1,4 @@ -command: s6 set status +command: "s6 set status; echo EXIT CODE: $?" requires_command: s6 output: | swap/always @@ -6,6 +6,7 @@ output: | NetworkManager-log/active avahi-daemon-srv/usable avahi-daemon-log/usable + EXIT CODE: 0 fact: swap: always NetworkManager-srv: active From c2196865171262addea25d59971126b1c2ee3140 Mon Sep 17 00:00:00 2001 From: epicrazzmatazz Date: Fri, 3 Jul 2026 13:28:20 -0400 Subject: [PATCH 11/25] WIP s6 support --- src/pyinfra/facts/s6.py | 6 + src/pyinfra/operations/s6.py | 157 +++++++++--------- tests/operations/s6.service/command.yaml | 11 ++ .../s6.service/disabled_rx_is_masked.yaml | 14 ++ .../s6.service/enabled_rx_is_always.yaml | 14 ++ tests/operations/s6.set/backup.yaml | 19 +++ tests/operations/s6.set/delete.yaml | 11 ++ tests/operations/s6.set/delete_noop.yaml | 9 + tests/operations/s6.set/enable.yaml | 4 +- tests/operations/s6.set/noop.yaml | 2 +- tests/operations/s6.set/save.yaml | 10 ++ tests/operations/s6.set/save_force.yaml | 0 12 files changed, 180 insertions(+), 77 deletions(-) create mode 100644 tests/operations/s6.service/command.yaml create mode 100644 tests/operations/s6.service/disabled_rx_is_masked.yaml create mode 100644 tests/operations/s6.service/enabled_rx_is_always.yaml create mode 100644 tests/operations/s6.set/delete_noop.yaml create mode 100644 tests/operations/s6.set/save_force.yaml diff --git a/src/pyinfra/facts/s6.py b/src/pyinfra/facts/s6.py index e7d8c9905..ff707f54d 100644 --- a/src/pyinfra/facts/s6.py +++ b/src/pyinfra/facts/s6.py @@ -43,6 +43,11 @@ class S6SetStatus(FactBase[dict[str, str]]): """Returns a dict of name -> rx (prescription) for each service in a given set. If the set does not exist, nothing is returned. + + > [!IMPORTANT] + > The fact only returns `None` when the set doesn't exist if `3` is in the `_success_exit_codes` + > parameter for the fact. It will throw an exception otherwise due to a limitation in pyinfra. + """ def check_preconditions(self, state, host): @@ -81,6 +86,7 @@ def command(self, set="current", repository=None): def process(self, output): # exit code 3: nonexistent set + # NOTE: will have to always specify 3 as success exit code when using this fact if output[-1] == "EXIT CODE: 3": return diff --git a/src/pyinfra/operations/s6.py b/src/pyinfra/operations/s6.py index 27717c0c5..4423b8803 100644 --- a/src/pyinfra/operations/s6.py +++ b/src/pyinfra/operations/s6.py @@ -11,14 +11,6 @@ from pyinfra.facts.files import FindInFile from pyinfra.operations import files -_rx_to_subcommand = { - "always": "make-essential", - "active": "enable", - "usable": "disable", - "masked": "mask", -} - - def _make_live_command(op: str, services: Iterable): """ + op: the operation, e.g. "start", "stop", "restart". @@ -47,13 +39,33 @@ def _make_set_command(services: list, curr_rxs: dict, wanted_rx: str): service_subset.append(srv) if service_subset: + _rx_to_subcommand = { + "always": "make-essential", + "active": "enable", + "usable": "disable", + "masked": "mask", + } + op = _rx_to_subcommand[wanted_rx] s = " ".join([f"{{{i}}}" for i in range(len(service_subset))]) yield make_formatted_string_command(f"s6 set {op} " + s, *map(QuoteString, service_subset)) -# for now, no support for custom repository; only the s6-frontend one. -# but should get this at some point, as it allows for user-managed (i.e. non-root) services +@operation(is_idempotent=False) +def commit(): + """Check the current working set and commit it.""" + yield StringCommand("s6 set check -F") + yield StringCommand("s6 set commit") + + +@operation(is_idempotent=False) +def install(): + """Install the compiled (committed) service database into the live state.""" + yield StringCommand("s6 live install") + + +# TODO for now, no support for custom repository; only the s6-frontend one. but should get this at +# some point, as it allows for user-managed (i.e. non-root) services @operation( is_idempotent=False, idempotent_notice="If `commit=True`, the operation is stateless due to an unconditional `s6 set check -F` and `s6 set commit`. Otherwise it is idempotent.", @@ -67,7 +79,7 @@ def set( save_name: str = set, force_save: bool = False, backup: bool = True, - commit: bool = True, + do_commit: bool = True, # TODO configurable s6-frontend.conf location ): """ @@ -98,72 +110,67 @@ def set( if present: # prescription of every service in the set - curr_rxs = host.get_fact(S6SetStatus, the_set) + curr_rxs = host.get_fact(S6SetStatus, set=the_set) if enforce_prescriptions: # mask all services not present in `prescriptions` arg wanted_masked.extend([srv for srv in curr_rxs if srv not in prescriptions]) - # TODO there has to be a way to reduce boilerplate if prescriptions and prescriptions != curr_rxs: if the_set != "current": yield make_formatted_string_command("s6 set load {0}", QuoteString(the_set)) if wanted_always: yield from _make_set_command(wanted_always, curr_rxs, "always") - # yield from _s6_set_helper(wanted_always, curr_rxs, "always") - # service_subset = [] - # for srv in wanted_always: - # try: - # if curr_rxs[srv] != "always": - # service_subset.append(srv) - # except KeyError: - # service_subset.append(srv) - # if service_subset: - # yield from _make_set_rx_command("make-essential", service_subset) if wanted_active: yield from _make_set_command(wanted_active, curr_rxs, "active") - # yield from _s6_set_helper(wanted_active, curr_rxs, "active") - # service_subset = [] - # for srv in wanted_active: - # try: - # if curr_rxs[srv] != "active": - # service_subset.append(srv) - # except KeyError: - # service_subset.append(srv) - # if service_subset: - # yield from _make_set_rx_command("enable", service_subset) if wanted_usable: yield from _make_set_command(wanted_usable, curr_rxs, "usable") - # yield from _s6_set_helper(wanted_usable, curr_rxs, "usable") - # service_subset = [] - # for srv in wanted_usable: - # try: - # if curr_rxs[srv] != "usable": - # service_subset.append(srv) - # except KeyError: - # service_subset.append(srv) - # if service_subset: - # yield from _make_set_rx_command("disable", service_subset) if wanted_masked: yield from _make_set_command(wanted_masked, curr_rxs, "masked") - # yield from _s6_set_helper(wanted_masked, curr_rxs, "masked") - # service_subset = [] - # for srv in wanted_masked: - # try: - # if curr_rxs[srv] != "masked": - # service_subset.append(srv) - # except KeyError: - # service_subset.append(srv) - # if service_subset: - # yield from _make_set_rx_command("mask", service_subset) + # TODO if save: + if force_save: + if backup: + # will break if repodir key pair in /etc/s6-frontend.conf spans several lines + lines = host.get_fact( + FindInFile, + "/etc/s6-frontend.conf", + r"repodir\s*=", + interpolate_variables=False, + extended_regex=True, + ) + if lines is None: + raise RuntimeError( + "no repodir found in /etc/s6-frontend.conf, or file doesn't exist" + ) + if len(lines) != 1: + raise RuntimeWarning( + "multiple repodir definitions found in /etc/s6-frontend.conf, using the first one" + ) + + # https://skarnet.org/software/execline/envfile.html#syntax + repodir = re.fullmatch(r'^\s*repodir\s*=\s*(/[^\s]*|"/.*")\s*$', lines[0])[1] + + if save_name: + yield from files.directory._inner( + path=repodir, present=False, force=True, force_backup=True + ) + else: + pass + + + yield make_formatted_string_command("s6 set save -f {0}", QuoteString(the_set)) + pass + else: + pass + if save_name: yield make_formatted_string_command("s6 set save {0}", QuoteString(save_name)) else: yield StringCommand("s6 set save") - elif prescriptions and not commit: + elif prescriptions and not do_commit: host.noop( "all services specified match the desired prescriptions and commit not requested" ) @@ -196,18 +203,22 @@ def set( yield make_formatted_string_command("s6 set save -f {0}", QuoteString(the_set)) - # TODO make this not do anything if not needed? how? separate operation? - if commit: - yield StringCommand("s6 set check -F") - yield StringCommand("s6 set commit") + if do_commit: + yield from commit._inner() # present=False else: - yield make_formatted_string_command("s6 set delete {0}", QuoteString(the_set)) + # only yield if the set exists + if host.get_fact(S6SetStatus, the_set): + yield make_formatted_string_command("s6 set delete {0}", QuoteString(the_set)) + else: + host.noop(f"the set \"{the_set}\" doesn't exist") -# TODO server.service compatibility (must use a string for services in that implementation) -@operation() +@operation( + is_idempotent=False, + idempotent_notice="It is not idempotent only when at least one of `commit_set` or `install_set` are `True`.", +) def service( service: str | Iterable[str], running: bool | None = None, @@ -230,7 +241,7 @@ def service( + running: whether the service(s) should be under an s6-supervise. + restarted: whether the service(s) should be restarted + reloaded: whether the service(s) should be reloaded by sending a SIGHUP. Whether the service is reloaded depends on how it handles SIGHUP. - + command: custom command to run after the auto-computed commands. + + command: custom command to run after the auto-computed commands. This must be an s6 subcommand, e.g. "system reboot" gives the command "s6 system reboot". + enabled: whether the service should be given an "active" or "usable" prescription + reload_signal: the signal to send to the service(s) when a reload is desired. + repo: name of the repository to use when managing enabled status, using the one configured in s6-frontend.conf by default. @@ -256,7 +267,6 @@ def service( if isinstance(service, str): service = (service,) - # live state management if (running, restarted, reloaded) != (None,) * 3: # dict[str, bool] whether the services given in the services arg are running. live_statuses = {srv: host.get_fact(S6LiveStatus)[srv] for srv in service} @@ -295,23 +305,22 @@ def service( host.noop(f"all specified services are down: {service}") # TODO: test masked services present in `services` arg on a real system - # offline set management if enabled is not None: if enabled is True: - yield from set._inner(the_set=the_set, prescriptions={srv: enabled_rx for srv in service}) + yield from set._inner( + the_set=the_set, prescriptions={srv: enabled_rx for srv in service} + ) if enabled is False: - yield from set._inner(the_set=the_set, prescriptions={srv: disabled_rx for srv in service}) + yield from set._inner( + the_set=the_set, prescriptions={srv: disabled_rx for srv in service} + ) # s6.set operation already handles s6 set load - # TODO look at how systemd daemon-reload handles this, or maybe a daemon-reload like - # operation not necessary if commit_set: - yield StringCommand("s6 set check -F") - yield StringCommand("s6 set commit") + yield from commit._inner() if install_set: - yield StringCommand("s6 live install") + yield from install._inner() - # TODO - if command: - yield StringCommand(command) + if command: + yield make_formatted_string_command("s6 {0}", command) diff --git a/tests/operations/s6.service/command.yaml b/tests/operations/s6.service/command.yaml new file mode 100644 index 000000000..0c56b5be9 --- /dev/null +++ b/tests/operations/s6.service/command.yaml @@ -0,0 +1,11 @@ +args: + - tipidee +kwargs: + running: true + command: system reboot +facts: + s6.S6LiveStatus: + tipidee: false +commands: + - s6 live start tipidee + - s6 system reboot diff --git a/tests/operations/s6.service/disabled_rx_is_masked.yaml b/tests/operations/s6.service/disabled_rx_is_masked.yaml new file mode 100644 index 000000000..9dabbdde6 --- /dev/null +++ b/tests/operations/s6.service/disabled_rx_is_masked.yaml @@ -0,0 +1,14 @@ +args: + - tipidee +kwargs: + enabled: false + disabled_rx: "masked" +facts: + s6.S6SetStatus: + repository=None, set=current: + tipidee: "active" +commands: + - s6 set mask tipidee + - s6 set check -F + - s6 set commit + diff --git a/tests/operations/s6.service/enabled_rx_is_always.yaml b/tests/operations/s6.service/enabled_rx_is_always.yaml new file mode 100644 index 000000000..66b874555 --- /dev/null +++ b/tests/operations/s6.service/enabled_rx_is_always.yaml @@ -0,0 +1,14 @@ +args: + - tipidee +kwargs: + enabled: true + enabled_rx: always +facts: + s6.S6SetStatus: + repository=None, set=current: + tipidee: usable +commands: + - s6 set make-essential tipidee + - s6 set check -F + - s6 set commit + diff --git a/tests/operations/s6.set/backup.yaml b/tests/operations/s6.set/backup.yaml index e69de29bb..b9f2e89d7 100644 --- a/tests/operations/s6.set/backup.yaml +++ b/tests/operations/s6.set/backup.yaml @@ -0,0 +1,19 @@ +args: + - default +kwargs: + prescriptions: + tipidee: "active" + force_save: true + backup: true +facts: + files.FindInFile: + 'extended_regex=True, interpolate_variables=False, path=/etc/s6-frontend.conf, pattern=repodir\\s*=': + - repodir=/etc/s6/repo + s6.S6SetStatus: + repository=None, set=default: + tipidee: "usable" +commands: + - s6 set load default + - s6 set enable tipidee + - s6 set check -F + - s6 set commit diff --git a/tests/operations/s6.set/delete.yaml b/tests/operations/s6.set/delete.yaml index e69de29bb..152e1890e 100644 --- a/tests/operations/s6.set/delete.yaml +++ b/tests/operations/s6.set/delete.yaml @@ -0,0 +1,11 @@ +args: + - default +kwargs: + present: false +facts: + # shows that "default" set exists + s6.S6SetStatus: + repository=None, set=default: + tipidee: active +commands: + - s6 set delete default diff --git a/tests/operations/s6.set/delete_noop.yaml b/tests/operations/s6.set/delete_noop.yaml new file mode 100644 index 000000000..bebfef0df --- /dev/null +++ b/tests/operations/s6.set/delete_noop.yaml @@ -0,0 +1,9 @@ +args: + - default +kwargs: + present: false +facts: + s6.S6SetStatus: + repository=None, set=default: null +commands: [] +noop_description: "the set \"default\" doesn't exist" diff --git a/tests/operations/s6.set/enable.yaml b/tests/operations/s6.set/enable.yaml index 45e3ded9a..f91423793 100644 --- a/tests/operations/s6.set/enable.yaml +++ b/tests/operations/s6.set/enable.yaml @@ -2,11 +2,11 @@ args: - default kwargs: prescriptions: - tipidee: "active" + tipidee: active facts: s6.S6SetStatus: repository=None, set=default: - tipidee: "usable" + tipidee: usable commands: - s6 set load default - s6 set enable tipidee diff --git a/tests/operations/s6.set/noop.yaml b/tests/operations/s6.set/noop.yaml index af75c721f..748437597 100644 --- a/tests/operations/s6.set/noop.yaml +++ b/tests/operations/s6.set/noop.yaml @@ -5,7 +5,7 @@ kwargs: nftables: "active" mysqld: "masked" tipidee: "usable" - commit: false + do_commit: false facts: s6.S6SetStatus: repository=None, set=default: diff --git a/tests/operations/s6.set/save.yaml b/tests/operations/s6.set/save.yaml index e69de29bb..7a739d0c9 100644 --- a/tests/operations/s6.set/save.yaml +++ b/tests/operations/s6.set/save.yaml @@ -0,0 +1,10 @@ +# save the current working set +kwargs: + save: true + save_name: default + do_commit: false +facts: + s6.S6SetStatus: + repository=None, set=default: null +commands: + - s6 set save default diff --git a/tests/operations/s6.set/save_force.yaml b/tests/operations/s6.set/save_force.yaml new file mode 100644 index 000000000..e69de29bb From d23fe1ae744f827dcb3abf76f074f3b51e9cbae8 Mon Sep 17 00:00:00 2001 From: epicrazzmatazz Date: Thu, 9 Jul 2026 20:41:50 -0400 Subject: [PATCH 12/25] WIP s6 support --- src/pyinfra/operations/s6.py | 202 ++++++++++-------- tests/operations/s6.set/delete_noop.yaml | 2 +- tests/operations/s6.set/save.yaml | 10 - tests/operations/s6.set/save_force.yaml | 0 tests/operations/s6.set_delete/delete.yaml | 4 + .../s6.set_delete/multi_delete.yaml | 4 + tests/operations/s6.set_save/save.yaml | 4 + tests/operations/s6.set_save/save_force.yaml | 7 + .../s6.set_save/save_force_backup.yaml | 17 ++ 9 files changed, 150 insertions(+), 100 deletions(-) delete mode 100644 tests/operations/s6.set/save.yaml delete mode 100644 tests/operations/s6.set/save_force.yaml create mode 100644 tests/operations/s6.set_delete/delete.yaml create mode 100644 tests/operations/s6.set_delete/multi_delete.yaml create mode 100644 tests/operations/s6.set_save/save.yaml create mode 100644 tests/operations/s6.set_save/save_force.yaml create mode 100644 tests/operations/s6.set_save/save_force_backup.yaml diff --git a/src/pyinfra/operations/s6.py b/src/pyinfra/operations/s6.py index 4423b8803..967e6d9f9 100644 --- a/src/pyinfra/operations/s6.py +++ b/src/pyinfra/operations/s6.py @@ -1,15 +1,26 @@ """Manage s6-rc services (https://www.skarnet.org/software/s6-rc/).""" +import os import builtins import re from collections.abc import Iterable from pyinfra import host -from pyinfra.api import QuoteString, StringCommand, operation +from pyinfra.api import QuoteString, StringCommand, OperationError, operation from pyinfra.api.command import make_formatted_string_command from pyinfra.facts.s6 import S6LiveStatus, S6SetStatus -from pyinfra.facts.files import FindInFile +from pyinfra.facts.files import FindInFile, Directory from pyinfra.operations import files +from pyinfra.operations.files import _raise_or_remove_invalid_path + +# https://skarnet.org/software/execline/envfile.html#syntax +_repodir_pattern = re.compile(r'^\s*repodir\s*=\s*(/[^\s]*|"/.*")\s*$') + + +def _make_format_fields(n): + """Returns "{0} {1} ... {n}".""" + return " ".join([f"{{{i}}}" for i in range(n)]) + def _make_live_command(op: str, services: Iterable): """ @@ -17,8 +28,9 @@ def _make_live_command(op: str, services: Iterable): + services: the service(s) to operate on. """ - s = " ".join([f"{{{i}}}" for i in range(len(services))]) - yield make_formatted_string_command(f"s6 live {op} " + s, *map(QuoteString, services)) + yield make_formatted_string_command( + f"s6 live {op} " + _make_format_fields(len(services)), *map(QuoteString, services) + ) def _make_set_command(services: list, curr_rxs: dict, wanted_rx: str): @@ -47,19 +59,90 @@ def _make_set_command(services: list, curr_rxs: dict, wanted_rx: str): } op = _rx_to_subcommand[wanted_rx] - s = " ".join([f"{{{i}}}" for i in range(len(service_subset))]) - yield make_formatted_string_command(f"s6 set {op} " + s, *map(QuoteString, service_subset)) + yield make_formatted_string_command( + f"s6 set {op} " + _make_format_fields(len(service_subset)), + *map(QuoteString, service_subset), + ) + + +# define multiple low level non-idempotent operations, then implement a couple higher level operations which implement idempotency logic. + + +@operation(is_idempotent=False) +def set_delete(names: str | Iterable[str]): + """Delete sets. + + + names: name or list of names of the sets to delete. + """ + if isinstance(names, str): + names = (names,) + + # TODO use _make_format_fields + # s = " ".join([f"{{{i}}}" for i in range(len(names))]) + yield make_formatted_string_command( + "s6 set delete " + _make_format_fields(len(names)), *map(QuoteString, names) + ) +# maybe it is idempotent? @operation(is_idempotent=False) -def commit(): +def set_save(name: str, force: bool = False, force_backup: bool = True): + """Save the current working set. + + + name: name to save the current working set as. + + force: whether to overwrite an existing set of the same name if it exists. + + force_backup: whether to backup an existing set that would be overwritten by `force`. + """ + if force: + if force_backup: + # requires knowing path of the repository. + # regex will break if repodir key pair in /etc/s6-frontend.conf spans several lines. + lines = host.get_fact( + FindInFile, + "/etc/s6-frontend.conf", + r"repodir\s*=", + interpolate_variables=False, + extended_regex=True, + ) + if lines is None: + raise OperationError( + "no repodir found in /etc/s6-frontend.conf, or file doesn't exist" + ) + if len(lines) != 1: + # no OperationWarning + raise RuntimeWarning( + "multiple repodir definitions found in /etc/s6-frontend.conf, using the first one" + ) + if (m := _repodir_pattern.fullmatch(lines[0])) is None: + raise OperationError("failed to match repodir line in /etc/s6-frontend.conf") + repodir = m[1] + + if host.get_fact(Directory, os.path.join(repodir, name)): + yield from _raise_or_remove_invalid_path( + "directory", os.path.join(repodir, name), True, True, False + ) + + # no -f since the old set has already been moved + yield make_formatted_string_command("s6 set save {0}", QuoteString(name)) + + # force_save=True, backup=False + else: + yield make_formatted_string_command("s6 set save -f {0}", QuoteString(name)) + + # save=True, force_save=False + else: + yield make_formatted_string_command("s6 set save {0}", QuoteString(name)) + + +@operation(is_idempotent=False) +def set_commit(): """Check the current working set and commit it.""" yield StringCommand("s6 set check -F") yield StringCommand("s6 set commit") @operation(is_idempotent=False) -def install(): +def live_install(): """Install the compiled (committed) service database into the live state.""" yield StringCommand("s6 live install") @@ -73,10 +156,10 @@ def install(): def set( the_set: str = "current", prescriptions: dict[str] | None = None, - enforce_prescriptions: bool = False, + enforce_prescriptions: bool = True, present: bool = True, save: bool = False, - save_name: str = set, + save_name: str | None = None, force_save: bool = False, backup: bool = True, do_commit: bool = True, @@ -90,13 +173,25 @@ def set( + enforce_prescriptions: whether the `prescriptions` should be the *only* prescriptions in the set (i.e. other services will be removed) + present: whether the set should be present in the repository. + save: whether to save the set to the repository. - + save_name: name for the saved set. + + save_name: name for the saved set. if `None`, the set will be saved under the same name as it was loaded from. saving to the set "current" is an error. + force_save: whether to overwrite existing sets. - + backup: whether to backup overwritten sets by appending the timestamp to the directory name. + + backup: whether to backup overwritten sets by appending the timestamp to the directory name. only works with `force_save` + commit: whether to commit the current(ly loaded) set. Delaying this step can allow for other operations to modify the current set, with the final result being committed at the end. """ + # TODO shouldn't need S6SetStatus if only saving current working set? + + if save: + if save_name is None: + if the_set == "current": + raise ValueError( + 'cannot save to the set named "current", try changing the_set parameter to something else' + ) + save_name = the_set + elif save_name == "current": + raise ValueError('cannot save to the set named "current"') + if prescriptions: if not (builtins.set(prescriptions.values()) <= {"always", "active", "usable", "masked"}): raise ValueError( @@ -128,91 +223,20 @@ def set( if wanted_masked: yield from _make_set_command(wanted_masked, curr_rxs, "masked") - # TODO - if save: - if force_save: - if backup: - # will break if repodir key pair in /etc/s6-frontend.conf spans several lines - lines = host.get_fact( - FindInFile, - "/etc/s6-frontend.conf", - r"repodir\s*=", - interpolate_variables=False, - extended_regex=True, - ) - if lines is None: - raise RuntimeError( - "no repodir found in /etc/s6-frontend.conf, or file doesn't exist" - ) - if len(lines) != 1: - raise RuntimeWarning( - "multiple repodir definitions found in /etc/s6-frontend.conf, using the first one" - ) - - # https://skarnet.org/software/execline/envfile.html#syntax - repodir = re.fullmatch(r'^\s*repodir\s*=\s*(/[^\s]*|"/.*")\s*$', lines[0])[1] - - if save_name: - yield from files.directory._inner( - path=repodir, present=False, force=True, force_backup=True - ) - else: - pass - - - yield make_formatted_string_command("s6 set save -f {0}", QuoteString(the_set)) - pass - else: - pass - - if save_name: - yield make_formatted_string_command("s6 set save {0}", QuoteString(save_name)) - else: - yield StringCommand("s6 set save") - elif prescriptions and not do_commit: host.noop( "all services specified match the desired prescriptions and commit not requested" ) - if force_save: - if backup: - # will break if repodir key pair in /etc/s6-frontend.conf spans several lines - lines = host.get_fact( - FindInFile, - "/etc/s6-frontend.conf", - r"repodir\s*=", - interpolate_variables=False, - extended_regex=True, - ) - if lines is None: - raise RuntimeError( - "no repodir found in /etc/s6-frontend.conf, or file doesn't exist" - ) - if len(lines) != 1: - raise RuntimeWarning( - "multiple repodir definitions found in /etc/s6-frontend.conf, using the first one" - ) - - # https://skarnet.org/software/execline/envfile.html#syntax - repodir = re.fullmatch(r'^\s*repodir\s*=\s*(/[^\s]*|"/.*")\s*$', lines[0])[1] - - yield from files.directory._inner( - path=repodir, present=False, force=True, force_backup=True - ) - - yield make_formatted_string_command("s6 set save -f {0}", QuoteString(the_set)) - if do_commit: - yield from commit._inner() + yield from set_commit._inner() # present=False else: - # only yield if the set exists if host.get_fact(S6SetStatus, the_set): yield make_formatted_string_command("s6 set delete {0}", QuoteString(the_set)) else: - host.noop(f"the set \"{the_set}\" doesn't exist") + host.noop(f'the set "{the_set}" already doesn\'t exist') @operation( @@ -251,8 +275,8 @@ def service( + commit_set: whether to commit the current(ly loaded) set. Delaying this step can allow for other operations to modify the current set, with the final result being committed at the end. + install_set: whether to install the compiled service database (the result of a commit operation) into the live state. This is analagous to systemd's daemon-reload, but not completely: systemd recognizes changes to service files after a reboot, but s6 does not. It only recognizes changes when an s6 live install command is executed. Live state replacement and enablement/disablement of services are coupled in s6. - Specifying multiple services is preferred: fewer commands will be executed, especially in the - case of changing the enabled status of the service, where the service database is recompiled per + Specifying multiple services results in fewer commands executed, especially in the case of + changing the enabled status of the service, where the service database is recompiled per command. Note that this operation does not give as granular control over prescriptions as the set operation does; all services will be assigned the same prescription. """ @@ -318,9 +342,9 @@ def service( # s6.set operation already handles s6 set load if commit_set: - yield from commit._inner() + yield from set_commit._inner() if install_set: - yield from install._inner() + yield from live_install._inner() if command: yield make_formatted_string_command("s6 {0}", command) diff --git a/tests/operations/s6.set/delete_noop.yaml b/tests/operations/s6.set/delete_noop.yaml index bebfef0df..0042f7c66 100644 --- a/tests/operations/s6.set/delete_noop.yaml +++ b/tests/operations/s6.set/delete_noop.yaml @@ -6,4 +6,4 @@ facts: s6.S6SetStatus: repository=None, set=default: null commands: [] -noop_description: "the set \"default\" doesn't exist" +noop_description: "the set \"default\" already doesn't exist" diff --git a/tests/operations/s6.set/save.yaml b/tests/operations/s6.set/save.yaml deleted file mode 100644 index 7a739d0c9..000000000 --- a/tests/operations/s6.set/save.yaml +++ /dev/null @@ -1,10 +0,0 @@ -# save the current working set -kwargs: - save: true - save_name: default - do_commit: false -facts: - s6.S6SetStatus: - repository=None, set=default: null -commands: - - s6 set save default diff --git a/tests/operations/s6.set/save_force.yaml b/tests/operations/s6.set/save_force.yaml deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/operations/s6.set_delete/delete.yaml b/tests/operations/s6.set_delete/delete.yaml new file mode 100644 index 000000000..141bec1fb --- /dev/null +++ b/tests/operations/s6.set_delete/delete.yaml @@ -0,0 +1,4 @@ +args: + - myset +commands: + - s6 set delete myset diff --git a/tests/operations/s6.set_delete/multi_delete.yaml b/tests/operations/s6.set_delete/multi_delete.yaml new file mode 100644 index 000000000..ca5885ae3 --- /dev/null +++ b/tests/operations/s6.set_delete/multi_delete.yaml @@ -0,0 +1,4 @@ +args: + - [ myset_a, myset_b, myset_c ] +commands: + - s6 set delete myset_a myset_b myset_c diff --git a/tests/operations/s6.set_save/save.yaml b/tests/operations/s6.set_save/save.yaml new file mode 100644 index 000000000..feaaa8a8d --- /dev/null +++ b/tests/operations/s6.set_save/save.yaml @@ -0,0 +1,4 @@ +args: + - default +commands: + - s6 set save default diff --git a/tests/operations/s6.set_save/save_force.yaml b/tests/operations/s6.set_save/save_force.yaml new file mode 100644 index 000000000..4f424553b --- /dev/null +++ b/tests/operations/s6.set_save/save_force.yaml @@ -0,0 +1,7 @@ +args: + - default +kwargs: + force: true + force_backup: false +commands: + - s6 set save -f default diff --git a/tests/operations/s6.set_save/save_force_backup.yaml b/tests/operations/s6.set_save/save_force_backup.yaml new file mode 100644 index 000000000..c02875dab --- /dev/null +++ b/tests/operations/s6.set_save/save_force_backup.yaml @@ -0,0 +1,17 @@ +args: + - myset +kwargs: + force: true + force_backup: true +facts: + files.FindInFile: + "extended_regex=True, interpolate_variables=False, path=/etc/s6-frontend.conf, pattern=repodir\\s*=": + - repodir=/etc/s6/repo + files.Directory: + path=/etc/s6/repo/myset: + user: pyinfra + group: pyinfra + mode: 644 +commands: + - mv /etc/s6/repo/myset /etc/s6/repo/myset.a-timestamp + - s6 set save myset From 943f08a5e500b44fddd09270de5a998b61b2c8e4 Mon Sep 17 00:00:00 2001 From: epicrazzmatazz Date: Thu, 9 Jul 2026 22:23:09 -0400 Subject: [PATCH 13/25] WIP s6 support --- src/pyinfra/operations/s6.py | 165 ++++++++++++++++++++++++++++------- 1 file changed, 135 insertions(+), 30 deletions(-) diff --git a/src/pyinfra/operations/s6.py b/src/pyinfra/operations/s6.py index 967e6d9f9..f262df64c 100644 --- a/src/pyinfra/operations/s6.py +++ b/src/pyinfra/operations/s6.py @@ -6,7 +6,7 @@ from collections.abc import Iterable from pyinfra import host -from pyinfra.api import QuoteString, StringCommand, OperationError, operation +from pyinfra.api import QuoteString, StringCommand, OperationError, OperationValueError, operation from pyinfra.api.command import make_formatted_string_command from pyinfra.facts.s6 import S6LiveStatus, S6SetStatus from pyinfra.facts.files import FindInFile, Directory @@ -33,10 +33,10 @@ def _make_live_command(op: str, services: Iterable): ) -def _make_set_command(services: list, curr_rxs: dict, wanted_rx: str): +def _make_rx_command(services: list, current_rxs: dict, wanted_rx: str): """ + services: the services to be assigned a specific prescription. - + curr_rxs: the current prescriptions for all services (from the S6SetStatus fact). + + current_rxs: the current prescriptions for all services (from the S6SetStatus fact). + wanted_rx: the prescription to assign to each service. """ # services that need their prescription changed (not all of them; those that are already in the @@ -45,7 +45,7 @@ def _make_set_command(services: list, curr_rxs: dict, wanted_rx: str): for srv in services: try: - if curr_rxs[srv] != wanted_rx: + if current_rxs[srv] != wanted_rx: service_subset.append(srv) except KeyError: service_subset.append(srv) @@ -63,6 +63,8 @@ def _make_set_command(services: list, curr_rxs: dict, wanted_rx: str): f"s6 set {op} " + _make_format_fields(len(service_subset)), *map(QuoteString, service_subset), ) + else: + host.noop(f"all services given ({services}) are in the desired prescription ({wanted_rx})") # define multiple low level non-idempotent operations, then implement a couple higher level operations which implement idempotency logic. @@ -84,6 +86,60 @@ def set_delete(names: str | Iterable[str]): ) +def set_prescribe(prescriptions: dict, name: str = "current", force_prescriptions: bool = True): + """Change the prescriptions for a set. + + + prescriptions: map of service -> prescription, which is one of "always", "active", "usable", "masked" + + name: name of the set to change prescriptions for. + + force_prescriptions: whether to ensure there are no other services in the set or to only modify the prescriptions of the specified services, leaving others untouched. + + The prescriptions are not saved. They remain in the current working set. + """ + + _working_rx_set = builtins.set(prescriptions.values()) + if not _working_rx_set <= {"always", "active", "usable", "masked"}: + raise OperationValueError( + 'prescriptions must be one of "always", "active", "usable", or "masked"' + ) + + # bin services by desired prescription + service_bins = { + "wanted_always": [], + "wanted_active": [], + "wanted_usable": [], + "wanted_masked": [], + } + + if "always" in _working_rx_set: + service_bins["wanted_always"].extend( + [srv for srv in prescriptions if prescriptions[srv] == "always"] + ) + if "active" in _working_rx_set: + service_bins["wanted_active"].extend( + [srv for srv in prescriptions if prescriptions[srv] == "active"] + ) + if "usable" in _working_rx_set: + service_bins["wanted_usable"].extend( + [srv for srv in prescriptions if prescriptions[srv] == "usable"] + ) + if "masked" in _working_rx_set: + service_bins["wanted_masked"].extend( + [srv for srv in prescriptions if prescriptions[srv] == "masked"] + ) + + current_rxs = host.get_fact(S6SetStatus, name) + + if force_prescriptions: + # mask all services not present in `prescriptions` arg + service_bins["wanted_masked"].extend( + [srv for srv in current_rxs if srv not in prescriptions] + ) + + for wanted_rx, service_set in service_bins.items(): + # TODO noop could be from some, but not all + yield from _make_rx_command(service_set, current_rxs, wanted_rx) + + # maybe it is idempotent? @operation(is_idempotent=False) def set_save(name: str, force: bool = False, force_backup: bool = True): @@ -147,49 +203,98 @@ def live_install(): yield StringCommand("s6 live install") -# TODO for now, no support for custom repository; only the s6-frontend one. but should get this at -# some point, as it allows for user-managed (i.e. non-root) services +# TODO support for repositories other than the one in s6-frontend.conf (e.g. a user repository for +# user services) @operation( is_idempotent=False, - idempotent_notice="If `commit=True`, the operation is stateless due to an unconditional `s6 set check -F` and `s6 set commit`. Otherwise it is idempotent.", + # TODO verify + idempotent_notice="If `commit=True`, the operation is stateless due to an unconditional `s6 set check -F` and `s6 set commit`. `force_prescriptions=False` also breaks idempotency. Otherwise it is idempotent.", ) def set( the_set: str = "current", prescriptions: dict[str] | None = None, - enforce_prescriptions: bool = True, + force_prescriptions: bool = True, present: bool = True, - save: bool = False, - save_name: str | None = None, + do_save: bool = False, + save_as: str | None = None, force_save: bool = False, - backup: bool = True, + force_backup: bool = True, do_commit: bool = True, # TODO configurable s6-frontend.conf location ): """ Manage sets in a repository. - + set: name of the set to manage. + + the_set: name of the set to manage. + prescriptions: the prescriptions to ensure in the set. A map of service name -> prescription, where the prescription is any of "always", "active", "usable", "masked". May be `None`, which allows management of set presence only. - + enforce_prescriptions: whether the `prescriptions` should be the *only* prescriptions in the set (i.e. other services will be removed) + + force_prescriptions: whether the `prescriptions` should be the *only* prescriptions in the set (i.e. other services will be removed) + present: whether the set should be present in the repository. - + save: whether to save the set to the repository. - + save_name: name for the saved set. if `None`, the set will be saved under the same name as it was loaded from. saving to the set "current" is an error. + + do_save: whether to save the set to the repository. + + save_as: name for the saved set. required if `do_save` is True. + force_save: whether to overwrite existing sets. - + backup: whether to backup overwritten sets by appending the timestamp to the directory name. only works with `force_save` - + commit: whether to commit the current(ly loaded) set. Delaying this step can allow for other operations to modify the current set, with the final result being committed at the end. + + force_backup: whether to backup overwritten sets by appending the timestamp to the directory name. only works with `force_save`. + + do_commit: whether to commit the current(ly loaded) set. delaying this step can allow for other operations to modify the current set, with the final result being committed at the end. """ - # TODO shouldn't need S6SetStatus if only saving current working set? + if present: + noops = [] + if prescriptions: + # TODO noop here is when all 4 internal yields to set_prescribe are noop + # idempotency handles in set_prescribe + if force_prescriptions: + yield from set_prescribe._inner(prescriptions, the_set, True) + # TODO non-idempotent? + else: + yield from set_prescribe._inner(prescriptions, the_set, False) + if do_save: + if not save_as: + raise OperationValueError( + "saving a set requires a name to save it under (do_save->save_as)" + ) + # when the current set matches an existing named set exactly, noop + if not host.get_fact(S6SetStatus, save_as) == host.get_fact(S6SetStatus, "current"): + if force_save: + if force_backup: + yield from set_save._inner(save_as, True, True) + else: + yield from set_save._inner(save_as, True, False) + else: + yield from set_save._inner(save_as, False, False) + else: + noops.append("save") + else: + noops.append("save") + # non-idempotent + if do_commit: + yield from set_commit._inner() + else: + noops.append("commit") + + # "global" noop only occurs if all 3 branches are noop + if noops == ["prescribe", "save", "commit"]: + host.noop( + "at least one of the following occurred, depending on which function arguments were passed: the set matches the given prescriptions exactly, there is a saved set with the exact name and prescriptions as what would be saved, or a commit was not requested" + ) - if save: - if save_name is None: + # present=False + else: + if host.get_fact(S6SetStatus, the_set): + yield make_formatted_string_command("s6 set delete {0}", QuoteString(the_set)) + else: + host.noop(f'the set "{the_set}" already doesn\'t exist') + + ########## + + # TODO shouldn't need S6SetStatus if only saving current working set? + if do_save: + if save_as is None: if the_set == "current": raise ValueError( 'cannot save to the set named "current", try changing the_set parameter to something else' ) - save_name = the_set - elif save_name == "current": + save_as = the_set + elif save_as == "current": raise ValueError('cannot save to the set named "current"') if prescriptions: @@ -205,23 +310,23 @@ def set( if present: # prescription of every service in the set - curr_rxs = host.get_fact(S6SetStatus, set=the_set) - if enforce_prescriptions: + current_rxs = host.get_fact(S6SetStatus, set=the_set) + if force_prescriptions: # mask all services not present in `prescriptions` arg - wanted_masked.extend([srv for srv in curr_rxs if srv not in prescriptions]) + wanted_masked.extend([srv for srv in current_rxs if srv not in prescriptions]) - if prescriptions and prescriptions != curr_rxs: + if prescriptions and prescriptions != current_rxs: if the_set != "current": yield make_formatted_string_command("s6 set load {0}", QuoteString(the_set)) if wanted_always: - yield from _make_set_command(wanted_always, curr_rxs, "always") + yield from _make_rx_command(wanted_always, current_rxs, "always") if wanted_active: - yield from _make_set_command(wanted_active, curr_rxs, "active") + yield from _make_rx_command(wanted_active, current_rxs, "active") if wanted_usable: - yield from _make_set_command(wanted_usable, curr_rxs, "usable") + yield from _make_rx_command(wanted_usable, current_rxs, "usable") if wanted_masked: - yield from _make_set_command(wanted_masked, curr_rxs, "masked") + yield from _make_rx_command(wanted_masked, current_rxs, "masked") elif prescriptions and not do_commit: host.noop( From 9f4ca222ec700b0a3b423cd547d9ea3d639d9adb Mon Sep 17 00:00:00 2001 From: epicrazzmatazz Date: Sat, 11 Jul 2026 15:29:20 -0400 Subject: [PATCH 14/25] WIP s6 support --- src/pyinfra/operations/s6.py | 178 +++++++----------- tests/operations/s6.set/backup.yaml | 2 +- tests/operations/s6.set/commit.yaml | 8 +- ...riptions.yaml => force_prescriptions.yaml} | 2 +- tests/operations/s6.set/noop.yaml | 17 -- tests/operations/s6.set/noop_commit.yaml | 6 + .../{delete_noop.yaml => noop_delete.yaml} | 0 tests/operations/s6.set/noop_prescribe.yaml | 19 ++ .../s6.set/noop_prescribe_save.yaml | 22 +++ tests/operations/s6.set/noop_save.yaml | 19 ++ 10 files changed, 135 insertions(+), 138 deletions(-) rename tests/operations/s6.set/{enforce_prescriptions.yaml => force_prescriptions.yaml} (90%) delete mode 100644 tests/operations/s6.set/noop.yaml create mode 100644 tests/operations/s6.set/noop_commit.yaml rename tests/operations/s6.set/{delete_noop.yaml => noop_delete.yaml} (100%) create mode 100644 tests/operations/s6.set/noop_prescribe.yaml create mode 100644 tests/operations/s6.set/noop_prescribe_save.yaml create mode 100644 tests/operations/s6.set/noop_save.yaml diff --git a/src/pyinfra/operations/s6.py b/src/pyinfra/operations/s6.py index f262df64c..2de52da72 100644 --- a/src/pyinfra/operations/s6.py +++ b/src/pyinfra/operations/s6.py @@ -27,17 +27,19 @@ def _make_live_command(op: str, services: Iterable): + op: the operation, e.g. "start", "stop", "restart". + services: the service(s) to operate on. """ - - yield make_formatted_string_command( + return make_formatted_string_command( f"s6 live {op} " + _make_format_fields(len(services)), *map(QuoteString, services) ) def _make_rx_command(services: list, current_rxs: dict, wanted_rx: str): - """ + """Returns a command like "s6 set enable httpd". + + services: the services to be assigned a specific prescription. + current_rxs: the current prescriptions for all services (from the S6SetStatus fact). + wanted_rx: the prescription to assign to each service. + + If every service already matches the desired prescription, None is returned. """ # services that need their prescription changed (not all of them; those that are already in the # desired state are not in this list) @@ -59,41 +61,24 @@ def _make_rx_command(services: list, current_rxs: dict, wanted_rx: str): } op = _rx_to_subcommand[wanted_rx] - yield make_formatted_string_command( + return make_formatted_string_command( f"s6 set {op} " + _make_format_fields(len(service_subset)), *map(QuoteString, service_subset), ) else: - host.noop(f"all services given ({services}) are in the desired prescription ({wanted_rx})") + return None -# define multiple low level non-idempotent operations, then implement a couple higher level operations which implement idempotency logic. - - -@operation(is_idempotent=False) -def set_delete(names: str | Iterable[str]): - """Delete sets. - - + names: name or list of names of the sets to delete. - """ - if isinstance(names, str): - names = (names,) - - # TODO use _make_format_fields - # s = " ".join([f"{{{i}}}" for i in range(len(names))]) - yield make_formatted_string_command( - "s6 set delete " + _make_format_fields(len(names)), *map(QuoteString, names) - ) - - -def set_prescribe(prescriptions: dict, name: str = "current", force_prescriptions: bool = True): - """Change the prescriptions for a set. +def _make_rx_commands(prescriptions: dict, name: str = "current", force_prescriptions: bool = True): + """Returns all commands necessary to bring the prescriptions to the desired state. + prescriptions: map of service -> prescription, which is one of "always", "active", "usable", "masked" + name: name of the set to change prescriptions for. + force_prescriptions: whether to ensure there are no other services in the set or to only modify the prescriptions of the specified services, leaving others untouched. - The prescriptions are not saved. They remain in the current working set. + Returns a length-4 array of `StringCommand`s or `None`s, for each prescription type, depending + on whether any services needed to be switched to that prescription. This is essentially 4 + invocations of `_make_rx_command` for each type of prescription. """ _working_rx_set = builtins.set(prescriptions.values()) @@ -135,9 +120,26 @@ def set_prescribe(prescriptions: dict, name: str = "current", force_prescription [srv for srv in current_rxs if srv not in prescriptions] ) - for wanted_rx, service_set in service_bins.items(): - # TODO noop could be from some, but not all - yield from _make_rx_command(service_set, current_rxs, wanted_rx) + return [ + _make_rx_command(service_set, current_rxs, wanted_rx.removeprefix("wanted_")) + for wanted_rx, service_set in service_bins.items() + ] + + +@operation(is_idempotent=False) +def set_delete(names: str | Iterable[str]): + """Delete sets. + + + names: name or list of names of the sets to delete. + """ + if isinstance(names, str): + names = (names,) + + # TODO use _make_format_fields + # s = " ".join([f"{{{i}}}" for i in range(len(names))]) + yield make_formatted_string_command( + "s6 set delete " + _make_format_fields(len(names)), *map(QuoteString, names) + ) # maybe it is idempotent? @@ -208,7 +210,10 @@ def live_install(): @operation( is_idempotent=False, # TODO verify - idempotent_notice="If `commit=True`, the operation is stateless due to an unconditional `s6 set check -F` and `s6 set commit`. `force_prescriptions=False` also breaks idempotency. Otherwise it is idempotent.", + # when the_set is not "current", always executes `s6 set load [the_set]` + # force_backup idempotent? + # force_save idempotent? + idempotent_notice='Not idempotent by default. If any of the following are true, then idempotency is broken: `the_set != "current", `do_commit=True`', ) def set( the_set: str = "current", @@ -238,22 +243,29 @@ def set( """ if present: - noops = [] + # deleting noops from the set if they don't occur cleans up conditionals, not requiring else clauses + noops = {"prescribe", "save", "commit"} if prescriptions: - # TODO noop here is when all 4 internal yields to set_prescribe are noop - # idempotency handles in set_prescribe - if force_prescriptions: - yield from set_prescribe._inner(prescriptions, the_set, True) - # TODO non-idempotent? - else: - yield from set_prescribe._inner(prescriptions, the_set, False) + if any( + cmds := _make_rx_commands( + prescriptions, the_set, True if force_prescriptions else False + ) + ): + noops.remove("prescribe") + if the_set != "current": + yield make_formatted_string_command("s6 set load {0}", QuoteString(the_set)) + yield from filter(lambda cmd: cmd is not None, cmds) + if do_save: if not save_as: raise OperationValueError( - "saving a set requires a name to save it under (do_save->save_as)" + "saving a set requires a name to save it under (do_save => save_as)" ) # when the current set matches an existing named set exactly, noop if not host.get_fact(S6SetStatus, save_as) == host.get_fact(S6SetStatus, "current"): + noops.remove("save") + if the_set != "current": + yield make_formatted_string_command("s6 set load {0}", QuoteString(the_set)) if force_save: if force_backup: yield from set_save._inner(save_as, True, True) @@ -261,80 +273,20 @@ def set( yield from set_save._inner(save_as, True, False) else: yield from set_save._inner(save_as, False, False) - else: - noops.append("save") - else: - noops.append("save") + # non-idempotent if do_commit: + noops.remove("commit") yield from set_commit._inner() - else: - noops.append("commit") - # "global" noop only occurs if all 3 branches are noop - if noops == ["prescribe", "save", "commit"]: - host.noop( - "at least one of the following occurred, depending on which function arguments were passed: the set matches the given prescriptions exactly, there is a saved set with the exact name and prescriptions as what would be saved, or a commit was not requested" - ) - - # present=False - else: - if host.get_fact(S6SetStatus, the_set): - yield make_formatted_string_command("s6 set delete {0}", QuoteString(the_set)) - else: - host.noop(f'the set "{the_set}" already doesn\'t exist') - - ########## - - # TODO shouldn't need S6SetStatus if only saving current working set? - if do_save: - if save_as is None: - if the_set == "current": - raise ValueError( - 'cannot save to the set named "current", try changing the_set parameter to something else' - ) - save_as = the_set - elif save_as == "current": - raise ValueError('cannot save to the set named "current"') - - if prescriptions: - if not (builtins.set(prescriptions.values()) <= {"always", "active", "usable", "masked"}): - raise ValueError( - 'prescriptions can only take values "always", "active", "usable", or "masked"' - ) - - wanted_always = [srv for srv, rx in prescriptions.items() if rx == "always"] - wanted_active = [srv for srv, rx in prescriptions.items() if rx == "active"] - wanted_usable = [srv for srv, rx in prescriptions.items() if rx == "usable"] - wanted_masked = [srv for srv, rx in prescriptions.items() if rx == "masked"] - - if present: - # prescription of every service in the set - current_rxs = host.get_fact(S6SetStatus, set=the_set) - if force_prescriptions: - # mask all services not present in `prescriptions` arg - wanted_masked.extend([srv for srv in current_rxs if srv not in prescriptions]) - - if prescriptions and prescriptions != current_rxs: - if the_set != "current": - yield make_formatted_string_command("s6 set load {0}", QuoteString(the_set)) - - if wanted_always: - yield from _make_rx_command(wanted_always, current_rxs, "always") - if wanted_active: - yield from _make_rx_command(wanted_active, current_rxs, "active") - if wanted_usable: - yield from _make_rx_command(wanted_usable, current_rxs, "usable") - if wanted_masked: - yield from _make_rx_command(wanted_masked, current_rxs, "masked") - - elif prescriptions and not do_commit: - host.noop( - "all services specified match the desired prescriptions and commit not requested" - ) - - if do_commit: - yield from set_commit._inner() + # "global" noop if all 3 branches noop + if noops == {"prescribe", "save", "commit"}: + if prescriptions and not do_save: + host.noop('the set "current" already has the desired prescriptions') + elif do_save: + host.noop(f'the set "current" already has the desired prescriptions and matches with the existing set "{save_as}"') + else: + host.noop(f'the set "{the_set}" already exists') # present=False else: @@ -406,19 +358,19 @@ def service( if running is False: if some_up: - yield from _make_live_command("stop", all_up_services) + yield _make_live_command("stop", all_up_services) else: host.noop(f"all specified services are already down: {service}") if running is True: if not all_up: - yield from _make_live_command("start", all_down_services) + yield _make_live_command("start", all_down_services) else: host.noop(f"all specified services are already up: {service}") if restarted: if some_up: - yield from _make_live_command("restart", all_up_services) + yield _make_live_command("restart", all_up_services) else: host.noop(f"all specified services are down: {service}") diff --git a/tests/operations/s6.set/backup.yaml b/tests/operations/s6.set/backup.yaml index b9f2e89d7..ae4734405 100644 --- a/tests/operations/s6.set/backup.yaml +++ b/tests/operations/s6.set/backup.yaml @@ -4,7 +4,7 @@ kwargs: prescriptions: tipidee: "active" force_save: true - backup: true + force_backup: true facts: files.FindInFile: 'extended_regex=True, interpolate_variables=False, path=/etc/s6-frontend.conf, pattern=repodir\\s*=': diff --git a/tests/operations/s6.set/commit.yaml b/tests/operations/s6.set/commit.yaml index 8992e6d96..3742fbd89 100644 --- a/tests/operations/s6.set/commit.yaml +++ b/tests/operations/s6.set/commit.yaml @@ -1,9 +1,5 @@ -args: - - default -facts: - s6.S6SetStatus: - repository=None, set=default: - tipidee: "active" +kwargs: + do_commit: true commands: - s6 set check -F - s6 set commit diff --git a/tests/operations/s6.set/enforce_prescriptions.yaml b/tests/operations/s6.set/force_prescriptions.yaml similarity index 90% rename from tests/operations/s6.set/enforce_prescriptions.yaml rename to tests/operations/s6.set/force_prescriptions.yaml index d26e3d256..e6509f424 100644 --- a/tests/operations/s6.set/enforce_prescriptions.yaml +++ b/tests/operations/s6.set/force_prescriptions.yaml @@ -3,7 +3,7 @@ args: kwargs: prescriptions: tipidee: "usable" - enforce_prescriptions: true + force_prescriptions: true facts: s6.S6SetStatus: repository=None, set=default: diff --git a/tests/operations/s6.set/noop.yaml b/tests/operations/s6.set/noop.yaml deleted file mode 100644 index 748437597..000000000 --- a/tests/operations/s6.set/noop.yaml +++ /dev/null @@ -1,17 +0,0 @@ -args: - - default -kwargs: - prescriptions: - nftables: "active" - mysqld: "masked" - tipidee: "usable" - do_commit: false -facts: - s6.S6SetStatus: - repository=None, set=default: - nftables: "active" - mysqld: "masked" - tipidee: "usable" -commands: [] -noop_description: all services specified match the desired prescriptions and commit not requested - diff --git a/tests/operations/s6.set/noop_commit.yaml b/tests/operations/s6.set/noop_commit.yaml new file mode 100644 index 000000000..0ecd974a4 --- /dev/null +++ b/tests/operations/s6.set/noop_commit.yaml @@ -0,0 +1,6 @@ +args: + - current +kwargs: + do_commit: false +commands: [] +noop_description: the set "current" already exists diff --git a/tests/operations/s6.set/delete_noop.yaml b/tests/operations/s6.set/noop_delete.yaml similarity index 100% rename from tests/operations/s6.set/delete_noop.yaml rename to tests/operations/s6.set/noop_delete.yaml diff --git a/tests/operations/s6.set/noop_prescribe.yaml b/tests/operations/s6.set/noop_prescribe.yaml new file mode 100644 index 000000000..877ee6fc1 --- /dev/null +++ b/tests/operations/s6.set/noop_prescribe.yaml @@ -0,0 +1,19 @@ +args: + # when declaring prescriptions, noop is only possible when working on the current set, as others + # must be loaded first by executing `s6 set load [the_set]` + - current +kwargs: + prescriptions: + nftables: "active" + mysqld: "masked" + tipidee: "usable" + do_commit: false +facts: + s6.S6SetStatus: + repository=None, set=current: + nftables: "active" + mysqld: "masked" + tipidee: "usable" +commands: [] +noop_description: the set "current" already has the desired prescriptions + diff --git a/tests/operations/s6.set/noop_prescribe_save.yaml b/tests/operations/s6.set/noop_prescribe_save.yaml new file mode 100644 index 000000000..a5ea97e64 --- /dev/null +++ b/tests/operations/s6.set/noop_prescribe_save.yaml @@ -0,0 +1,22 @@ +args: + - current +kwargs: + prescriptions: + nftables: "active" + mysqld: "masked" + tipidee: "usable" + do_save: true + save_as: "default" + do_commit: false +facts: + s6.S6SetStatus: + repository=None, set=current: + nftables: "active" + mysqld: "masked" + tipidee: "usable" + repository=None, set=default: + nftables: "active" + mysqld: "masked" + tipidee: "usable" +commands: [] +noop_description: the set "current" already has the desired prescriptions and matches with the existing set "default" diff --git a/tests/operations/s6.set/noop_save.yaml b/tests/operations/s6.set/noop_save.yaml new file mode 100644 index 000000000..304c350ab --- /dev/null +++ b/tests/operations/s6.set/noop_save.yaml @@ -0,0 +1,19 @@ +args: + # noop only possible when working on the current set + - current +kwargs: + do_save: true + save_as: "default" + do_commit: false +facts: + s6.S6SetStatus: + repository=None, set=current: + nftables: "active" + mysqld: "masked" + tipidee: "usable" + repository=None, set=default: + nftables: "active" + mysqld: "masked" + tipidee: "usable" +commands: [] +noop_description: the set "current" already has the desired prescriptions and matches with the existing set "default" From 2df056d6e879d6efbc85ab91065bae61494b3340 Mon Sep 17 00:00:00 2001 From: epicrazzmatazz Date: Sat, 11 Jul 2026 15:59:36 -0400 Subject: [PATCH 15/25] WIP s6 support --- src/pyinfra/facts/s6.py | 40 +++++++++++++++++++++----------- src/pyinfra/operations/s6.py | 17 +++++++------- src/pyinfra/operations/server.py | 4 +++- 3 files changed, 39 insertions(+), 22 deletions(-) diff --git a/src/pyinfra/facts/s6.py b/src/pyinfra/facts/s6.py index ff707f54d..49c512297 100644 --- a/src/pyinfra/facts/s6.py +++ b/src/pyinfra/facts/s6.py @@ -1,17 +1,20 @@ +from typing_extensions import override + from pyinfra.api import FactBase, QuoteString from pyinfra.api.command import make_formatted_string_command +from pyinfra.facts.files import File class S6RepositoryList(FactBase[list[str]]): """Returns the name of every set in a repository.""" + @override def check_preconditions(self, state, host): - from pyinfra.facts.files import File - # TODO allow passing S6_FRONTEND_CONF envvar - if not host.get_fact(File("/etc/s6/frontend.conf")): + if not host.get_fact(File, "/etc/s6/frontend.conf"): return "couldn't read /etc/s6/frontend.conf or it doesn't exist" + @override def requires_command(self, repository=None): # "s6" only sees the repository configured in /etc/s6-frontend.conf if repository: @@ -19,6 +22,7 @@ def requires_command(self, repository=None): return "s6" + @override def command(self, repository=None): """ + repository: path of the repository to inspect, default the one configured in `/etc/s6-frontend.conf`. @@ -28,6 +32,7 @@ def command(self, repository=None): return "s6 repository list" + @override def process(self, output): # "s6" command doesn't list the set named "current", while s6-rc-repo-list does. this # try-except normalizes the output. @@ -50,19 +55,20 @@ class S6SetStatus(FactBase[dict[str, str]]): """ + @override def check_preconditions(self, state, host): - from pyinfra.facts.files import File - # TODO allow passing S6_FRONTEND_CONF envvar - if not host.get_fact(File("/etc/s6/frontend.conf")): + if not host.get_fact(File, "/etc/s6/frontend.conf"): return "couldn't read /etc/s6/frontend.conf or it doesn't exist" + @override def requires_command(self, set="current", repository=None): if repository or set != "current": return "s6-rc-set-status" return "s6" + @override def command(self, set="current", repository=None): """ + set: the set to inspect. @@ -71,19 +77,24 @@ def command(self, set="current", repository=None): if set != "current": if repository: return make_formatted_string_command( - "s6-rc-set-status -r {0} {1}; echo EXIT CODE: $?", QuoteString(repository), QuoteString(set) + "s6-rc-set-status -r {0} {1}; echo EXIT CODE: $?", + QuoteString(repository), + QuoteString(set), ) - return make_formatted_string_command("s6-rc-set-status {0}; echo EXIT CODE: $?", QuoteString(set)) + return make_formatted_string_command( + "s6-rc-set-status {0}; echo EXIT CODE: $?", QuoteString(set) + ) if repository: return make_formatted_string_command( - "s6-rc-set-status -r {0} current; echo EXIT CODE: $?", QuoteString(repository) + "s6-rc-set-status -r {0} current; echo EXIT CODE: $?", QuoteString(repository) ) # TODO consider case where util-linux triggers column pretty printing return "s6 set status; echo EXIT CODE: $?" + @override def process(self, output): # exit code 3: nonexistent set # NOTE: will have to always specify 3 as success exit code when using this fact @@ -91,7 +102,8 @@ def process(self, output): return return { - triplet[0]: triplet[-1] for triplet in map(lambda line: line.partition("/"), output[:-1]) + triplet[0]: triplet[-1] + for triplet in map(lambda line: line.partition("/"), output[:-1]) } @@ -104,19 +116,21 @@ class S6LiveStatus(FactBase[dict[str, bool]]): """ # could also rewrite this using the "s6 live status" command + @override def requires_command(self): return "s6" + @override def check_preconditions(self, state, host): - from pyinfra.facts.files import File - # TODO allow passing S6_FRONTEND_CONF envvar - if not host.get_fact(File("/etc/s6/frontend.conf")): + if not host.get_fact(File, "/etc/s6/frontend.conf"): return "couldn't read /etc/s6/frontend.conf or it doesn't exist" + @override def command(self): return "s6 live status" + @override def process(self, output): return { triple[0]: True if triple[2] == "up" else False diff --git a/src/pyinfra/operations/s6.py b/src/pyinfra/operations/s6.py index 2de52da72..0426bf0c8 100644 --- a/src/pyinfra/operations/s6.py +++ b/src/pyinfra/operations/s6.py @@ -3,14 +3,13 @@ import os import builtins import re -from collections.abc import Iterable +from collections.abc import Sequence from pyinfra import host from pyinfra.api import QuoteString, StringCommand, OperationError, OperationValueError, operation from pyinfra.api.command import make_formatted_string_command from pyinfra.facts.s6 import S6LiveStatus, S6SetStatus from pyinfra.facts.files import FindInFile, Directory -from pyinfra.operations import files from pyinfra.operations.files import _raise_or_remove_invalid_path # https://skarnet.org/software/execline/envfile.html#syntax @@ -22,7 +21,7 @@ def _make_format_fields(n): return " ".join([f"{{{i}}}" for i in range(n)]) -def _make_live_command(op: str, services: Iterable): +def _make_live_command(op: str, services: Sequence): """ + op: the operation, e.g. "start", "stop", "restart". + services: the service(s) to operate on. @@ -88,7 +87,7 @@ def _make_rx_commands(prescriptions: dict, name: str = "current", force_prescrip ) # bin services by desired prescription - service_bins = { + service_bins: dict[str, list[str]] = { "wanted_always": [], "wanted_active": [], "wanted_usable": [], @@ -127,7 +126,7 @@ def _make_rx_commands(prescriptions: dict, name: str = "current", force_prescrip @operation(is_idempotent=False) -def set_delete(names: str | Iterable[str]): +def set_delete(names: str | Sequence[str]): """Delete sets. + names: name or list of names of the sets to delete. @@ -217,7 +216,7 @@ def live_install(): ) def set( the_set: str = "current", - prescriptions: dict[str] | None = None, + prescriptions: dict[str, str] | None = None, force_prescriptions: bool = True, present: bool = True, do_save: bool = False, @@ -284,7 +283,9 @@ def set( if prescriptions and not do_save: host.noop('the set "current" already has the desired prescriptions') elif do_save: - host.noop(f'the set "current" already has the desired prescriptions and matches with the existing set "{save_as}"') + host.noop( + f'the set "current" already has the desired prescriptions and matches with the existing set "{save_as}"' + ) else: host.noop(f'the set "{the_set}" already exists') @@ -301,7 +302,7 @@ def set( idempotent_notice="It is not idempotent only when at least one of `commit_set` or `install_set` are `True`.", ) def service( - service: str | Iterable[str], + service: str | Sequence[str], running: bool | None = None, restarted: bool | None = None, reloaded: bool | None = None, diff --git a/src/pyinfra/operations/server.py b/src/pyinfra/operations/server.py index 7fbf2ceb2..e7ad9dbf8 100644 --- a/src/pyinfra/operations/server.py +++ b/src/pyinfra/operations/server.py @@ -740,7 +740,9 @@ def service( else: raise OperationError( - ("No init system found (no systemctl, rc-service, initctl, sv, s6, /etc/init.d or /etc/rc.d found)"), + ( + "No init system found (no systemctl, rc-service, initctl, sv, s6, /etc/init.d or /etc/rc.d found)" + ), ) yield from service_operation._inner( From 32d70a93fa7a1201aaa38f73794dfccfbd02105a Mon Sep 17 00:00:00 2001 From: epicrazzmatazz Date: Sat, 11 Jul 2026 16:14:27 -0400 Subject: [PATCH 16/25] WIP s6 support --- src/pyinfra/operations/s6.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pyinfra/operations/s6.py b/src/pyinfra/operations/s6.py index 0426bf0c8..9b3c0f7dc 100644 --- a/src/pyinfra/operations/s6.py +++ b/src/pyinfra/operations/s6.py @@ -331,7 +331,7 @@ def service( + enabled_rx: name of the prescription to assign to the service(s) when enabled, which could be either "active" or "always" + disabled_rx: name of the prescription to assign to the service(s) when disabled, which could be either "usable" or "masked" + commit_set: whether to commit the current(ly loaded) set. Delaying this step can allow for other operations to modify the current set, with the final result being committed at the end. - + install_set: whether to install the compiled service database (the result of a commit operation) into the live state. This is analagous to systemd's daemon-reload, but not completely: systemd recognizes changes to service files after a reboot, but s6 does not. It only recognizes changes when an s6 live install command is executed. Live state replacement and enablement/disablement of services are coupled in s6. + + install_set: whether to install the compiled service database (the result of a commit operation) into the live state. This is analogous to systemd's daemon-reload, but not completely: systemd recognizes changes to service files after a reboot, but s6 does not. It only recognizes changes when an s6 live install command is executed. Live state replacement and enablement/disablement of services are coupled in s6. Specifying multiple services results in fewer commands executed, especially in the case of changing the enabled status of the service, where the service database is recompiled per From 2048dd4f7bfd598eff11ebb7a1b652dd917d5553 Mon Sep 17 00:00:00 2001 From: epicrazzmatazz Date: Sat, 11 Jul 2026 16:35:17 -0400 Subject: [PATCH 17/25] WIP s6 support --- tests/operations/s6.set_save/save_force_backup.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/operations/s6.set_save/save_force_backup.yaml b/tests/operations/s6.set_save/save_force_backup.yaml index c02875dab..658521e54 100644 --- a/tests/operations/s6.set_save/save_force_backup.yaml +++ b/tests/operations/s6.set_save/save_force_backup.yaml @@ -1,3 +1,7 @@ +# bug on windows runner, files.Directory expects path=/etc/s6\\repo +require_platform: + - "Linux" + - "Darwin" args: - myset kwargs: From bbd005b8ac75b5169601e7702a48d6688fc150f6 Mon Sep 17 00:00:00 2001 From: epicrazzmatazz Date: Fri, 21 Aug 2026 13:06:52 -0400 Subject: [PATCH 18/25] fix shell command injection --- src/pyinfra/operations/s6.py | 3 ++- tests/operations/s6.service/command_injection.yaml | 11 +++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 tests/operations/s6.service/command_injection.yaml diff --git a/src/pyinfra/operations/s6.py b/src/pyinfra/operations/s6.py index 9b3c0f7dc..ae6860040 100644 --- a/src/pyinfra/operations/s6.py +++ b/src/pyinfra/operations/s6.py @@ -3,6 +3,7 @@ import os import builtins import re +import shlex from collections.abc import Sequence from pyinfra import host @@ -405,4 +406,4 @@ def service( yield from live_install._inner() if command: - yield make_formatted_string_command("s6 {0}", command) + yield StringCommand("s6", *map(QuoteString, shlex.split(command))) diff --git a/tests/operations/s6.service/command_injection.yaml b/tests/operations/s6.service/command_injection.yaml new file mode 100644 index 000000000..5d3e80275 --- /dev/null +++ b/tests/operations/s6.service/command_injection.yaml @@ -0,0 +1,11 @@ +args: + - tipidee +kwargs: + running: true + command: system reboot; rm -rf / +facts: + s6.S6LiveStatus: + tipidee: false +commands: + - s6 live start tipidee + - s6 system 'reboot;' rm -rf / From 941cb3def49ef15859868c0fb3dc97306360e238 Mon Sep 17 00:00:00 2001 From: epicrazzmatazz Date: Fri, 21 Aug 2026 13:10:07 -0400 Subject: [PATCH 19/25] log warning rather than raise --- src/pyinfra/operations/s6.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pyinfra/operations/s6.py b/src/pyinfra/operations/s6.py index ae6860040..074e1986c 100644 --- a/src/pyinfra/operations/s6.py +++ b/src/pyinfra/operations/s6.py @@ -6,7 +6,7 @@ import shlex from collections.abc import Sequence -from pyinfra import host +from pyinfra import host, logger from pyinfra.api import QuoteString, StringCommand, OperationError, OperationValueError, operation from pyinfra.api.command import make_formatted_string_command from pyinfra.facts.s6 import S6LiveStatus, S6SetStatus @@ -168,7 +168,7 @@ def set_save(name: str, force: bool = False, force_backup: bool = True): ) if len(lines) != 1: # no OperationWarning - raise RuntimeWarning( + logger.warning( "multiple repodir definitions found in /etc/s6-frontend.conf, using the first one" ) if (m := _repodir_pattern.fullmatch(lines[0])) is None: From db8c0088d8865b69c850dd212c038a70d999871d Mon Sep 17 00:00:00 2001 From: epicrazzmatazz Date: Fri, 21 Aug 2026 13:53:15 -0400 Subject: [PATCH 20/25] rename s6.set -> s6.manage_set --- src/pyinfra/operations/s6.py | 19 +++++++++---------- .../{s6.set => s6.manage_set}/backup.yaml | 0 .../{s6.set => s6.manage_set}/commit.yaml | 0 .../{s6.set => s6.manage_set}/delete.yaml | 0 .../{s6.set => s6.manage_set}/disable.yaml | 0 .../{s6.set => s6.manage_set}/enable.yaml | 0 .../force_prescriptions.yaml | 0 .../multi_disable.yaml | 0 .../multi_enable.yaml | 0 .../multi_mixed.yaml | 0 .../noop_commit.yaml | 0 .../noop_delete.yaml | 2 +- .../noop_prescribe.yaml | 0 .../noop_prescribe_save.yaml | 0 .../{s6.set => s6.manage_set}/noop_save.yaml | 0 .../{s6.set => s6.manage_set}/standard.yaml | 0 .../s6.service/dont_restart_if_stopped.yaml | 1 + 17 files changed, 11 insertions(+), 11 deletions(-) rename tests/operations/{s6.set => s6.manage_set}/backup.yaml (100%) rename tests/operations/{s6.set => s6.manage_set}/commit.yaml (100%) rename tests/operations/{s6.set => s6.manage_set}/delete.yaml (100%) rename tests/operations/{s6.set => s6.manage_set}/disable.yaml (100%) rename tests/operations/{s6.set => s6.manage_set}/enable.yaml (100%) rename tests/operations/{s6.set => s6.manage_set}/force_prescriptions.yaml (100%) rename tests/operations/{s6.set => s6.manage_set}/multi_disable.yaml (100%) rename tests/operations/{s6.set => s6.manage_set}/multi_enable.yaml (100%) rename tests/operations/{s6.set => s6.manage_set}/multi_mixed.yaml (100%) rename tests/operations/{s6.set => s6.manage_set}/noop_commit.yaml (100%) rename tests/operations/{s6.set => s6.manage_set}/noop_delete.yaml (66%) rename tests/operations/{s6.set => s6.manage_set}/noop_prescribe.yaml (100%) rename tests/operations/{s6.set => s6.manage_set}/noop_prescribe_save.yaml (100%) rename tests/operations/{s6.set => s6.manage_set}/noop_save.yaml (100%) rename tests/operations/{s6.set => s6.manage_set}/standard.yaml (100%) diff --git a/src/pyinfra/operations/s6.py b/src/pyinfra/operations/s6.py index 074e1986c..5ec696962 100644 --- a/src/pyinfra/operations/s6.py +++ b/src/pyinfra/operations/s6.py @@ -1,7 +1,6 @@ """Manage s6-rc services (https://www.skarnet.org/software/s6-rc/).""" import os -import builtins import re import shlex from collections.abc import Sequence @@ -81,7 +80,7 @@ def _make_rx_commands(prescriptions: dict, name: str = "current", force_prescrip invocations of `_make_rx_command` for each type of prescription. """ - _working_rx_set = builtins.set(prescriptions.values()) + _working_rx_set = set(prescriptions.values()) if not _working_rx_set <= {"always", "active", "usable", "masked"}: raise OperationValueError( 'prescriptions must be one of "always", "active", "usable", or "masked"' @@ -215,7 +214,7 @@ def live_install(): # force_save idempotent? idempotent_notice='Not idempotent by default. If any of the following are true, then idempotency is broken: `the_set != "current", `do_commit=True`', ) -def set( +def manage_set( the_set: str = "current", prescriptions: dict[str, str] | None = None, force_prescriptions: bool = True, @@ -320,7 +319,7 @@ def service( """ Manage the state of s6-supervised services. - + services: name(s) of the service(s) to manage. + + service: name(s) of the service(s) to manage. + running: whether the service(s) should be under an s6-supervise. + restarted: whether the service(s) should be restarted + reloaded: whether the service(s) should be reloaded by sending a SIGHUP. Whether the service is reloaded depends on how it handles SIGHUP. @@ -362,19 +361,19 @@ def service( if some_up: yield _make_live_command("stop", all_up_services) else: - host.noop(f"all specified services are already down: {service}") + host.noop(f"all specified services are already down: {', '.join(service)}") if running is True: if not all_up: yield _make_live_command("start", all_down_services) else: - host.noop(f"all specified services are already up: {service}") + host.noop(f"all specified services are already up: {', '.join(service)}") if restarted: if some_up: yield _make_live_command("restart", all_up_services) else: - host.noop(f"all specified services are down: {service}") + host.noop(f"all specified services are down: {', '.join(service)}") if reloaded: if some_up: @@ -385,17 +384,17 @@ def service( *map(QuoteString, all_up_services), ) else: - host.noop(f"all specified services are down: {service}") + host.noop(f"all specified services are down: {', '.join(service)}") # TODO: test masked services present in `services` arg on a real system if enabled is not None: if enabled is True: - yield from set._inner( + yield from manage_set._inner( the_set=the_set, prescriptions={srv: enabled_rx for srv in service} ) if enabled is False: - yield from set._inner( + yield from manage_set._inner( the_set=the_set, prescriptions={srv: disabled_rx for srv in service} ) diff --git a/tests/operations/s6.set/backup.yaml b/tests/operations/s6.manage_set/backup.yaml similarity index 100% rename from tests/operations/s6.set/backup.yaml rename to tests/operations/s6.manage_set/backup.yaml diff --git a/tests/operations/s6.set/commit.yaml b/tests/operations/s6.manage_set/commit.yaml similarity index 100% rename from tests/operations/s6.set/commit.yaml rename to tests/operations/s6.manage_set/commit.yaml diff --git a/tests/operations/s6.set/delete.yaml b/tests/operations/s6.manage_set/delete.yaml similarity index 100% rename from tests/operations/s6.set/delete.yaml rename to tests/operations/s6.manage_set/delete.yaml diff --git a/tests/operations/s6.set/disable.yaml b/tests/operations/s6.manage_set/disable.yaml similarity index 100% rename from tests/operations/s6.set/disable.yaml rename to tests/operations/s6.manage_set/disable.yaml diff --git a/tests/operations/s6.set/enable.yaml b/tests/operations/s6.manage_set/enable.yaml similarity index 100% rename from tests/operations/s6.set/enable.yaml rename to tests/operations/s6.manage_set/enable.yaml diff --git a/tests/operations/s6.set/force_prescriptions.yaml b/tests/operations/s6.manage_set/force_prescriptions.yaml similarity index 100% rename from tests/operations/s6.set/force_prescriptions.yaml rename to tests/operations/s6.manage_set/force_prescriptions.yaml diff --git a/tests/operations/s6.set/multi_disable.yaml b/tests/operations/s6.manage_set/multi_disable.yaml similarity index 100% rename from tests/operations/s6.set/multi_disable.yaml rename to tests/operations/s6.manage_set/multi_disable.yaml diff --git a/tests/operations/s6.set/multi_enable.yaml b/tests/operations/s6.manage_set/multi_enable.yaml similarity index 100% rename from tests/operations/s6.set/multi_enable.yaml rename to tests/operations/s6.manage_set/multi_enable.yaml diff --git a/tests/operations/s6.set/multi_mixed.yaml b/tests/operations/s6.manage_set/multi_mixed.yaml similarity index 100% rename from tests/operations/s6.set/multi_mixed.yaml rename to tests/operations/s6.manage_set/multi_mixed.yaml diff --git a/tests/operations/s6.set/noop_commit.yaml b/tests/operations/s6.manage_set/noop_commit.yaml similarity index 100% rename from tests/operations/s6.set/noop_commit.yaml rename to tests/operations/s6.manage_set/noop_commit.yaml diff --git a/tests/operations/s6.set/noop_delete.yaml b/tests/operations/s6.manage_set/noop_delete.yaml similarity index 66% rename from tests/operations/s6.set/noop_delete.yaml rename to tests/operations/s6.manage_set/noop_delete.yaml index 0042f7c66..6009b048f 100644 --- a/tests/operations/s6.set/noop_delete.yaml +++ b/tests/operations/s6.manage_set/noop_delete.yaml @@ -6,4 +6,4 @@ facts: s6.S6SetStatus: repository=None, set=default: null commands: [] -noop_description: "the set \"default\" already doesn't exist" +noop_description: the set "default" already doesn't exist diff --git a/tests/operations/s6.set/noop_prescribe.yaml b/tests/operations/s6.manage_set/noop_prescribe.yaml similarity index 100% rename from tests/operations/s6.set/noop_prescribe.yaml rename to tests/operations/s6.manage_set/noop_prescribe.yaml diff --git a/tests/operations/s6.set/noop_prescribe_save.yaml b/tests/operations/s6.manage_set/noop_prescribe_save.yaml similarity index 100% rename from tests/operations/s6.set/noop_prescribe_save.yaml rename to tests/operations/s6.manage_set/noop_prescribe_save.yaml diff --git a/tests/operations/s6.set/noop_save.yaml b/tests/operations/s6.manage_set/noop_save.yaml similarity index 100% rename from tests/operations/s6.set/noop_save.yaml rename to tests/operations/s6.manage_set/noop_save.yaml diff --git a/tests/operations/s6.set/standard.yaml b/tests/operations/s6.manage_set/standard.yaml similarity index 100% rename from tests/operations/s6.set/standard.yaml rename to tests/operations/s6.manage_set/standard.yaml diff --git a/tests/operations/s6.service/dont_restart_if_stopped.yaml b/tests/operations/s6.service/dont_restart_if_stopped.yaml index 7a4279108..e7f13361f 100644 --- a/tests/operations/s6.service/dont_restart_if_stopped.yaml +++ b/tests/operations/s6.service/dont_restart_if_stopped.yaml @@ -6,3 +6,4 @@ facts: s6.S6LiveStatus: tipidee: false commands: [] +noop_description: "all specified services are down: tipidee" From 46ffffd9f72861b3d1d877bfbaf2279997853f5d Mon Sep 17 00:00:00 2001 From: epicrazzmatazz Date: Tue, 25 Aug 2026 12:35:51 -0400 Subject: [PATCH 21/25] update facts --- src/pyinfra/facts/s6.py | 88 +++++++++++++++-------------------------- 1 file changed, 32 insertions(+), 56 deletions(-) diff --git a/src/pyinfra/facts/s6.py b/src/pyinfra/facts/s6.py index 49c512297..f65f4df2a 100644 --- a/src/pyinfra/facts/s6.py +++ b/src/pyinfra/facts/s6.py @@ -1,46 +1,34 @@ from typing_extensions import override from pyinfra.api import FactBase, QuoteString -from pyinfra.api.command import make_formatted_string_command +from pyinfra.api.command import make_formatted_string_command, StringCommand +from pyinfra.facts.server import Command from pyinfra.facts.files import File class S6RepositoryList(FactBase[list[str]]): - """Returns the name of every set in a repository.""" - - @override - def check_preconditions(self, state, host): - # TODO allow passing S6_FRONTEND_CONF envvar - if not host.get_fact(File, "/etc/s6/frontend.conf"): - return "couldn't read /etc/s6/frontend.conf or it doesn't exist" + """Returns the name of every set in a repository, including the set named "current".""" @override def requires_command(self, repository=None): - # "s6" only sees the repository configured in /etc/s6-frontend.conf - if repository: - return "s6-rc-repo-list" - - return "s6" + return "s6-rc-repo-list" + # and envfile, but that comes bundled in execline dependency of s6 @override def command(self, repository=None): """ - + repository: path of the repository to inspect, default the one configured in `/etc/s6-frontend.conf`. + + repository: path of the repository to inspect, default the one in the s6-frontend configuration. """ if repository: return make_formatted_string_command("s6-rc-repo-list -r {0}", QuoteString(repository)) - return "s6 repository list" + # if no repository passed, try to get its location from the s6-frontend configuration file + return StringCommand( + '[ ! -z "$S6_CONF" ] || S6_CONF=/etc/s6.conf && envfile "$S6_CONF" sh -c \'s6-rc-repo-list -r "$repodir"\'; echo EXIT CODE: $?' + ) @override def process(self, output): - # "s6" command doesn't list the set named "current", while s6-rc-repo-list does. this - # try-except normalizes the output. - try: - del output[output.index("current")] - except ValueError: - pass - return output @@ -55,44 +43,30 @@ class S6SetStatus(FactBase[dict[str, str]]): """ - @override - def check_preconditions(self, state, host): - # TODO allow passing S6_FRONTEND_CONF envvar - if not host.get_fact(File, "/etc/s6/frontend.conf"): - return "couldn't read /etc/s6/frontend.conf or it doesn't exist" - @override def requires_command(self, set="current", repository=None): - if repository or set != "current": - return "s6-rc-set-status" - - return "s6" + return "s6-rc-set-status" + # and sh @override def command(self, set="current", repository=None): """ + set: the set to inspect. + TODO update + repository: path of the repository to inspect, default `None` which resolves the following way: If `set` is unspecified, the repository in `/etc/s6-frontend.conf` will be used. If `set` is specified, the compiled-in default `/var/lib/s6-rc/repository` will be used. """ - if set != "current": - if repository: - return make_formatted_string_command( - "s6-rc-set-status -r {0} {1}; echo EXIT CODE: $?", - QuoteString(repository), - QuoteString(set), - ) - - return make_formatted_string_command( - "s6-rc-set-status {0}; echo EXIT CODE: $?", QuoteString(set) - ) - if repository: return make_formatted_string_command( - "s6-rc-set-status -r {0} current; echo EXIT CODE: $?", QuoteString(repository) + "s6-rc-set-status -r {0} {1}; echo EXIT CODE: $?", + QuoteString(repository), + QuoteString(set), ) - # TODO consider case where util-linux triggers column pretty printing - return "s6 set status; echo EXIT CODE: $?" + # extra escaping needed for make_formatted_string_command, but not in StringCommand + return make_formatted_string_command( + '[ ! -z \\"$S6_CONF\\" ] || S6_CONF=/etc/s6.conf && envfile \\"$S6_CONF\\" sh -c \\\'s6-rc-set-status -r \\"$repodir\\" {0}\\\'; echo EXIT CODE: $?', + QuoteString(set), + ) @override def process(self, output): @@ -108,8 +82,7 @@ def process(self, output): class S6LiveStatus(FactBase[dict[str, bool]]): - """ - Returns a dict of name -> status for each service in the live state. + """ Returns a dict of name -> status for each service in the live state. True when the service is "running", meaning the service is managed by an `s6-supervise`s, False otherwise. @@ -118,21 +91,24 @@ class S6LiveStatus(FactBase[dict[str, bool]]): # could also rewrite this using the "s6 live status" command @override def requires_command(self): - return "s6" + return "s6-rc" @override def check_preconditions(self, state, host): - # TODO allow passing S6_FRONTEND_CONF envvar - if not host.get_fact(File, "/etc/s6/frontend.conf"): - return "couldn't read /etc/s6/frontend.conf or it doesn't exist" + if not host.run_shell_command('[ ! -z "$S6_CONF" ] || [ -f /etc/s6.conf ]')[0]: + return "couldn't find s6-frontend configuration" @override def command(self): - return "s6 live status" + return "s6-rc -c list" @override def process(self, output): + # example of an output line: + # seatd-srv/longrun//up/explicit + # returns + # { "seatd-srv": True } return { - triple[0]: True if triple[2] == "up" else False - for triple in map(lambda line: line.partition("/"), output) + statusline[0]: True if statusline[3] == "up" else False + for statusline in map(lambda line: line.split("/"), output) } From 7c8ec8597969b7133c5145467a75b64526231732 Mon Sep 17 00:00:00 2001 From: epicrazzmatazz Date: Tue, 25 Aug 2026 20:58:53 -0400 Subject: [PATCH 22/25] update operations --- src/pyinfra/operations/s6.py | 292 ++++++++++++++++++++++++++--------- 1 file changed, 220 insertions(+), 72 deletions(-) diff --git a/src/pyinfra/operations/s6.py b/src/pyinfra/operations/s6.py index 5ec696962..d7bcd7725 100644 --- a/src/pyinfra/operations/s6.py +++ b/src/pyinfra/operations/s6.py @@ -6,18 +6,95 @@ from collections.abc import Sequence from pyinfra import host, logger -from pyinfra.api import QuoteString, StringCommand, OperationError, OperationValueError, operation +from pyinfra.api import ( + QuoteString, + StringCommand, + OperationError, + OperationValueError, + operation, + Host, +) from pyinfra.api.command import make_formatted_string_command -from pyinfra.facts.s6 import S6LiveStatus, S6SetStatus -from pyinfra.facts.files import FindInFile, Directory +from pyinfra.facts.s6 import S6LiveStatus, S6SetStatus, S6RepositoryList +from pyinfra.facts.files import FindInFile, Directory, File, FileContents +from pyinfra.facts.server import Command from pyinfra.operations.files import _raise_or_remove_invalid_path # https://skarnet.org/software/execline/envfile.html#syntax _repodir_pattern = re.compile(r'^\s*repodir\s*=\s*(/[^\s]*|"/.*")\s*$') +def _get_repodir_from_conf(content: list[str]): + """Get the path to a repodir from an s6 configuration file. + + + content: the text in the file, with each line being a string in the list. + """ + # simplistic check for now, avoiding complicated syntax + # does not account for a statement broken over multiple lines with backslashes + # assume repodir line is unique + if match := re.search( + r'^\s*repodir\s*=\s*(/[^\s]*|"/.*")\s*$', "\n".join(content), re.MULTILINE + ): + return match.group(1) + + return + + ## TODO edge case + # for line, next_line in itertools.pairwise(content): + # # ignore commented, empty and whitespace lines + # if line == "": + # continue + # elif re.search(r'^\s*#', line, re.ASCII): + # continue + # elif re.fullmatch(r'\s+', line, re.ASCII): + # continue + + # # line continuation detected + # if line.endswith("\\"): + # if next_line.endswith("\\"): + # pass + # elif "=" not in line: + # return False + + +def _s6_repo_lookup(host: Host): + """Attempts to find an s6-rc repository. + + The algorithm is as follows: + + The environment variable `S6_CONF` is checked for a valid filesystem path, and if it is, check + whether the file defines a repodir. Otherwise check whether the file `/etc/s6.conf` exists and + if it defines a repodir. If both fail, no value is returned. + """ + conf_envvar = host.get_fact(Command, 'printf %s "$S6_CONF"') + + if conf_envvar != "": + conf = host.get_fact(FileContents, conf_envvar) + if repodir := _get_repodir_from_conf(conf): + return repodir + + if s6_conf := host.get_fact(FileContents, "/etc/s6.conf"): + if repodir := _get_repodir_from_conf(s6_conf): + return repodir + + # for compatibility with older versions of s6-frontend. the envvar used to have a different name + # and config file was called something else. + conf_envvar_old = host.get_fact(Command, 'printf %s "$S6_FRONTEND_CONF"') + + if conf_envvar_old != "": + conf = host.get_fact(FileContents, conf_envvar_old) + if repodir := _get_repodir_from_conf(conf): + return repodir + + if frontend_conf := host.get_fact(FileContents, "/etc/s6-frontend.conf"): + if repodir := _get_repodir_from_conf(frontend_conf): + return repodir + + return + + def _make_format_fields(n): - """Returns "{0} {1} ... {n}".""" + """Returns "{0} {1} ... {n-1}".""" return " ".join([f"{{{i}}}" for i in range(n)]) @@ -31,12 +108,13 @@ def _make_live_command(op: str, services: Sequence): ) -def _make_rx_command(services: list, current_rxs: dict, wanted_rx: str): +def _make_rx_command(services: list, current_rxs: dict, wanted_rx: str, the_set: str = "current"): """Returns a command like "s6 set enable httpd". + services: the services to be assigned a specific prescription. + current_rxs: the current prescriptions for all services (from the S6SetStatus fact). + wanted_rx: the prescription to assign to each service. + + the_set: name of the set to operate on If every service already matches the desired prescription, None is returned. """ @@ -59,16 +137,21 @@ def _make_rx_command(services: list, current_rxs: dict, wanted_rx: str): "masked": "mask", } + # example of the string passed into make_formatted_string_command + # s6 set disable -s {5} {0} {1} {2} {3} {4} op = _rx_to_subcommand[wanted_rx] return make_formatted_string_command( - f"s6 set {op} " + _make_format_fields(len(service_subset)), + f"s6 set {op} -s {{{len(service_subset)}}} " + _make_format_fields(len(service_subset)), *map(QuoteString, service_subset), + QuoteString(the_set), ) else: return None -def _make_rx_commands(prescriptions: dict, name: str = "current", force_prescriptions: bool = True): +def _make_rx_commands( + prescriptions: dict, the_set: str = "current", force_prescriptions: bool = True +): """Returns all commands necessary to bring the prescriptions to the desired state. + prescriptions: map of service -> prescription, which is one of "always", "active", "usable", "masked" @@ -111,7 +194,7 @@ def _make_rx_commands(prescriptions: dict, name: str = "current", force_prescrip [srv for srv in prescriptions if prescriptions[srv] == "masked"] ) - current_rxs = host.get_fact(S6SetStatus, name) + current_rxs = host.get_fact(S6SetStatus, the_set) if force_prescriptions: # mask all services not present in `prescriptions` arg @@ -120,11 +203,41 @@ def _make_rx_commands(prescriptions: dict, name: str = "current", force_prescrip ) return [ - _make_rx_command(service_set, current_rxs, wanted_rx.removeprefix("wanted_")) + _make_rx_command( + service_set, current_rxs, wanted_rx.removeprefix("wanted_"), the_set=the_set + ) for wanted_rx, service_set in service_bins.items() ] +@operation(is_idempotent=False) +def set_create(name: str, repository: str | None = None): + """Create a new set. + + + name: name for the new set. + + repository: repository to save the set in. + + This is a distinct operation from set_copy. + """ + if repository: + existing_sets = host.get_fact(S6RepositoryList, repository=repository) + if name not in existing_sets: + yield make_formatted_string_command( + "s6-rc-set-new -r {0} {1}", QuoteString(repository), QuoteString(name) + ) + + elif repodir := _s6_repo_lookup(host): + existing_sets = host.get_fact(S6RepositoryList, repository=repodir) + if name not in existing_sets: + yield make_formatted_string_command( + "s6-rc-set-new -r {0} {1}", QuoteString(repodir), QuoteString(name) + ) + # fallback to compiled-in default repo, which is /var/lib/s6/repository if left unchanged at + # compile time + else: + yield make_formatted_string_command("s6-rc-set-new {0}", QuoteString(name)) + + @operation(is_idempotent=False) def set_delete(names: str | Sequence[str]): """Delete sets. @@ -141,69 +254,90 @@ def set_delete(names: str | Sequence[str]): ) -# maybe it is idempotent? @operation(is_idempotent=False) -def set_save(name: str, force: bool = False, force_backup: bool = True): - """Save the current working set. +def set_copy(dest: str, source="current", force: bool = False): + """Save the contents of the given set as a new set. - + name: name to save the current working set as. + + dest: name of the saved copy. + + source: name of the set to copy. + force: whether to overwrite an existing set of the same name if it exists. - + force_backup: whether to backup an existing set that would be overwritten by `force`. """ if force: - if force_backup: - # requires knowing path of the repository. - # regex will break if repodir key pair in /etc/s6-frontend.conf spans several lines. - lines = host.get_fact( - FindInFile, - "/etc/s6-frontend.conf", - r"repodir\s*=", - interpolate_variables=False, - extended_regex=True, - ) - if lines is None: - raise OperationError( - "no repodir found in /etc/s6-frontend.conf, or file doesn't exist" - ) - if len(lines) != 1: - # no OperationWarning - logger.warning( - "multiple repodir definitions found in /etc/s6-frontend.conf, using the first one" - ) - if (m := _repodir_pattern.fullmatch(lines[0])) is None: - raise OperationError("failed to match repodir line in /etc/s6-frontend.conf") - repodir = m[1] - - if host.get_fact(Directory, os.path.join(repodir, name)): - yield from _raise_or_remove_invalid_path( - "directory", os.path.join(repodir, name), True, True, False - ) + yield make_formatted_string_command( + "s6 set copy -f {0} {1}", QuoteString(source), QuoteString(dest) + ) - # no -f since the old set has already been moved - yield make_formatted_string_command("s6 set save {0}", QuoteString(name)) + # backing up requires more thought. try to use $S6_FRONTEND_CONF, envfile, and whether the + # fact that the friendly set names are symlinks to unique names changes anything + # (e.g. `readlink /etc/s6/repo/sources/default` gives `.default:YN2tP3`). + + # + force_backup: whether to backup an existing set that would be overwritten by `force`. + # if force_backup: + # # requires knowing path of the repository. + # # regex will break if repodir key pair in /etc/s6-frontend.conf spans several lines. + # lines = host.get_fact( + # FindInFile, + # "/etc/s6-frontend.conf", + # r"repodir\s*=", + # interpolate_variables=False, + # extended_regex=True, + # ) + # if lines is None: + # raise OperationError( + # "no repodir found in /etc/s6-frontend.conf, or file doesn't exist" + # ) + # if len(lines) != 1: + # # no OperationWarning + # logger.warning( + # "multiple repodir definitions found in /etc/s6-frontend.conf, using the first one" + # ) + # if (m := _repodir_pattern.fullmatch(lines[0])) is None: + # raise OperationError("failed to match repodir line in /etc/s6-frontend.conf") + # repodir = m[1] + + # if host.get_fact(Directory, os.path.join(repodir, dest)): + # yield from _raise_or_remove_invalid_path( + # "directory", os.path.join(repodir, dest), True, True, False + # ) + + # # no -f since the old set has already been moved + # yield make_formatted_string_command( + # "s6 set copy {0} {1}", QuoteString(source), QuoteString(dest) + # ) # force_save=True, backup=False - else: - yield make_formatted_string_command("s6 set save -f {0}", QuoteString(name)) + # else: + # yield make_formatted_string_command( + # "s6 set copy -f {0} {1}", QuoteString(source), QuoteString(dest) + # ) # save=True, force_save=False else: - yield make_formatted_string_command("s6 set save {0}", QuoteString(name)) + yield make_formatted_string_command( + "s6 set copy {0} {1}", QuoteString(source), QuoteString(dest) + ) @operation(is_idempotent=False) -def set_commit(): - """Check the current working set and commit it.""" - yield StringCommand("s6 set check -F") - yield StringCommand("s6 set commit") +def set_commit(the_set: str = "current"): + """Check the given set and commit it. + + + the_set: name of the set to check and commit. + """ + yield make_formatted_string_command("s6 set check -F -s {0}", QuoteString(the_set)) + yield make_formatted_string_command("s6 set commit -s {0}", QuoteString(the_set)) @operation(is_idempotent=False) -def live_install(): - """Install the compiled (committed) service database into the live state.""" - yield StringCommand("s6 live install") +def live_install(the_set: str = "current"): + """Install a compiled (committed) service database into the live state. + + + the_set: name of the set containing an already compiled service database to be installed into the live state. + """ + yield make_formatted_string_command("s6 live install -s {0}", QuoteString(the_set)) +# TODO refactor now that skarnet added -s option to many s6 frontend commands # TODO support for repositories other than the one in s6-frontend.conf (e.g. a user repository for # user services) @operation( @@ -216,15 +350,16 @@ def live_install(): ) def manage_set( the_set: str = "current", + # no -r option exposed by s6-frontend + # repository: str | None = None, prescriptions: dict[str, str] | None = None, force_prescriptions: bool = True, present: bool = True, do_save: bool = False, save_as: str | None = None, force_save: bool = False, - force_backup: bool = True, + # force_backup: bool = True, do_commit: bool = True, - # TODO configurable s6-frontend.conf location ): """ Manage sets in a repository. @@ -236,14 +371,29 @@ def manage_set( + do_save: whether to save the set to the repository. + save_as: name for the saved set. required if `do_save` is True. + force_save: whether to overwrite existing sets. - + force_backup: whether to backup overwritten sets by appending the timestamp to the directory name. only works with `force_save`. + do_commit: whether to commit the current(ly loaded) set. delaying this step can allow for other operations to modify the current set, with the final result being committed at the end. """ + # + force_backup: whether to backup overwritten sets by appending the timestamp to the directory name. only works with `force_save`. if present: # deleting noops from the set if they don't occur cleans up conditionals, not requiring else clauses - noops = {"prescribe", "save", "commit"} + noops = {"create", "prescribe", "save", "commit"} + + ### set creation ### + # the repository needs to be the one recognized by s6-frontend, as the other sub-operations + # use s6-frontend commands that don't have a repository option; they implicitly use the one + # in the configuration. if the repository were able to be specified by the user here, set + # creation could occur in a different repository than the other operations. + if repodir := _s6_repo_lookup(host): + existing_sets = host.get_fact(S6RepositoryList, repository=repodir) + if the_set not in existing_sets: + noops.remove("create") + yield make_formatted_string_command( + "s6-rc-set-new -r {0} {1}", QuoteString(repodir), QuoteString(the_set) + ) + + ### prescription assignment ### if prescriptions: if any( cmds := _make_rx_commands( @@ -251,35 +401,32 @@ def manage_set( ) ): noops.remove("prescribe") - if the_set != "current": - yield make_formatted_string_command("s6 set load {0}", QuoteString(the_set)) + # _make_rx_commands should already include -s the_set, no need to load now + # if the_set != "current": + # yield make_formatted_string_command("s6 set load {0}", QuoteString(the_set)) yield from filter(lambda cmd: cmd is not None, cmds) + ### saving ### if do_save: if not save_as: raise OperationValueError( "saving a set requires a name to save it under (do_save => save_as)" ) # when the current set matches an existing named set exactly, noop - if not host.get_fact(S6SetStatus, save_as) == host.get_fact(S6SetStatus, "current"): + if not host.get_fact(S6SetStatus, save_as) == host.get_fact(S6SetStatus, the_set): noops.remove("save") - if the_set != "current": - yield make_formatted_string_command("s6 set load {0}", QuoteString(the_set)) - if force_save: - if force_backup: - yield from set_save._inner(save_as, True, True) - else: - yield from set_save._inner(save_as, True, False) - else: - yield from set_save._inner(save_as, False, False) + yield from set_copy._inner( + dest=save_as, source=the_set, force=True if force_save else False + ) + ### committing ### # non-idempotent if do_commit: noops.remove("commit") - yield from set_commit._inner() + yield from set_commit._inner(the_set) - # "global" noop if all 3 branches noop - if noops == {"prescribe", "save", "commit"}: + # "global" noop if all 4 branches noop + if noops == {"create", "prescribe", "save", "commit"}: if prescriptions and not do_save: host.noop('the set "current" already has the desired prescriptions') elif do_save: @@ -289,6 +436,7 @@ def manage_set( else: host.noop(f'the set "{the_set}" already exists') + ### deleting ### # present=False else: if host.get_fact(S6SetStatus, the_set): From e6bde7847fefb64dc9517d011d54cada03179586 Mon Sep 17 00:00:00 2001 From: epicrazzmatazz Date: Wed, 26 Aug 2026 15:31:51 -0400 Subject: [PATCH 23/25] update facts and operations --- src/pyinfra/facts/s6.py | 13 +- src/pyinfra/operations/s6.py | 431 ++++++++++++++++++----------------- 2 files changed, 233 insertions(+), 211 deletions(-) diff --git a/src/pyinfra/facts/s6.py b/src/pyinfra/facts/s6.py index f65f4df2a..614685d60 100644 --- a/src/pyinfra/facts/s6.py +++ b/src/pyinfra/facts/s6.py @@ -44,28 +44,27 @@ class S6SetStatus(FactBase[dict[str, str]]): """ @override - def requires_command(self, set="current", repository=None): + def requires_command(self, the_set="current", repository=None): return "s6-rc-set-status" # and sh @override - def command(self, set="current", repository=None): + def command(self, the_set="current", repository=None): """ - + set: the set to inspect. - TODO update - + repository: path of the repository to inspect, default `None` which resolves the following way: If `set` is unspecified, the repository in `/etc/s6-frontend.conf` will be used. If `set` is specified, the compiled-in default `/var/lib/s6-rc/repository` will be used. + + the_set: the set to inspect. + + repository: path of the repository to inspect, default `None` which resolves the following way: the repository is read from the config file stored in the environment variable `S6_CONF`, with fallback to a hardcoded path `/etc/s6.conf`, and if that fails, the compiled-in default repository will be used, most likely `/var/lib/s6/repository`. """ if repository: return make_formatted_string_command( "s6-rc-set-status -r {0} {1}; echo EXIT CODE: $?", QuoteString(repository), - QuoteString(set), + QuoteString(the_set), ) # extra escaping needed for make_formatted_string_command, but not in StringCommand return make_formatted_string_command( '[ ! -z \\"$S6_CONF\\" ] || S6_CONF=/etc/s6.conf && envfile \\"$S6_CONF\\" sh -c \\\'s6-rc-set-status -r \\"$repodir\\" {0}\\\'; echo EXIT CODE: $?', - QuoteString(set), + QuoteString(the_set), ) @override diff --git a/src/pyinfra/operations/s6.py b/src/pyinfra/operations/s6.py index d7bcd7725..e8de62de0 100644 --- a/src/pyinfra/operations/s6.py +++ b/src/pyinfra/operations/s6.py @@ -20,25 +20,42 @@ from pyinfra.facts.server import Command from pyinfra.operations.files import _raise_or_remove_invalid_path -# https://skarnet.org/software/execline/envfile.html#syntax -_repodir_pattern = re.compile(r'^\s*repodir\s*=\s*(/[^\s]*|"/.*")\s*$') +def _get_s6_frontend_conf_contents(host: Host) -> list[str] | None: + """Attempts to locate and return the contents of an s6-frontend configuration. -def _get_repodir_from_conf(content: list[str]): - """Get the path to a repodir from an s6 configuration file. + Does not return anything if no configuration is found. - + content: the text in the file, with each line being a string in the list. + + host: pyinfra host object on which to perform this lookup. + """ + conf_envvar = host.get_fact(Command, 'printf %s "$S6_CONF"') + if conf_envvar != "": + if contents := host.get_fact(FileContents, conf_envvar): + return contents + + if contents := host.get_fact(FileContents, "/etc/s6.conf"): + return contents + + +def _get_value_from_conf(key: str, content: list[str]): + """Get the value associated with a key in an s6-frontend configuration. + + If no repodir is found, nothing is returned. + + + key: the key associated with the value, e.g. "repodir". + + content: the file contents, as a list of strings. """ # simplistic check for now, avoiding complicated syntax # does not account for a statement broken over multiple lines with backslashes - # assume repodir line is unique + # assume the key is unique if match := re.search( - r'^\s*repodir\s*=\s*(/[^\s]*|"/.*")\s*$', "\n".join(content), re.MULTILINE + # https://skarnet.org/software/execline/envfile.html#syntax + r"^\s*" + re.escape(key) + r'\s*=\s*(/[^\s]*|"/.*")\s*$', + "\n".join(content), + re.MULTILINE, ): return match.group(1) - return - ## TODO edge case # for line, next_line in itertools.pairwise(content): # # ignore commented, empty and whitespace lines @@ -57,40 +74,40 @@ def _get_repodir_from_conf(content: list[str]): # return False -def _s6_repo_lookup(host: Host): - """Attempts to find an s6-rc repository. - - The algorithm is as follows: - - The environment variable `S6_CONF` is checked for a valid filesystem path, and if it is, check - whether the file defines a repodir. Otherwise check whether the file `/etc/s6.conf` exists and - if it defines a repodir. If both fail, no value is returned. - """ - conf_envvar = host.get_fact(Command, 'printf %s "$S6_CONF"') - - if conf_envvar != "": - conf = host.get_fact(FileContents, conf_envvar) - if repodir := _get_repodir_from_conf(conf): - return repodir - - if s6_conf := host.get_fact(FileContents, "/etc/s6.conf"): - if repodir := _get_repodir_from_conf(s6_conf): - return repodir - - # for compatibility with older versions of s6-frontend. the envvar used to have a different name - # and config file was called something else. - conf_envvar_old = host.get_fact(Command, 'printf %s "$S6_FRONTEND_CONF"') - - if conf_envvar_old != "": - conf = host.get_fact(FileContents, conf_envvar_old) - if repodir := _get_repodir_from_conf(conf): - return repodir - - if frontend_conf := host.get_fact(FileContents, "/etc/s6-frontend.conf"): - if repodir := _get_repodir_from_conf(frontend_conf): - return repodir - - return +# def _s6_frontend_repo_lookup(host: Host): +# """Attempts to find an s6-rc repository. +# +# The algorithm is as follows: +# +# The environment variable `S6_CONF` is checked for a valid filesystem path, and if it is, check +# whether the file defines a repodir. Otherwise check whether the file `/etc/s6.conf` exists and +# if it defines a repodir. If both fail, no value is returned. +# """ +# conf_envvar = host.get_fact(Command, 'printf %s "$S6_CONF"') +# +# if conf_envvar != "": +# conf = host.get_fact(FileContents, conf_envvar) +# if repodir := _get_repodir_from_conf(conf): +# return repodir +# +# if s6_conf := host.get_fact(FileContents, "/etc/s6.conf"): +# if repodir := _get_repodir_from_conf(s6_conf): +# return repodir +# +# # for compatibility with older versions of s6-frontend. the envvar used to have a different name +# # and config file was called something else. +# conf_envvar_old = host.get_fact(Command, 'printf %s "$S6_FRONTEND_CONF"') +# +# if conf_envvar_old != "": +# conf = host.get_fact(FileContents, conf_envvar_old) +# if repodir := _get_repodir_from_conf(conf): +# return repodir +# +# if frontend_conf := host.get_fact(FileContents, "/etc/s6-frontend.conf"): +# if repodir := _get_repodir_from_conf(frontend_conf): +# return repodir +# +# return def _make_format_fields(n): @@ -108,18 +125,26 @@ def _make_live_command(op: str, services: Sequence): ) -def _make_rx_command(services: list, current_rxs: dict, wanted_rx: str, the_set: str = "current"): - """Returns a command like "s6 set enable httpd". +def _make_set_change_command( + services: list, current_rxs: dict, wanted_rx: str, repository: str, the_set: str = "current" +): + """Returns a command like "s6-rc-set-change -r /etc/s6/repo current active httpd". + services: the services to be assigned a specific prescription. - + current_rxs: the current prescriptions for all services (from the S6SetStatus fact). + + current_rxs: the current prescriptions for all services (from the `S6SetStatus` fact). + wanted_rx: the prescription to assign to each service. - + the_set: name of the set to operate on + + repository: path to the repository containing the set. + + the_set: name of the set to operate on. If every service already matches the desired prescription, None is returned. """ + if wanted_rx not in {"always", "active", "usable", "masked"}: + raise ValueError( + f'wanted_rx must be one of "always", "active", "usable", or "masked", got {wanted_rx}.' + ) + # services that need their prescription changed (not all of them; those that are already in the - # desired state are not in this list) + # desired state are excluded from this list) service_subset = [] for srv in services: @@ -130,32 +155,35 @@ def _make_rx_command(services: list, current_rxs: dict, wanted_rx: str, the_set: service_subset.append(srv) if service_subset: - _rx_to_subcommand = { - "always": "make-essential", - "active": "enable", - "usable": "disable", - "masked": "mask", - } - - # example of the string passed into make_formatted_string_command - # s6 set disable -s {5} {0} {1} {2} {3} {4} - op = _rx_to_subcommand[wanted_rx] + # _rx_to_subcommand = { + # "always": "make-essential", + # "active": "enable", + # "usable": "disable", + # "masked": "mask", + # } + + # example of the string passed into make_formatted_string_command: + # s6-rc-set-change -r {5} {6} usable {0} {1} {2} {3} {4} return make_formatted_string_command( - f"s6 set {op} -s {{{len(service_subset)}}} " + _make_format_fields(len(service_subset)), + f"s6-rc-set-change -r {{{len(service_subset)}}} {{{len(service_subset) + 1}}} {wanted_rx} " + + _make_format_fields(len(service_subset)), *map(QuoteString, service_subset), + QuoteString(repository), QuoteString(the_set), ) + else: return None -def _make_rx_commands( - prescriptions: dict, the_set: str = "current", force_prescriptions: bool = True +def _make_all_set_change_commands( + prescriptions: dict, repository: str, the_set: str = "current", force_prescriptions: bool = True ): """Returns all commands necessary to bring the prescriptions to the desired state. - + prescriptions: map of service -> prescription, which is one of "always", "active", "usable", "masked" - + name: name of the set to change prescriptions for. + + prescriptions: map of service -> prescription, which is one of "always", "active", "usable", "masked". + + repository: path to a repository containing the set. + + the_set: the set to change the prescriptions of. + force_prescriptions: whether to ensure there are no other services in the set or to only modify the prescriptions of the specified services, leaving others untouched. Returns a length-4 array of `StringCommand`s or `None`s, for each prescription type, depending @@ -194,7 +222,7 @@ def _make_rx_commands( [srv for srv in prescriptions if prescriptions[srv] == "masked"] ) - current_rxs = host.get_fact(S6SetStatus, the_set) + current_rxs = host.get_fact(S6SetStatus, the_set, repository) if force_prescriptions: # mask all services not present in `prescriptions` arg @@ -203,15 +231,15 @@ def _make_rx_commands( ) return [ - _make_rx_command( - service_set, current_rxs, wanted_rx.removeprefix("wanted_"), the_set=the_set + _make_set_change_command( + service_set, current_rxs, wanted_rx.removeprefix("wanted_"), repository, the_set=the_set ) for wanted_rx, service_set in service_bins.items() ] @operation(is_idempotent=False) -def set_create(name: str, repository: str | None = None): +def set_create(name: str, repository: str): """Create a new set. + name: name for the new set. @@ -219,134 +247,115 @@ def set_create(name: str, repository: str | None = None): This is a distinct operation from set_copy. """ - if repository: - existing_sets = host.get_fact(S6RepositoryList, repository=repository) - if name not in existing_sets: - yield make_formatted_string_command( - "s6-rc-set-new -r {0} {1}", QuoteString(repository), QuoteString(name) - ) - - elif repodir := _s6_repo_lookup(host): - existing_sets = host.get_fact(S6RepositoryList, repository=repodir) - if name not in existing_sets: - yield make_formatted_string_command( - "s6-rc-set-new -r {0} {1}", QuoteString(repodir), QuoteString(name) - ) - # fallback to compiled-in default repo, which is /var/lib/s6/repository if left unchanged at - # compile time - else: - yield make_formatted_string_command("s6-rc-set-new {0}", QuoteString(name)) + yield make_formatted_string_command( + "s6-rc-set-new -r {0} {1}", QuoteString(repository), QuoteString(name) + ) @operation(is_idempotent=False) -def set_delete(names: str | Sequence[str]): +def set_delete(the_sets: str | Sequence[str], repository: str): """Delete sets. - + names: name or list of names of the sets to delete. + + the_sets: name or list of names of the sets to delete. + + repository: path to the repository containing the sets """ - if isinstance(names, str): - names = (names,) + if isinstance(the_sets, str): + the_sets = (the_sets,) - # TODO use _make_format_fields - # s = " ".join([f"{{{i}}}" for i in range(len(names))]) yield make_formatted_string_command( - "s6 set delete " + _make_format_fields(len(names)), *map(QuoteString, names) + f"s6-rc-set-delete -r {{{len(the_sets)}}} " + _make_format_fields(len(the_sets)), + *map(QuoteString, the_sets), + QuoteString(repository), ) +# TODO make it idempotent? @operation(is_idempotent=False) -def set_copy(dest: str, source="current", force: bool = False): +def set_copy(dest: str, repository: str, source="current", force: bool = False): """Save the contents of the given set as a new set. + dest: name of the saved copy. + + repository: path to the repository containing the set. + source: name of the set to copy. + force: whether to overwrite an existing set of the same name if it exists. """ - if force: - yield make_formatted_string_command( - "s6 set copy -f {0} {1}", QuoteString(source), QuoteString(dest) - ) - - # backing up requires more thought. try to use $S6_FRONTEND_CONF, envfile, and whether the - # fact that the friendly set names are symlinks to unique names changes anything - # (e.g. `readlink /etc/s6/repo/sources/default` gives `.default:YN2tP3`). - - # + force_backup: whether to backup an existing set that would be overwritten by `force`. - # if force_backup: - # # requires knowing path of the repository. - # # regex will break if repodir key pair in /etc/s6-frontend.conf spans several lines. - # lines = host.get_fact( - # FindInFile, - # "/etc/s6-frontend.conf", - # r"repodir\s*=", - # interpolate_variables=False, - # extended_regex=True, - # ) - # if lines is None: - # raise OperationError( - # "no repodir found in /etc/s6-frontend.conf, or file doesn't exist" - # ) - # if len(lines) != 1: - # # no OperationWarning - # logger.warning( - # "multiple repodir definitions found in /etc/s6-frontend.conf, using the first one" - # ) - # if (m := _repodir_pattern.fullmatch(lines[0])) is None: - # raise OperationError("failed to match repodir line in /etc/s6-frontend.conf") - # repodir = m[1] - - # if host.get_fact(Directory, os.path.join(repodir, dest)): - # yield from _raise_or_remove_invalid_path( - # "directory", os.path.join(repodir, dest), True, True, False - # ) + yield make_formatted_string_command( + f"s6-rc-set-copy -r {{0}}{' -f' if force else ''} {{1}} {{2}}", + QuoteString(repository), + QuoteString(source), + QuoteString(dest), + ) - # # no -f since the old set has already been moved - # yield make_formatted_string_command( - # "s6 set copy {0} {1}", QuoteString(source), QuoteString(dest) - # ) - - # force_save=True, backup=False - # else: - # yield make_formatted_string_command( - # "s6 set copy -f {0} {1}", QuoteString(source), QuoteString(dest) - # ) - - # save=True, force_save=False - else: - yield make_formatted_string_command( - "s6 set copy {0} {1}", QuoteString(source), QuoteString(dest) - ) + # TODO + # backing up a set when it would have been overwritten by -f requires more thought. try to use + # $S6_CONF, envfile, and whether the fact that the friendly set names are symlinks to unique + # names changes anything (e.g. `readlink /etc/s6/repo/sources/default` gives `.default:YN2tP3`). + + # + force_backup: whether to backup an existing set that would be overwritten by `force`. + # if force_backup: + # # requires knowing path of the repository. + # # regex will break if repodir key pair in /etc/s6-frontend.conf spans several lines. + # lines = host.get_fact( + # FindInFile, + # "/etc/s6-frontend.conf", + # r"repodir\s*=", + # interpolate_variables=False, + # extended_regex=True, + # ) + # if lines is None: + # raise OperationError( + # "no repodir found in /etc/s6-frontend.conf, or file doesn't exist" + # ) + # if len(lines) != 1: + # # no OperationWarning + # logger.warning( + # "multiple repodir definitions found in /etc/s6-frontend.conf, using the first one" + # ) + # if (m := _repodir_pattern.fullmatch(lines[0])) is None: + # raise OperationError("failed to match repodir line in /etc/s6-frontend.conf") + # repodir = m[1] + + # if host.get_fact(Directory, os.path.join(repodir, dest)): + # yield from _raise_or_remove_invalid_path( + # "directory", os.path.join(repodir, dest), True, True, False + # ) + + # # no -f since the old set has already been moved + # yield make_formatted_string_command( + # "s6 set copy {0} {1}", QuoteString(source), QuoteString(dest) + # ) @operation(is_idempotent=False) -def set_commit(the_set: str = "current"): +def set_commit(repository: str, the_set: str = "current"): """Check the given set and commit it. + + repository: path to the repository containing the set. + the_set: name of the set to check and commit. """ - yield make_formatted_string_command("s6 set check -F -s {0}", QuoteString(the_set)) - yield make_formatted_string_command("s6 set commit -s {0}", QuoteString(the_set)) + yield make_formatted_string_command( + "s6-rc-set-fix -r {0} {1}", QuoteString(repository), QuoteString(the_set) + ) + yield make_formatted_string_command( + "s6-rc-set-commit -r {0} {1}", QuoteString(repository), QuoteString(the_set) + ) +# TODO switch to s6-rc-set-install, need to find livedir dynamically like repodir, add repository argument @operation(is_idempotent=False) def live_install(the_set: str = "current"): """Install a compiled (committed) service database into the live state. + + repository: path to the repository containing the set. + the_set: name of the set containing an already compiled service database to be installed into the live state. """ yield make_formatted_string_command("s6 live install -s {0}", QuoteString(the_set)) -# TODO refactor now that skarnet added -s option to many s6 frontend commands -# TODO support for repositories other than the one in s6-frontend.conf (e.g. a user repository for -# user services) @operation( is_idempotent=False, - # TODO verify - # when the_set is not "current", always executes `s6 set load [the_set]` - # force_backup idempotent? - # force_save idempotent? - idempotent_notice='Not idempotent by default. If any of the following are true, then idempotency is broken: `the_set != "current", `do_commit=True`', + # TODO verify idempotency paths + idempotent_notice="Not idempotent by default. If `do_commit=False`, then the operation is idempotent.", ) def manage_set( the_set: str = "current", @@ -355,9 +364,9 @@ def manage_set( prescriptions: dict[str, str] | None = None, force_prescriptions: bool = True, present: bool = True, - do_save: bool = False, - save_as: str | None = None, - force_save: bool = False, + # do_save: bool = False, + # save_as: str | None = None, + # force_save: bool = False, # force_backup: bool = True, do_commit: bool = True, ): @@ -368,24 +377,28 @@ def manage_set( + prescriptions: the prescriptions to ensure in the set. A map of service name -> prescription, where the prescription is any of "always", "active", "usable", "masked". May be `None`, which allows management of set presence only. + force_prescriptions: whether the `prescriptions` should be the *only* prescriptions in the set (i.e. other services will be removed) + present: whether the set should be present in the repository. - + do_save: whether to save the set to the repository. - + save_as: name for the saved set. required if `do_save` is True. - + force_save: whether to overwrite existing sets. + do_commit: whether to commit the current(ly loaded) set. delaying this step can allow for other operations to modify the current set, with the final result being committed at the end. """ + # + do_save: whether to save the set to the repository. + # + save_as: name for the saved set. required if `do_save` is True. + # + force_save: whether to overwrite existing sets. # + force_backup: whether to backup overwritten sets by appending the timestamp to the directory name. only works with `force_save`. + # automatically find the configured s6-frontend repository to use in commands + if not (s6_conf_lines := _get_s6_frontend_conf_contents(host)): + raise OperationError("failed to find an s6-frontend configuration on the remote host") + if not (repodir := _get_value_from_conf(key="repodir", content=s6_conf_lines)): + raise OperationError( + f"failed to extract a repodir from remote s6-frontend config: {s6_conf_lines}" + ) + if present: - # deleting noops from the set if they don't occur cleans up conditionals, not requiring else clauses - noops = {"create", "prescribe", "save", "commit"} + # noops = {"create", "prescribe", "save", "commit"} + noops = {"create", "prescribe", "commit"} ### set creation ### - # the repository needs to be the one recognized by s6-frontend, as the other sub-operations - # use s6-frontend commands that don't have a repository option; they implicitly use the one - # in the configuration. if the repository were able to be specified by the user here, set - # creation could occur in a different repository than the other operations. - if repodir := _s6_repo_lookup(host): + if repodir: existing_sets = host.get_fact(S6RepositoryList, repository=repodir) if the_set not in existing_sets: noops.remove("create") @@ -396,51 +409,56 @@ def manage_set( ### prescription assignment ### if prescriptions: if any( - cmds := _make_rx_commands( - prescriptions, the_set, True if force_prescriptions else False + cmds := _make_all_set_change_commands( + prescriptions, repodir, the_set=the_set, force_prescriptions=force_prescriptions ) ): noops.remove("prescribe") - # _make_rx_commands should already include -s the_set, no need to load now - # if the_set != "current": - # yield make_formatted_string_command("s6 set load {0}", QuoteString(the_set)) yield from filter(lambda cmd: cmd is not None, cmds) ### saving ### - if do_save: - if not save_as: - raise OperationValueError( - "saving a set requires a name to save it under (do_save => save_as)" - ) - # when the current set matches an existing named set exactly, noop - if not host.get_fact(S6SetStatus, save_as) == host.get_fact(S6SetStatus, the_set): - noops.remove("save") - yield from set_copy._inner( - dest=save_as, source=the_set, force=True if force_save else False - ) + # if do_save: + # if not save_as: + # raise OperationValueError( + # "saving a set requires a name to save it under (do_save => save_as)" + # ) + # # when the current set matches an existing named set exactly, noop + # if not host.get_fact(S6SetStatus, save_as) == host.get_fact(S6SetStatus, the_set): + # noops.remove("save") + # yield from set_copy._inner( + # dest=save_as, source=the_set, repository=repodir, force=True if force_save else False + # ) ### committing ### - # non-idempotent + # non-idempotent, I don't know of a way of checking whether the to-be compiled database the + # matches the existing compiled service database hash if do_commit: noops.remove("commit") - yield from set_commit._inner(the_set) + yield from set_commit._inner(repository=repodir, the_set=the_set) # "global" noop if all 4 branches noop - if noops == {"create", "prescribe", "save", "commit"}: - if prescriptions and not do_save: - host.noop('the set "current" already has the desired prescriptions') - elif do_save: - host.noop( - f'the set "current" already has the desired prescriptions and matches with the existing set "{save_as}"' - ) - else: - host.noop(f'the set "{the_set}" already exists') + # if noops == {"create", "prescribe", "save", "commit"}: + if noops == {"create", "prescribe", "commit"}: + # TODO rework noop messages + pass + # if prescriptions and not do_save: + # host.noop('the set "current" already has the desired prescriptions') + # elif do_save: + # host.noop( + # f'the set "current" already has the desired prescriptions and matches with the existing set "{save_as}"' + # ) + # else: + # host.noop(f'the set "{the_set}" already exists') ### deleting ### # present=False else: - if host.get_fact(S6SetStatus, the_set): - yield make_formatted_string_command("s6 set delete {0}", QuoteString(the_set)) + if the_set in host.get_fact(S6RepositoryList, repository=repodir): + yield make_formatted_string_command( + "s6-rc-set-delete -r {0} {1}", QuoteString(repodir), QuoteString(the_set) + ) + # if host.get_fact(S6SetStatus, the_set): + # yield make_formatted_string_command("s6 set delete {0}", QuoteString(the_set)) else: host.noop(f'the set "{the_set}" already doesn\'t exist') @@ -470,7 +488,7 @@ def service( + service: name(s) of the service(s) to manage. + running: whether the service(s) should be under an s6-supervise. + restarted: whether the service(s) should be restarted - + reloaded: whether the service(s) should be reloaded by sending a SIGHUP. Whether the service is reloaded depends on how it handles SIGHUP. + + reloaded: whether the service(s) should be reloaded by sending a signal, SIGHUP by default. Whether the service is reloaded depends on how it handles the signal. + command: custom command to run after the auto-computed commands. This must be an s6 subcommand, e.g. "system reboot" gives the command "s6 system reboot". + enabled: whether the service should be given an "active" or "usable" prescription + reload_signal: the signal to send to the service(s) when a reload is desired. @@ -487,18 +505,17 @@ def service( set operation does; all services will be assigned the same prescription. """ + if enabled_rx not in {"active", "always"}: raise ValueError('enabled_rx must be either "active" or "always"') if disabled_rx not in {"usable", "masked"}: raise ValueError('disabled_rx must be either "usable" or "masked"') - # `service` is treated as an iterable of strings; if it is a string itself (i.e. one service - # specified), undesired iteration over characters will occur. if isinstance(service, str): service = (service,) if (running, restarted, reloaded) != (None,) * 3: - # dict[str, bool] whether the services given in the services arg are running. + # dict[str, bool] whether each service given in the services arg are running. live_statuses = {srv: host.get_fact(S6LiveStatus)[srv] for srv in service} all_up = all(live_statuses.values()) some_up = any(live_statuses.values()) @@ -525,6 +542,7 @@ def service( if reloaded: if some_up: + # TODO use s6-svc instead of frontend? yield make_formatted_string_command( "s6 process kill -s {0} " + " ".join([f"{{{i + 1}}}" for i in range(len(all_up_services))]), @@ -546,9 +564,14 @@ def service( the_set=the_set, prescriptions={srv: disabled_rx for srv in service} ) - # s6.set operation already handles s6 set load if commit_set: - yield from set_commit._inner() + if not (s6_conf_lines := _get_s6_frontend_conf_contents(host)): + raise OperationError("failed to find an s6-frontend configuration on the remote host") + if not (repodir := _get_value_from_conf(key="repodir", content=s6_conf_lines)): + raise OperationError( + f"failed to extract a repodir from remote s6-frontend config: {s6_conf_lines}" + ) + yield from set_commit._inner(repository=repodir) if install_set: yield from live_install._inner() From bce80c242785f7767590fd558734dd0aaa8e8fad Mon Sep 17 00:00:00 2001 From: epicrazzmatazz Date: Wed, 26 Aug 2026 20:11:09 -0400 Subject: [PATCH 24/25] big update to tests, update to src --- src/pyinfra/facts/s6.py | 32 +- src/pyinfra/operations/s6.py | 280 ++++++++++-------- .../s6.S6LiveStatus/no_running_services.yaml | 12 +- .../s6.S6LiveStatus/no_stopped_services.yaml | 12 +- tests/facts/s6.S6LiveStatus/standard.yaml | 12 +- ...standard_repository.yaml => explicit.yaml} | 12 +- tests/facts/s6.S6RepositoryList/implicit.yaml | 15 + tests/facts/s6.S6RepositoryList/standard.yaml | 9 - tests/facts/s6.S6SetStatus/explicit_repo.yaml | 17 ++ .../{standard.yaml => implicit_repo.yaml} | 4 +- .../facts/s6.S6SetStatus/nonexistent_set.yaml | 6 +- .../nonstandard_repository_set.yaml | 2 +- .../facts/s6.S6SetStatus/nonstandard_set.yaml | 5 +- tests/operations/s6.manage_set/backup.yaml | 19 -- tests/operations/s6.manage_set/commit.yaml | 15 +- .../s6.manage_set/compiled-in_repo.yaml | 18 ++ .../s6.manage_set/create_missing.yaml | 16 + tests/operations/s6.manage_set/delete.yaml | 14 +- tests/operations/s6.manage_set/disable.yaml | 19 +- tests/operations/s6.manage_set/enable.yaml | 19 +- .../s6.manage_set/force_prescriptions.yaml | 19 +- .../s6.manage_set/lookup_repo_envvar.yaml | 19 ++ .../s6.manage_set/lookup_repo_no_envvar.yaml | 18 ++ .../s6.manage_set/multi_disable.yaml | 19 +- .../s6.manage_set/multi_enable.yaml | 19 +- .../operations/s6.manage_set/multi_mixed.yaml | 25 +- .../operations/s6.manage_set/noop_commit.yaml | 13 +- .../operations/s6.manage_set/noop_delete.yaml | 11 +- .../s6.manage_set/noop_prescribe.yaml | 18 +- .../s6.manage_set/noop_prescribe_save.yaml | 22 -- tests/operations/s6.manage_set/noop_save.yaml | 19 -- tests/operations/s6.manage_set/standard.yaml | 19 +- tests/operations/s6.service/all_at_once.yaml | 27 +- tests/operations/s6.service/disable.yaml | 15 +- .../s6.service/disabled_rx_is_masked.yaml | 15 +- tests/operations/s6.service/enable.yaml | 15 +- .../s6.service/enabled_rx_is_always.yaml | 16 +- .../operations/s6.service/multi_disable.yaml | 15 +- tests/operations/s6.service/multi_enable.yaml | 15 +- .../s6.service/multi_partial_disable.yaml | 15 +- .../s6.service/multi_partial_enable.yaml | 15 +- tests/operations/s6.set_copy/noop.yaml | 11 + tests/operations/s6.set_copy/save.yaml | 12 + tests/operations/s6.set_copy/save_force.yaml | 13 + tests/operations/s6.set_delete/delete.yaml | 3 +- .../s6.set_delete/multi_delete.yaml | 3 +- tests/operations/s6.set_save/save.yaml | 4 - tests/operations/s6.set_save/save_force.yaml | 7 - .../s6.set_save/save_force_backup.yaml | 21 -- 49 files changed, 618 insertions(+), 363 deletions(-) rename tests/facts/s6.S6RepositoryList/{nonstandard_repository.yaml => explicit.yaml} (68%) create mode 100644 tests/facts/s6.S6RepositoryList/implicit.yaml delete mode 100644 tests/facts/s6.S6RepositoryList/standard.yaml create mode 100644 tests/facts/s6.S6SetStatus/explicit_repo.yaml rename tests/facts/s6.S6SetStatus/{standard.yaml => implicit_repo.yaml} (75%) delete mode 100644 tests/operations/s6.manage_set/backup.yaml create mode 100644 tests/operations/s6.manage_set/compiled-in_repo.yaml create mode 100644 tests/operations/s6.manage_set/create_missing.yaml create mode 100644 tests/operations/s6.manage_set/lookup_repo_envvar.yaml create mode 100644 tests/operations/s6.manage_set/lookup_repo_no_envvar.yaml delete mode 100644 tests/operations/s6.manage_set/noop_prescribe_save.yaml delete mode 100644 tests/operations/s6.manage_set/noop_save.yaml create mode 100644 tests/operations/s6.set_copy/noop.yaml create mode 100644 tests/operations/s6.set_copy/save.yaml create mode 100644 tests/operations/s6.set_copy/save_force.yaml delete mode 100644 tests/operations/s6.set_save/save.yaml delete mode 100644 tests/operations/s6.set_save/save_force.yaml delete mode 100644 tests/operations/s6.set_save/save_force_backup.yaml diff --git a/src/pyinfra/facts/s6.py b/src/pyinfra/facts/s6.py index 614685d60..1405e278c 100644 --- a/src/pyinfra/facts/s6.py +++ b/src/pyinfra/facts/s6.py @@ -17,15 +17,17 @@ def requires_command(self, repository=None): @override def command(self, repository=None): """ - + repository: path of the repository to inspect, default the one in the s6-frontend configuration. + + repository: path of the repository to inspect. If `None`, the compiled-in default will be used, most likely `/var/lib/s6/repository`. """ if repository: return make_formatted_string_command("s6-rc-repo-list -r {0}", QuoteString(repository)) + return StringCommand("s6-rc-repo-list") + # if no repository passed, try to get its location from the s6-frontend configuration file - return StringCommand( - '[ ! -z "$S6_CONF" ] || S6_CONF=/etc/s6.conf && envfile "$S6_CONF" sh -c \'s6-rc-repo-list -r "$repodir"\'; echo EXIT CODE: $?' - ) + # return StringCommand( + # '[ ! -z "$S6_CONF" ] || S6_CONF=/etc/s6.conf && envfile "$S6_CONF" sh -c \'s6-rc-repo-list -r "$repodir"\'; echo EXIT CODE: $?' + # ) @override def process(self, output): @@ -52,7 +54,7 @@ def requires_command(self, the_set="current", repository=None): def command(self, the_set="current", repository=None): """ + the_set: the set to inspect. - + repository: path of the repository to inspect, default `None` which resolves the following way: the repository is read from the config file stored in the environment variable `S6_CONF`, with fallback to a hardcoded path `/etc/s6.conf`, and if that fails, the compiled-in default repository will be used, most likely `/var/lib/s6/repository`. + + repository: path of the repository to inspect, default `None`, which means to use the compiled-in default repository most likely `/var/lib/s6/repository`. """ if repository: return make_formatted_string_command( @@ -61,11 +63,14 @@ def command(self, the_set="current", repository=None): QuoteString(the_set), ) - # extra escaping needed for make_formatted_string_command, but not in StringCommand return make_formatted_string_command( - '[ ! -z \\"$S6_CONF\\" ] || S6_CONF=/etc/s6.conf && envfile \\"$S6_CONF\\" sh -c \\\'s6-rc-set-status -r \\"$repodir\\" {0}\\\'; echo EXIT CODE: $?', - QuoteString(the_set), + "s6-rc-set-status {0}; echo EXIT CODE: $?", QuoteString(the_set) ) + # extra escaping needed for make_formatted_string_command, but not in StringCommand + # return make_formatted_string_command( + # '[ ! -z \\"$S6_CONF\\" ] || S6_CONF=/etc/s6.conf && envfile \\"$S6_CONF\\" sh -c \\\'s6-rc-set-status -r \\"$repodir\\" {0}\\\'; echo EXIT CODE: $?', + # QuoteString(the_set), + # ) @override def process(self, output): @@ -81,21 +86,20 @@ def process(self, output): class S6LiveStatus(FactBase[dict[str, bool]]): - """ Returns a dict of name -> status for each service in the live state. + """Returns a dict of name -> status for each service in the live state. True when the service is "running", meaning the service is managed by an `s6-supervise`s, False otherwise. """ - # could also rewrite this using the "s6 live status" command @override def requires_command(self): return "s6-rc" - @override - def check_preconditions(self, state, host): - if not host.run_shell_command('[ ! -z "$S6_CONF" ] || [ -f /etc/s6.conf ]')[0]: - return "couldn't find s6-frontend configuration" + # @override + # def check_preconditions(self, state, host): + # if not host.run_shell_command('[ ! -z "$S6_CONF" ] || [ -f /etc/s6.conf ]')[0]: + # return "couldn't find s6-frontend configuration" @override def command(self): diff --git a/src/pyinfra/operations/s6.py b/src/pyinfra/operations/s6.py index e8de62de0..5d9d288bf 100644 --- a/src/pyinfra/operations/s6.py +++ b/src/pyinfra/operations/s6.py @@ -16,7 +16,7 @@ ) from pyinfra.api.command import make_formatted_string_command from pyinfra.facts.s6 import S6LiveStatus, S6SetStatus, S6RepositoryList -from pyinfra.facts.files import FindInFile, Directory, File, FileContents +from pyinfra.facts.files import FindInFile, Directory, File, FileContents, Sha256File from pyinfra.facts.server import Command from pyinfra.operations.files import _raise_or_remove_invalid_path @@ -37,24 +37,25 @@ def _get_s6_frontend_conf_contents(host: Host) -> list[str] | None: return contents -def _get_value_from_conf(key: str, content: list[str]): +def _get_value_from_conf(key: str, content: list[str] | None) -> str | None: """Get the value associated with a key in an s6-frontend configuration. If no repodir is found, nothing is returned. + key: the key associated with the value, e.g. "repodir". - + content: the file contents, as a list of strings. + + content: the file contents, as a list of strings. if `None`, this function returns nothing. """ # simplistic check for now, avoiding complicated syntax # does not account for a statement broken over multiple lines with backslashes # assume the key is unique - if match := re.search( - # https://skarnet.org/software/execline/envfile.html#syntax - r"^\s*" + re.escape(key) + r'\s*=\s*(/[^\s]*|"/.*")\s*$', - "\n".join(content), - re.MULTILINE, - ): - return match.group(1) + if content is not None: + if match := re.search( + # https://skarnet.org/software/execline/envfile.html#syntax + r"^\s*" + re.escape(key) + r'\s*=\s*(/[^\s]*|"/.*")\s*$', + "\n".join(content), + re.MULTILINE, + ): + return match.group(1) ## TODO edge case # for line, next_line in itertools.pairwise(content): @@ -126,7 +127,11 @@ def _make_live_command(op: str, services: Sequence): def _make_set_change_command( - services: list, current_rxs: dict, wanted_rx: str, repository: str, the_set: str = "current" + services: list, + current_rxs: dict, + wanted_rx: str, + repository: str | None = None, + the_set: str = "current", ): """Returns a command like "s6-rc-set-change -r /etc/s6/repo current active httpd". @@ -164,20 +169,30 @@ def _make_set_change_command( # example of the string passed into make_formatted_string_command: # s6-rc-set-change -r {5} {6} usable {0} {1} {2} {3} {4} + if repository: + return make_formatted_string_command( + f"s6-rc-set-change -r {{{len(service_subset)}}} {{{len(service_subset) + 1}}} {wanted_rx} " + + _make_format_fields(len(service_subset)), + *map(QuoteString, service_subset), + QuoteString(repository), + QuoteString(the_set), + ) + return make_formatted_string_command( - f"s6-rc-set-change -r {{{len(service_subset)}}} {{{len(service_subset) + 1}}} {wanted_rx} " + f"s6-rc-set-change {{{len(service_subset)}}} {wanted_rx} " + _make_format_fields(len(service_subset)), *map(QuoteString, service_subset), - QuoteString(repository), QuoteString(the_set), ) - else: - return None + return None def _make_all_set_change_commands( - prescriptions: dict, repository: str, the_set: str = "current", force_prescriptions: bool = True + prescriptions: dict, + repository: str | None = None, + the_set: str = "current", + force_prescriptions: bool = True, ): """Returns all commands necessary to bring the prescriptions to the desired state. @@ -239,21 +254,22 @@ def _make_all_set_change_commands( @operation(is_idempotent=False) -def set_create(name: str, repository: str): +def set_create(name: str, repository: str | None = None): """Create a new set. + name: name for the new set. + repository: repository to save the set in. - - This is a distinct operation from set_copy. """ - yield make_formatted_string_command( - "s6-rc-set-new -r {0} {1}", QuoteString(repository), QuoteString(name) - ) + if repository: + yield make_formatted_string_command( + "s6-rc-set-new -r {0} {1}", QuoteString(repository), QuoteString(name) + ) + else: + yield make_formatted_string_command("s6-rc-set-new -r {0}", QuoteString(name)) @operation(is_idempotent=False) -def set_delete(the_sets: str | Sequence[str], repository: str): +def set_delete(the_sets: str | Sequence[str], repository: str | None = None): """Delete sets. + the_sets: name or list of names of the sets to delete. @@ -262,16 +278,23 @@ def set_delete(the_sets: str | Sequence[str], repository: str): if isinstance(the_sets, str): the_sets = (the_sets,) - yield make_formatted_string_command( - f"s6-rc-set-delete -r {{{len(the_sets)}}} " + _make_format_fields(len(the_sets)), - *map(QuoteString, the_sets), - QuoteString(repository), - ) + if "current" in the_sets: + raise OperationValueError('cannot delete the set "current"') + + if repository: + yield make_formatted_string_command( + f"s6-rc-set-delete -r {{{len(the_sets)}}} " + _make_format_fields(len(the_sets)), + *map(QuoteString, the_sets), + QuoteString(repository), + ) + else: + yield make_formatted_string_command( + "s6-rc-set-delete " + _make_format_fields(len(the_sets)), *map(QuoteString, the_sets) + ) -# TODO make it idempotent? -@operation(is_idempotent=False) -def set_copy(dest: str, repository: str, source="current", force: bool = False): +@operation() +def set_copy(dest: str, repository: str | None = None, source="current", force: bool = False): """Save the contents of the given set as a new set. + dest: name of the saved copy. @@ -279,12 +302,24 @@ def set_copy(dest: str, repository: str, source="current", force: bool = False): + source: name of the set to copy. + force: whether to overwrite an existing set of the same name if it exists. """ - yield make_formatted_string_command( - f"s6-rc-set-copy -r {{0}}{' -f' if force else ''} {{1}} {{2}}", - QuoteString(repository), - QuoteString(source), - QuoteString(dest), - ) + source_set_contents = host.get_fact(S6SetStatus, the_set=source, repository=repository) + dest_set_contents = host.get_fact(S6SetStatus, the_set=dest, repository=repository) + if source_set_contents != dest_set_contents: + if repository: + yield make_formatted_string_command( + f"s6-rc-set-copy -r {{0}}{' -f' if force else ''} {{1}} {{2}}", + QuoteString(repository), + QuoteString(source), + QuoteString(dest), + ) + else: + yield make_formatted_string_command( + f"s6-rc-set-copy{' -f' if force else ''} {{0}} {{1}}", + QuoteString(source), + QuoteString(dest), + ) + else: + host.noop(f'the set "{dest}" exists and is identical to "{source}"') # TODO # backing up a set when it would have been overwritten by -f requires more thought. try to use @@ -327,18 +362,22 @@ def set_copy(dest: str, repository: str, source="current", force: bool = False): @operation(is_idempotent=False) -def set_commit(repository: str, the_set: str = "current"): +def set_commit(repository: str | None = None, the_set: str = "current"): """Check the given set and commit it. + repository: path to the repository containing the set. + the_set: name of the set to check and commit. """ - yield make_formatted_string_command( - "s6-rc-set-fix -r {0} {1}", QuoteString(repository), QuoteString(the_set) - ) - yield make_formatted_string_command( - "s6-rc-set-commit -r {0} {1}", QuoteString(repository), QuoteString(the_set) - ) + if repository is not None: + yield make_formatted_string_command( + "s6-rc-set-fix -r {0} {1}", QuoteString(repository), QuoteString(the_set) + ) + yield make_formatted_string_command( + "s6-rc-set-commit -r {0} {1}", QuoteString(repository), QuoteString(the_set) + ) + else: + yield make_formatted_string_command("s6-rc-set-fix {0}", QuoteString(the_set)) + yield make_formatted_string_command("s6-rc-set-commit {0}", QuoteString(the_set)) # TODO switch to s6-rc-set-install, need to find livedir dynamically like repodir, add repository argument @@ -359,54 +398,66 @@ def live_install(the_set: str = "current"): ) def manage_set( the_set: str = "current", - # no -r option exposed by s6-frontend - # repository: str | None = None, + present: bool = True, prescriptions: dict[str, str] | None = None, force_prescriptions: bool = True, - present: bool = True, - # do_save: bool = False, - # save_as: str | None = None, - # force_save: bool = False, - # force_backup: bool = True, do_commit: bool = True, ): - """ - Manage sets in a repository. + """Manage sets in a repository. + + The repository is automatically found by inspecting the `S6_CONF` environment variable or + trying the hardcoded path `/etc/s6.conf`, in line with `s6-frontend` behavior. `manage_set` + combines smaller operations into one: + - ensuring existence or non-existence, like `files.file` does + - ensuring specific prescriptions within sets, with support for ensuring only prescriptions + on only a subset of the services in a set + - committing a set, which is a stateless operation + the_set: name of the set to manage. + + present: whether the set should be present in the repository. + prescriptions: the prescriptions to ensure in the set. A map of service name -> prescription, where the prescription is any of "always", "active", "usable", "masked". May be `None`, which allows management of set presence only. + force_prescriptions: whether the `prescriptions` should be the *only* prescriptions in the set (i.e. other services will be removed) - + present: whether the set should be present in the repository. + do_commit: whether to commit the current(ly loaded) set. delaying this step can allow for other operations to modify the current set, with the final result being committed at the end. """ - # + do_save: whether to save the set to the repository. - # + save_as: name for the saved set. required if `do_save` is True. - # + force_save: whether to overwrite existing sets. - # + force_backup: whether to backup overwritten sets by appending the timestamp to the directory name. only works with `force_save`. - - # automatically find the configured s6-frontend repository to use in commands - if not (s6_conf_lines := _get_s6_frontend_conf_contents(host)): - raise OperationError("failed to find an s6-frontend configuration on the remote host") - if not (repodir := _get_value_from_conf(key="repodir", content=s6_conf_lines)): - raise OperationError( - f"failed to extract a repodir from remote s6-frontend config: {s6_conf_lines}" + + # automatically find the configured s6-frontend repository to use in commands. the value can be + # `None`, which happens when no configuration file was found. in this case, s6 commands will use + # the compiled-in default repository. + s6_conf_lines = _get_s6_frontend_conf_contents(host) + if s6_conf_lines is None: + # TODO should it really be a warning instead of info? + logger.warning( + "could not find an s6-frontend configuration on remote host, will use compiled-in default repository for s6 commands" ) + elif s6_conf_lines == []: + logger.warning( + "remote host's s6-frontend configuration is an empty file, will use compiled-in default repository for s6 commands" + ) + # repodir will be `None` if not found, but possibility of None is handled, resulting in the + # compiled-in default repository being used. + repodir = _get_value_from_conf(key="repodir", content=s6_conf_lines) + + # if not (repodir := _get_value_from_conf(key="repodir", content=s6_conf_lines)): + # raise OperationError( + # f"failed to extract a repodir from remote s6-frontend config: {s6_conf_lines}" + # ) if present: - # noops = {"create", "prescribe", "save", "commit"} + # remove noops from this set as conditions are satisfied noops = {"create", "prescribe", "commit"} - ### set creation ### - if repodir: - existing_sets = host.get_fact(S6RepositoryList, repository=repodir) - if the_set not in existing_sets: - noops.remove("create") + # create a new set if it doesn't exist + existing_sets = host.get_fact(S6RepositoryList, repository=repodir) + if the_set not in existing_sets: + noops.remove("create") + if repodir is not None: yield make_formatted_string_command( "s6-rc-set-new -r {0} {1}", QuoteString(repodir), QuoteString(the_set) ) + else: + yield make_formatted_string_command("s6-rc-set-new {0}", QuoteString(the_set)) - ### prescription assignment ### if prescriptions: if any( cmds := _make_all_set_change_commands( @@ -416,54 +467,33 @@ def manage_set( noops.remove("prescribe") yield from filter(lambda cmd: cmd is not None, cmds) - ### saving ### - # if do_save: - # if not save_as: - # raise OperationValueError( - # "saving a set requires a name to save it under (do_save => save_as)" - # ) - # # when the current set matches an existing named set exactly, noop - # if not host.get_fact(S6SetStatus, save_as) == host.get_fact(S6SetStatus, the_set): - # noops.remove("save") - # yield from set_copy._inner( - # dest=save_as, source=the_set, repository=repodir, force=True if force_save else False - # ) - - ### committing ### - # non-idempotent, I don't know of a way of checking whether the to-be compiled database the - # matches the existing compiled service database hash + # non-idempotent, I don't know of a way of checking whether the to-be compiled database + # matches the existing compiled service database if do_commit: noops.remove("commit") yield from set_commit._inner(repository=repodir, the_set=the_set) - # "global" noop if all 4 branches noop - # if noops == {"create", "prescribe", "save", "commit"}: + # "global" noop if all 3 branches noop if noops == {"create", "prescribe", "commit"}: - # TODO rework noop messages - pass - # if prescriptions and not do_save: - # host.noop('the set "current" already has the desired prescriptions') - # elif do_save: - # host.noop( - # f'the set "current" already has the desired prescriptions and matches with the existing set "{save_as}"' - # ) - # else: - # host.noop(f'the set "{the_set}" already exists') + host.noop( + f'the set "{the_set}" already exists, has the desired prescriptions, and no commit was requested' + ) - ### deleting ### # present=False else: if the_set in host.get_fact(S6RepositoryList, repository=repodir): - yield make_formatted_string_command( - "s6-rc-set-delete -r {0} {1}", QuoteString(repodir), QuoteString(the_set) - ) - # if host.get_fact(S6SetStatus, the_set): - # yield make_formatted_string_command("s6 set delete {0}", QuoteString(the_set)) + if repodir is not None: + yield make_formatted_string_command( + "s6-rc-set-delete -r {0} {1}", QuoteString(repodir), QuoteString(the_set) + ) + else: + yield make_formatted_string_command("s6-rc-set-delete {0}", QuoteString(the_set)) else: - host.noop(f'the set "{the_set}" already doesn\'t exist') + host.noop(f'the set "{the_set}" is already nonexistent') @operation( + # TODO verify idempotency paths is_idempotent=False, idempotent_notice="It is not idempotent only when at least one of `commit_set` or `install_set` are `True`.", ) @@ -472,7 +502,6 @@ def service( running: bool | None = None, restarted: bool | None = None, reloaded: bool | None = None, - # TODO command command: str | None = None, enabled: bool | None = None, reload_signal: str = "SIGHUP", @@ -487,25 +516,24 @@ def service( + service: name(s) of the service(s) to manage. + running: whether the service(s) should be under an s6-supervise. - + restarted: whether the service(s) should be restarted + + restarted: whether the service(s) should be restarted. + reloaded: whether the service(s) should be reloaded by sending a signal, SIGHUP by default. Whether the service is reloaded depends on how it handles the signal. + command: custom command to run after the auto-computed commands. This must be an s6 subcommand, e.g. "system reboot" gives the command "s6 system reboot". - + enabled: whether the service should be given an "active" or "usable" prescription + + enabled: whether the service should be given an "active" or "usable" prescription. + reload_signal: the signal to send to the service(s) when a reload is desired. - + repo: name of the repository to use when managing enabled status, using the one configured in s6-frontend.conf by default. + the_set: name of the set to use when managing enabled status, using the set named "current" by default. - + enabled_rx: name of the prescription to assign to the service(s) when enabled, which could be either "active" or "always" - + disabled_rx: name of the prescription to assign to the service(s) when disabled, which could be either "usable" or "masked" + + enabled_rx: name of the prescription to assign to the service(s) when enabled, which could be either "active" or "always". + + disabled_rx: name of the prescription to assign to the service(s) when disabled, which could be either "usable" or "masked". + commit_set: whether to commit the current(ly loaded) set. Delaying this step can allow for other operations to modify the current set, with the final result being committed at the end. + install_set: whether to install the compiled service database (the result of a commit operation) into the live state. This is analogous to systemd's daemon-reload, but not completely: systemd recognizes changes to service files after a reboot, but s6 does not. It only recognizes changes when an s6 live install command is executed. Live state replacement and enablement/disablement of services are coupled in s6. Specifying multiple services results in fewer commands executed, especially in the case of - changing the enabled status of the service, where the service database is recompiled per - command. Note that this operation does not give as granular control over prescriptions as the - set operation does; all services will be assigned the same prescription. - """ - + changing the enabled status of the service, where the service database is recompiled per command + by default. + Note that this operation does not give as granular control over prescriptions as the + `manage_set` operation does; all services will be assigned the same prescription. + """ if enabled_rx not in {"active", "always"}: raise ValueError('enabled_rx must be either "active" or "always"') if disabled_rx not in {"usable", "masked"}: @@ -566,14 +594,20 @@ def service( if commit_set: if not (s6_conf_lines := _get_s6_frontend_conf_contents(host)): - raise OperationError("failed to find an s6-frontend configuration on the remote host") - if not (repodir := _get_value_from_conf(key="repodir", content=s6_conf_lines)): raise OperationError( - f"failed to extract a repodir from remote s6-frontend config: {s6_conf_lines}" + "failed to find an s6-frontend configuration on the remote host" ) + repodir = _get_value_from_conf(key="repodir", content=s6_conf_lines) + + # if not (repodir := _get_value_from_conf(key="repodir", content=s6_conf_lines)): + # raise OperationError( + # f"failed to extract a repodir from remote s6-frontend config: {s6_conf_lines}" + # ) + yield from set_commit._inner(repository=repodir) - if install_set: - yield from live_install._inner() + + if install_set: + yield from live_install._inner() if command: yield StringCommand("s6", *map(QuoteString, shlex.split(command))) diff --git a/tests/facts/s6.S6LiveStatus/no_running_services.yaml b/tests/facts/s6.S6LiveStatus/no_running_services.yaml index c7ee5e582..8b27f25b7 100644 --- a/tests/facts/s6.S6LiveStatus/no_running_services.yaml +++ b/tests/facts/s6.S6LiveStatus/no_running_services.yaml @@ -1,10 +1,10 @@ -command: s6 live status -requires_command: s6 +command: s6-rc -c list +requires_command: s6-rc output: | - NetworkManager-srv/down - NetworkManager-log/down - avahi-daemon-log/down - avahi-daemon-srv/down + NetworkManager-srv/longrun//down/ + NetworkManager-log/longrun//down/ + avahi-daemon-log/longrun//down/ + avahi-daemon-srv/longrun//down/ fact: NetworkManager-srv: false NetworkManager-log: false diff --git a/tests/facts/s6.S6LiveStatus/no_stopped_services.yaml b/tests/facts/s6.S6LiveStatus/no_stopped_services.yaml index 6992300a0..6236ce33d 100644 --- a/tests/facts/s6.S6LiveStatus/no_stopped_services.yaml +++ b/tests/facts/s6.S6LiveStatus/no_stopped_services.yaml @@ -1,10 +1,10 @@ -command: s6 live status -requires_command: s6 +command: s6-rc -c list +requires_command: s6-rc output: | - NetworkManager-log/up - NetworkManager-srv/up - avahi-daemon-srv/up - avahi-daemon-log/up + NetworkManager-log/longrun//up/explicit + NetworkManager-srv/longrun//up/explicit + avahi-daemon-srv/longrun//up/explicit + avahi-daemon-log/longrun//up/explicit fact: NetworkManager-srv: true NetworkManager-log: true diff --git a/tests/facts/s6.S6LiveStatus/standard.yaml b/tests/facts/s6.S6LiveStatus/standard.yaml index 06ef46f71..a3fe2c630 100644 --- a/tests/facts/s6.S6LiveStatus/standard.yaml +++ b/tests/facts/s6.S6LiveStatus/standard.yaml @@ -1,10 +1,10 @@ -command: s6 live status -requires_command: s6 +command: s6-rc -c list +requires_command: s6-rc output: | - NetworkManager-log/up - NetworkManager-srv/up - avahi-daemon-srv/down - avahi-daemon-log/down + NetworkManager-log/longrun//up/explicit + NetworkManager-srv/longrun//up/explicit + avahi-daemon-srv/longrun//down/ + avahi-daemon-log/longrun//down/ fact: NetworkManager-log: true NetworkManager-srv: true diff --git a/tests/facts/s6.S6RepositoryList/nonstandard_repository.yaml b/tests/facts/s6.S6RepositoryList/explicit.yaml similarity index 68% rename from tests/facts/s6.S6RepositoryList/nonstandard_repository.yaml rename to tests/facts/s6.S6RepositoryList/explicit.yaml index 97fb73897..1a226bc1a 100644 --- a/tests/facts/s6.S6RepositoryList/nonstandard_repository.yaml +++ b/tests/facts/s6.S6RepositoryList/explicit.yaml @@ -3,9 +3,15 @@ arg: command: s6-rc-repo-list -r /etc/s6/repo requires_command: s6-rc-repo-list output: | + kiosk + current default minimal recovery - kiosk -fact: - [ default, minimal, recovery, kiosk ] +fact: [ + kiosk, + current, + default, + minimal, + recovery +] diff --git a/tests/facts/s6.S6RepositoryList/implicit.yaml b/tests/facts/s6.S6RepositoryList/implicit.yaml new file mode 100644 index 000000000..289edb7d7 --- /dev/null +++ b/tests/facts/s6.S6RepositoryList/implicit.yaml @@ -0,0 +1,15 @@ +command: s6-rc-repo-list +requires-command: s6-rc-repo-list +output: | + kiosk + current + default + minimal + recovery +fact: [ + kiosk, + current, + default, + minimal, + recovery +] diff --git a/tests/facts/s6.S6RepositoryList/standard.yaml b/tests/facts/s6.S6RepositoryList/standard.yaml deleted file mode 100644 index 82cf09dd6..000000000 --- a/tests/facts/s6.S6RepositoryList/standard.yaml +++ /dev/null @@ -1,9 +0,0 @@ -command: s6 repository list -requires_command: s6 -output: | - default - minimal - recovery - kiosk -fact: - [ default, minimal, recovery, kiosk ] diff --git a/tests/facts/s6.S6SetStatus/explicit_repo.yaml b/tests/facts/s6.S6SetStatus/explicit_repo.yaml new file mode 100644 index 000000000..6d9d5dc5e --- /dev/null +++ b/tests/facts/s6.S6SetStatus/explicit_repo.yaml @@ -0,0 +1,17 @@ +arg: + repository: "/etc/s6/repo" +command: "s6-rc-set-status -r /etc/s6/repo current; echo EXIT CODE: $?" +requires_command: s6-rc-set-status +output: | + swap/always + NetworkManager-srv/active + NetworkManager-log/active + avahi-daemon-srv/usable + avahi-daemon-log/usable + EXIT CODE: 0 +fact: + swap: always + NetworkManager-srv: active + NetworkManager-log: active + avahi-daemon-srv: usable + avahi-daemon-log: usable diff --git a/tests/facts/s6.S6SetStatus/standard.yaml b/tests/facts/s6.S6SetStatus/implicit_repo.yaml similarity index 75% rename from tests/facts/s6.S6SetStatus/standard.yaml rename to tests/facts/s6.S6SetStatus/implicit_repo.yaml index 3b7b3bd45..59e7587c3 100644 --- a/tests/facts/s6.S6SetStatus/standard.yaml +++ b/tests/facts/s6.S6SetStatus/implicit_repo.yaml @@ -1,5 +1,5 @@ -command: "s6 set status; echo EXIT CODE: $?" -requires_command: s6 +command: 's6-rc-set-status current; echo EXIT CODE: $?' +requires_command: s6-rc-set-status output: | swap/always NetworkManager-srv/active diff --git a/tests/facts/s6.S6SetStatus/nonexistent_set.yaml b/tests/facts/s6.S6SetStatus/nonexistent_set.yaml index e9a579b94..d9e7b4b73 100644 --- a/tests/facts/s6.S6SetStatus/nonexistent_set.yaml +++ b/tests/facts/s6.S6SetStatus/nonexistent_set.yaml @@ -1,5 +1,7 @@ -command: "s6 set status; echo EXIT CODE: $?" -requires_command: s6 +arg: + repository: /etc/s6/repo +command: "s6-rc-set-status -r /etc/s6/repo current; echo EXIT CODE: $?" +requires_command: s6-rc-set-status output: | EXIT CODE: 3 fact: null diff --git a/tests/facts/s6.S6SetStatus/nonstandard_repository_set.yaml b/tests/facts/s6.S6SetStatus/nonstandard_repository_set.yaml index 5997eba33..1fa7d8a22 100644 --- a/tests/facts/s6.S6SetStatus/nonstandard_repository_set.yaml +++ b/tests/facts/s6.S6SetStatus/nonstandard_repository_set.yaml @@ -1,5 +1,5 @@ arg: - set: recovery + the_set: recovery repository: /etc/s6/repo command: "s6-rc-set-status -r /etc/s6/repo recovery; echo EXIT CODE: $?" requires_command: s6-rc-set-status diff --git a/tests/facts/s6.S6SetStatus/nonstandard_set.yaml b/tests/facts/s6.S6SetStatus/nonstandard_set.yaml index c7204a2d0..2b1a852a2 100644 --- a/tests/facts/s6.S6SetStatus/nonstandard_set.yaml +++ b/tests/facts/s6.S6SetStatus/nonstandard_set.yaml @@ -1,6 +1,7 @@ arg: - set: recovery -command: "s6-rc-set-status recovery; echo EXIT CODE: $?" + repository: /etc/s6/repo + the_set: recovery +command: "s6-rc-set-status -r /etc/s6/repo recovery; echo EXIT CODE: $?" requires_command: s6-rc-set-status output: | swap/always diff --git a/tests/operations/s6.manage_set/backup.yaml b/tests/operations/s6.manage_set/backup.yaml deleted file mode 100644 index ae4734405..000000000 --- a/tests/operations/s6.manage_set/backup.yaml +++ /dev/null @@ -1,19 +0,0 @@ -args: - - default -kwargs: - prescriptions: - tipidee: "active" - force_save: true - force_backup: true -facts: - files.FindInFile: - 'extended_regex=True, interpolate_variables=False, path=/etc/s6-frontend.conf, pattern=repodir\\s*=': - - repodir=/etc/s6/repo - s6.S6SetStatus: - repository=None, set=default: - tipidee: "usable" -commands: - - s6 set load default - - s6 set enable tipidee - - s6 set check -F - - s6 set commit diff --git a/tests/operations/s6.manage_set/commit.yaml b/tests/operations/s6.manage_set/commit.yaml index 3742fbd89..a342c1fc3 100644 --- a/tests/operations/s6.manage_set/commit.yaml +++ b/tests/operations/s6.manage_set/commit.yaml @@ -1,5 +1,16 @@ +args: + - default kwargs: do_commit: true +facts: + server.Command: + 'command=printf %s "$S6_CONF"': "" + files.FileContents: + path=/etc/s6.conf: null + s6.S6RepositoryList: + repository=None: + - current + - default commands: - - s6 set check -F - - s6 set commit + - s6-rc-set-fix default + - s6-rc-set-commit default diff --git a/tests/operations/s6.manage_set/compiled-in_repo.yaml b/tests/operations/s6.manage_set/compiled-in_repo.yaml new file mode 100644 index 000000000..a549203ed --- /dev/null +++ b/tests/operations/s6.manage_set/compiled-in_repo.yaml @@ -0,0 +1,18 @@ +kwargs: + the_set: "default" + present: false + prescriptions: null + do_commit: false +facts: + server.Command: + 'command=printf %s "$S6_CONF"': "" + files.FileContents: + path=/etc/s6.conf: null + s6.S6RepositoryList: + repository=None: + - current + - default + #s6.S6SetStatus: + # repository=None, the_set=current: +commands: + - s6-rc-set-delete default diff --git a/tests/operations/s6.manage_set/create_missing.yaml b/tests/operations/s6.manage_set/create_missing.yaml new file mode 100644 index 000000000..70be02e1e --- /dev/null +++ b/tests/operations/s6.manage_set/create_missing.yaml @@ -0,0 +1,16 @@ +args: + - default +kwargs: + present: true + prescriptions: null + do_commit: false +facts: + server.Command: + 'command=printf %s "$S6_CONF"': "" + files.FileContents: + path=/etc/s6.conf: null + s6.S6RepositoryList: + repository=None: + - current +commands: + - s6-rc-set-new default diff --git a/tests/operations/s6.manage_set/delete.yaml b/tests/operations/s6.manage_set/delete.yaml index 152e1890e..805c3f8f5 100644 --- a/tests/operations/s6.manage_set/delete.yaml +++ b/tests/operations/s6.manage_set/delete.yaml @@ -3,9 +3,19 @@ args: kwargs: present: false facts: + server.Command: + 'command=printf %s "$S6_CONF"': "" + files.FileContents: + path=/etc/s6.conf: null + s6.S6RepositoryList: + repository=None: + - current + - default + - desktop + - kiosk # shows that "default" set exists s6.S6SetStatus: - repository=None, set=default: + repository=None, the_set=default: tipidee: active commands: - - s6 set delete default + - s6-rc-set-delete default diff --git a/tests/operations/s6.manage_set/disable.yaml b/tests/operations/s6.manage_set/disable.yaml index 0e4c9145c..4fd3d5ac2 100644 --- a/tests/operations/s6.manage_set/disable.yaml +++ b/tests/operations/s6.manage_set/disable.yaml @@ -4,11 +4,20 @@ kwargs: prescriptions: tipidee: "usable" facts: + server.Command: + 'command=printf %s "$S6_CONF"': "" + files.FileContents: + path=/etc/s6.conf: null + s6.S6RepositoryList: + repository=None: + - current + - default + - desktop + - kiosk s6.S6SetStatus: - repository=None, set=default: + repository=None, the_set=default: tipidee: "active" commands: - - s6 set load default - - s6 set disable tipidee - - s6 set check -F - - s6 set commit + - s6-rc-set-change default usable tipidee + - s6-rc-set-fix default + - s6-rc-set-commit default diff --git a/tests/operations/s6.manage_set/enable.yaml b/tests/operations/s6.manage_set/enable.yaml index f91423793..d859806f0 100644 --- a/tests/operations/s6.manage_set/enable.yaml +++ b/tests/operations/s6.manage_set/enable.yaml @@ -4,11 +4,20 @@ kwargs: prescriptions: tipidee: active facts: + server.Command: + 'command=printf %s "$S6_CONF"': "" + files.FileContents: + path=/etc/s6.conf: null + s6.S6RepositoryList: + repository=None: + - current + - default + - desktop + - kiosk s6.S6SetStatus: - repository=None, set=default: + repository=None, the_set=default: tipidee: usable commands: - - s6 set load default - - s6 set enable tipidee - - s6 set check -F - - s6 set commit + - s6-rc-set-change default active tipidee + - s6-rc-set-fix default + - s6-rc-set-commit default diff --git a/tests/operations/s6.manage_set/force_prescriptions.yaml b/tests/operations/s6.manage_set/force_prescriptions.yaml index e6509f424..e6799c2d7 100644 --- a/tests/operations/s6.manage_set/force_prescriptions.yaml +++ b/tests/operations/s6.manage_set/force_prescriptions.yaml @@ -5,13 +5,22 @@ kwargs: tipidee: "usable" force_prescriptions: true facts: + server.Command: + 'command=printf %s "$S6_CONF"': "" + files.FileContents: + path=/etc/s6.conf: null + s6.S6RepositoryList: + repository=None: + - current + - default + - desktop + - kiosk s6.S6SetStatus: - repository=None, set=default: + repository=None, the_set=default: nftables: "usable" mysqld: "masked" tipidee: "usable" commands: - - s6 set load default - - s6 set mask nftables - - s6 set check -F - - s6 set commit + - s6-rc-set-change default masked nftables + - s6-rc-set-fix default + - s6-rc-set-commit default diff --git a/tests/operations/s6.manage_set/lookup_repo_envvar.yaml b/tests/operations/s6.manage_set/lookup_repo_envvar.yaml new file mode 100644 index 000000000..f68197e9f --- /dev/null +++ b/tests/operations/s6.manage_set/lookup_repo_envvar.yaml @@ -0,0 +1,19 @@ +args: + - default +kwargs: + present: true + do_commit: false +facts: + server.Command: + 'command=printf %s "$S6_CONF"': + /etc/s6-stuff/repositories/bleeding-edge + files.FileContents: + path=/etc/s6-stuff/repositories/bleeding-edge: + [ "repodir = /etc/s6-stuff/repositories/bleeding-edge" ] + s6.S6RepositoryList: + repository=/etc/s6-stuff/repositories/bleeding-edge: + - current + - desktop + - kiosk +commands: + - s6-rc-set-new -r /etc/s6-stuff/repositories/bleeding-edge default diff --git a/tests/operations/s6.manage_set/lookup_repo_no_envvar.yaml b/tests/operations/s6.manage_set/lookup_repo_no_envvar.yaml new file mode 100644 index 000000000..5d7a70342 --- /dev/null +++ b/tests/operations/s6.manage_set/lookup_repo_no_envvar.yaml @@ -0,0 +1,18 @@ +args: + - default +kwargs: + present: true + do_commit: false +facts: + server.Command: + 'command=printf %s "$S6_CONF"': "" + files.FileContents: + path=/etc/s6.conf: + [ "repodir = /etc/s6-stuff/repositories/bleeding-edge" ] + s6.S6RepositoryList: + repository=/etc/s6-stuff/repositories/bleeding-edge: + - current + - desktop + - kiosk +commands: + - s6-rc-set-new -r /etc/s6-stuff/repositories/bleeding-edge default diff --git a/tests/operations/s6.manage_set/multi_disable.yaml b/tests/operations/s6.manage_set/multi_disable.yaml index f11b57c65..23a366b49 100644 --- a/tests/operations/s6.manage_set/multi_disable.yaml +++ b/tests/operations/s6.manage_set/multi_disable.yaml @@ -6,13 +6,22 @@ kwargs: mysqld: "usable" tipidee: "usable" facts: + server.Command: + 'command=printf %s "$S6_CONF"': "" + files.FileContents: + path=/etc/s6.conf: null + s6.S6RepositoryList: + repository=None: + - current + - default + - desktop + - kiosk s6.S6SetStatus: - repository=None, set=default: + repository=None, the_set=default: nftables: "active" mysqld: "active" tipidee: "active" commands: - - s6 set load default - - s6 set disable nftables mysqld tipidee - - s6 set check -F - - s6 set commit + - s6-rc-set-change default usable nftables mysqld tipidee + - s6-rc-set-fix default + - s6-rc-set-commit default diff --git a/tests/operations/s6.manage_set/multi_enable.yaml b/tests/operations/s6.manage_set/multi_enable.yaml index 051277e8b..e8e392b05 100644 --- a/tests/operations/s6.manage_set/multi_enable.yaml +++ b/tests/operations/s6.manage_set/multi_enable.yaml @@ -6,13 +6,22 @@ kwargs: mysqld: "active" tipidee: "active" facts: + server.Command: + 'command=printf %s "$S6_CONF"': "" + files.FileContents: + path=/etc/s6.conf: null + s6.S6RepositoryList: + repository=None: + - current + - default + - desktop + - kiosk s6.S6SetStatus: - repository=None, set=default: + repository=None, the_set=default: nftables: "usable" mysqld: "usable" tipidee: "usable" commands: - - s6 set load default - - s6 set enable nftables mysqld tipidee - - s6 set check -F - - s6 set commit + - s6-rc-set-change default active nftables mysqld tipidee + - s6-rc-set-fix default + - s6-rc-set-commit default diff --git a/tests/operations/s6.manage_set/multi_mixed.yaml b/tests/operations/s6.manage_set/multi_mixed.yaml index 44b85da0a..f67d90ddc 100644 --- a/tests/operations/s6.manage_set/multi_mixed.yaml +++ b/tests/operations/s6.manage_set/multi_mixed.yaml @@ -7,17 +7,26 @@ kwargs: php-fpm: "masked" tipidee: "usable" facts: + server.Command: + 'command=printf %s "$S6_CONF"': "" + files.FileContents: + path=/etc/s6.conf: null + s6.S6RepositoryList: + repository=None: + - current + - default + - desktop + - kiosk s6.S6SetStatus: - repository=None, set=default: + repository=None, the_set=default: nftables: "active" mysqld: "usable" php-fpm: "usable" tipidee: "active" commands: - - s6 set load default - - s6 set make-essential nftables - - s6 set enable mysqld - - s6 set disable tipidee - - s6 set mask php-fpm - - s6 set check -F - - s6 set commit + - s6-rc-set-change default always nftables + - s6-rc-set-change default active mysqld + - s6-rc-set-change default usable tipidee + - s6-rc-set-change default masked php-fpm + - s6-rc-set-fix default + - s6-rc-set-commit default diff --git a/tests/operations/s6.manage_set/noop_commit.yaml b/tests/operations/s6.manage_set/noop_commit.yaml index 0ecd974a4..5e18615c9 100644 --- a/tests/operations/s6.manage_set/noop_commit.yaml +++ b/tests/operations/s6.manage_set/noop_commit.yaml @@ -1,6 +1,15 @@ args: - - current + - default kwargs: do_commit: false +facts: + server.Command: + 'command=printf %s "$S6_CONF"': "" + files.FileContents: + path=/etc/s6.conf: null + s6.S6RepositoryList: + repository=None: + - current + - default commands: [] -noop_description: the set "current" already exists +noop_description: the set "default" already exists, has the desired prescriptions, and no commit was requested diff --git a/tests/operations/s6.manage_set/noop_delete.yaml b/tests/operations/s6.manage_set/noop_delete.yaml index 6009b048f..aae8be4d2 100644 --- a/tests/operations/s6.manage_set/noop_delete.yaml +++ b/tests/operations/s6.manage_set/noop_delete.yaml @@ -3,7 +3,12 @@ args: kwargs: present: false facts: - s6.S6SetStatus: - repository=None, set=default: null + server.Command: + 'command=printf %s "$S6_CONF"': "" + files.FileContents: + path=/etc/s6.conf: null + s6.S6RepositoryList: + repository=None: + - current commands: [] -noop_description: the set "default" already doesn't exist +noop_description: the set "default" is already nonexistent diff --git a/tests/operations/s6.manage_set/noop_prescribe.yaml b/tests/operations/s6.manage_set/noop_prescribe.yaml index 877ee6fc1..e2c741775 100644 --- a/tests/operations/s6.manage_set/noop_prescribe.yaml +++ b/tests/operations/s6.manage_set/noop_prescribe.yaml @@ -1,7 +1,5 @@ args: - # when declaring prescriptions, noop is only possible when working on the current set, as others - # must be loaded first by executing `s6 set load [the_set]` - - current + - default kwargs: prescriptions: nftables: "active" @@ -9,11 +7,21 @@ kwargs: tipidee: "usable" do_commit: false facts: + server.Command: + 'command=printf %s "$S6_CONF"': "" + files.FileContents: + path=/etc/s6.conf: null + s6.S6RepositoryList: + repository=None: + - current + - default + - desktop + - kiosk s6.S6SetStatus: - repository=None, set=current: + repository=None, the_set=default: nftables: "active" mysqld: "masked" tipidee: "usable" commands: [] -noop_description: the set "current" already has the desired prescriptions +noop_description: the set "default" already exists, has the desired prescriptions, and no commit was requested diff --git a/tests/operations/s6.manage_set/noop_prescribe_save.yaml b/tests/operations/s6.manage_set/noop_prescribe_save.yaml deleted file mode 100644 index a5ea97e64..000000000 --- a/tests/operations/s6.manage_set/noop_prescribe_save.yaml +++ /dev/null @@ -1,22 +0,0 @@ -args: - - current -kwargs: - prescriptions: - nftables: "active" - mysqld: "masked" - tipidee: "usable" - do_save: true - save_as: "default" - do_commit: false -facts: - s6.S6SetStatus: - repository=None, set=current: - nftables: "active" - mysqld: "masked" - tipidee: "usable" - repository=None, set=default: - nftables: "active" - mysqld: "masked" - tipidee: "usable" -commands: [] -noop_description: the set "current" already has the desired prescriptions and matches with the existing set "default" diff --git a/tests/operations/s6.manage_set/noop_save.yaml b/tests/operations/s6.manage_set/noop_save.yaml deleted file mode 100644 index 304c350ab..000000000 --- a/tests/operations/s6.manage_set/noop_save.yaml +++ /dev/null @@ -1,19 +0,0 @@ -args: - # noop only possible when working on the current set - - current -kwargs: - do_save: true - save_as: "default" - do_commit: false -facts: - s6.S6SetStatus: - repository=None, set=current: - nftables: "active" - mysqld: "masked" - tipidee: "usable" - repository=None, set=default: - nftables: "active" - mysqld: "masked" - tipidee: "usable" -commands: [] -noop_description: the set "current" already has the desired prescriptions and matches with the existing set "default" diff --git a/tests/operations/s6.manage_set/standard.yaml b/tests/operations/s6.manage_set/standard.yaml index 45e3ded9a..877038a63 100644 --- a/tests/operations/s6.manage_set/standard.yaml +++ b/tests/operations/s6.manage_set/standard.yaml @@ -4,11 +4,20 @@ kwargs: prescriptions: tipidee: "active" facts: + server.Command: + 'command=printf %s "$S6_CONF"': "" + files.FileContents: + path=/etc/s6.conf: null + s6.S6RepositoryList: + repository=None: + - current + - default + - desktop + - kiosk s6.S6SetStatus: - repository=None, set=default: + repository=None, the_set=default: tipidee: "usable" commands: - - s6 set load default - - s6 set enable tipidee - - s6 set check -F - - s6 set commit + - s6-rc-set-change default active tipidee + - s6-rc-set-fix default + - s6-rc-set-commit default diff --git a/tests/operations/s6.service/all_at_once.yaml b/tests/operations/s6.service/all_at_once.yaml index b8c10782d..c009e49d7 100644 --- a/tests/operations/s6.service/all_at_once.yaml +++ b/tests/operations/s6.service/all_at_once.yaml @@ -8,24 +8,31 @@ kwargs: the_set: webserver enabled_rx: active facts: - s6.S6LiveStatus: - nftables: true - mysqld: false - php-fpm: false - tipidee: false + server.Command: + 'command=printf %s "$S6_CONF"': "" + files.FileContents: + path=/etc/s6.conf: null + s6.S6RepositoryList: + repository=None: + - current + - webserver s6.S6SetStatus: - repository=None, set=webserver: + repository=None, the_set=webserver: nftables: always mysqld: active php-fpm: masked tipidee: usable + s6.S6LiveStatus: + nftables: true + mysqld: false + php-fpm: false + tipidee: false commands: - s6 live start mysqld php-fpm tipidee - s6 live restart nftables - s6 process kill -s SIGHUP nftables - - s6 set load webserver # enabled argument applies enabled_rx to all services, hence why nftables is here, despite having # "always" rx - - s6 set enable nftables php-fpm tipidee - - s6 set check -F - - s6 set commit + - s6-rc-set-change webserver active nftables php-fpm tipidee + - s6-rc-set-fix webserver + - s6-rc-set-commit webserver diff --git a/tests/operations/s6.service/disable.yaml b/tests/operations/s6.service/disable.yaml index 9efd01d76..0e165c47c 100644 --- a/tests/operations/s6.service/disable.yaml +++ b/tests/operations/s6.service/disable.yaml @@ -4,10 +4,17 @@ kwargs: running: null enabled: false facts: + server.Command: + 'command=printf %s "$S6_CONF"': "" + files.FileContents: + path=/etc/s6.conf: null + s6.S6RepositoryList: + repository=None: + - current s6.S6SetStatus: - repository=None, set=current: + repository=None, the_set=current: tipidee: "active" commands: - - s6 set disable tipidee - - s6 set check -F - - s6 set commit + - s6-rc-set-change current usable tipidee + - s6-rc-set-fix current + - s6-rc-set-commit current diff --git a/tests/operations/s6.service/disabled_rx_is_masked.yaml b/tests/operations/s6.service/disabled_rx_is_masked.yaml index 9dabbdde6..029032cc7 100644 --- a/tests/operations/s6.service/disabled_rx_is_masked.yaml +++ b/tests/operations/s6.service/disabled_rx_is_masked.yaml @@ -4,11 +4,18 @@ kwargs: enabled: false disabled_rx: "masked" facts: + server.Command: + 'command=printf %s "$S6_CONF"': "" + files.FileContents: + path=/etc/s6.conf: null + s6.S6RepositoryList: + repository=None: + - current s6.S6SetStatus: - repository=None, set=current: + repository=None, the_set=current: tipidee: "active" commands: - - s6 set mask tipidee - - s6 set check -F - - s6 set commit + - s6-rc-set-change current masked tipidee + - s6-rc-set-fix current + - s6-rc-set-commit current diff --git a/tests/operations/s6.service/enable.yaml b/tests/operations/s6.service/enable.yaml index 67fe0e071..58ed88fee 100644 --- a/tests/operations/s6.service/enable.yaml +++ b/tests/operations/s6.service/enable.yaml @@ -3,10 +3,17 @@ args: kwargs: enabled: true facts: + server.Command: + 'command=printf %s "$S6_CONF"': "" + files.FileContents: + path=/etc/s6.conf: null + s6.S6RepositoryList: + repository=None: + - current s6.S6SetStatus: - repository=None, set=current: + repository=None, the_set=current: tipidee: "usable" commands: - - s6 set enable tipidee - - s6 set check -F - - s6 set commit + - s6-rc-set-change current active tipidee + - s6-rc-set-fix current + - s6-rc-set-commit current diff --git a/tests/operations/s6.service/enabled_rx_is_always.yaml b/tests/operations/s6.service/enabled_rx_is_always.yaml index 66b874555..3628549f9 100644 --- a/tests/operations/s6.service/enabled_rx_is_always.yaml +++ b/tests/operations/s6.service/enabled_rx_is_always.yaml @@ -4,11 +4,17 @@ kwargs: enabled: true enabled_rx: always facts: + server.Command: + 'command=printf %s "$S6_CONF"': "" + files.FileContents: + path=/etc/s6.conf: null + s6.S6RepositoryList: + repository=None: + - current s6.S6SetStatus: - repository=None, set=current: + repository=None, the_set=current: tipidee: usable commands: - - s6 set make-essential tipidee - - s6 set check -F - - s6 set commit - + - s6-rc-set-change current always tipidee + - s6-rc-set-fix current + - s6-rc-set-commit current diff --git a/tests/operations/s6.service/multi_disable.yaml b/tests/operations/s6.service/multi_disable.yaml index dde6e3bbc..03b6996c4 100644 --- a/tests/operations/s6.service/multi_disable.yaml +++ b/tests/operations/s6.service/multi_disable.yaml @@ -4,12 +4,19 @@ kwargs: running: null enabled: false facts: + server.Command: + 'command=printf %s "$S6_CONF"': "" + files.FileContents: + path=/etc/s6.conf: null + s6.S6RepositoryList: + repository=None: + - current s6.S6SetStatus: - repository=None, set=current: + repository=None, the_set=current: nftables: "active" mysqld: "active" tipidee: "active" commands: - - s6 set disable nftables mysqld tipidee - - s6 set check -F - - s6 set commit + - s6-rc-set-change current usable nftables mysqld tipidee + - s6-rc-set-fix current + - s6-rc-set-commit current diff --git a/tests/operations/s6.service/multi_enable.yaml b/tests/operations/s6.service/multi_enable.yaml index 8234a641d..0de506ff6 100644 --- a/tests/operations/s6.service/multi_enable.yaml +++ b/tests/operations/s6.service/multi_enable.yaml @@ -4,12 +4,19 @@ kwargs: running: null enabled: true facts: + server.Command: + 'command=printf %s "$S6_CONF"': "" + files.FileContents: + path=/etc/s6.conf: null + s6.S6RepositoryList: + repository=None: + - current s6.S6SetStatus: - repository=None, set=current: + repository=None, the_set=current: nftables: "usable" mysqld: "usable" tipidee: "usable" commands: - - s6 set enable nftables mysqld tipidee - - s6 set check -F - - s6 set commit + - s6-rc-set-change current active nftables mysqld tipidee + - s6-rc-set-fix current + - s6-rc-set-commit current diff --git a/tests/operations/s6.service/multi_partial_disable.yaml b/tests/operations/s6.service/multi_partial_disable.yaml index bc6dfe23c..cfa1e63f3 100644 --- a/tests/operations/s6.service/multi_partial_disable.yaml +++ b/tests/operations/s6.service/multi_partial_disable.yaml @@ -4,12 +4,19 @@ kwargs: running: null enabled: false facts: + server.Command: + 'command=printf %s "$S6_CONF"': "" + files.FileContents: + path=/etc/s6.conf: null + s6.S6RepositoryList: + repository=None: + - current s6.S6SetStatus: - repository=None, set=current: + repository=None, the_set=current: nftables: "active" mysqld: "usable" tipidee: "active" commands: - - s6 set disable nftables tipidee - - s6 set check -F - - s6 set commit + - s6-rc-set-change current usable nftables tipidee + - s6-rc-set-fix current + - s6-rc-set-commit current diff --git a/tests/operations/s6.service/multi_partial_enable.yaml b/tests/operations/s6.service/multi_partial_enable.yaml index 5b58ee628..e38e08d55 100644 --- a/tests/operations/s6.service/multi_partial_enable.yaml +++ b/tests/operations/s6.service/multi_partial_enable.yaml @@ -4,12 +4,19 @@ kwargs: running: null enabled: true facts: + server.Command: + 'command=printf %s "$S6_CONF"': "" + files.FileContents: + path=/etc/s6.conf: null + s6.S6RepositoryList: + repository=None: + - current s6.S6SetStatus: - repository=None, set=current: + repository=None, the_set=current: nftables: "usable" mysqld: "active" tipidee: "usable" commands: - - s6 set enable nftables tipidee - - s6 set check -F - - s6 set commit + - s6-rc-set-change current active nftables tipidee + - s6-rc-set-fix current + - s6-rc-set-commit current diff --git a/tests/operations/s6.set_copy/noop.yaml b/tests/operations/s6.set_copy/noop.yaml new file mode 100644 index 000000000..cb5ff7ede --- /dev/null +++ b/tests/operations/s6.set_copy/noop.yaml @@ -0,0 +1,11 @@ +kwargs: + source: current + dest: myset +facts: + s6.S6SetStatus: + repository=None, the_set=current: + tipidee: true + repository=None, the_set=myset: + tipidee: true +commands: [] +noop_description: the set "myset" exists and is identical to "current" diff --git a/tests/operations/s6.set_copy/save.yaml b/tests/operations/s6.set_copy/save.yaml new file mode 100644 index 000000000..826e73cdf --- /dev/null +++ b/tests/operations/s6.set_copy/save.yaml @@ -0,0 +1,12 @@ +args: + # dest + - myset +kwargs: + source: current +facts: + s6.S6SetStatus: + repository=None, the_set=current: + tipidee: true + repository=None, the_set=myset: null +commands: + - s6-rc-set-copy current myset diff --git a/tests/operations/s6.set_copy/save_force.yaml b/tests/operations/s6.set_copy/save_force.yaml new file mode 100644 index 000000000..7e2d39eae --- /dev/null +++ b/tests/operations/s6.set_copy/save_force.yaml @@ -0,0 +1,13 @@ +args: + - myset +kwargs: + source: current + force: true +facts: + s6.S6SetStatus: + repository=None, the_set=current: + tipidee: true + repository=None, the_set=myset: + tipidee: false +commands: + - s6-rc-set-copy -f current myset diff --git a/tests/operations/s6.set_delete/delete.yaml b/tests/operations/s6.set_delete/delete.yaml index 141bec1fb..a424b89cc 100644 --- a/tests/operations/s6.set_delete/delete.yaml +++ b/tests/operations/s6.set_delete/delete.yaml @@ -1,4 +1,5 @@ args: - myset + - /etc/s6/repo commands: - - s6 set delete myset + - s6-rc-set-delete -r /etc/s6/repo myset diff --git a/tests/operations/s6.set_delete/multi_delete.yaml b/tests/operations/s6.set_delete/multi_delete.yaml index ca5885ae3..22ad4ad04 100644 --- a/tests/operations/s6.set_delete/multi_delete.yaml +++ b/tests/operations/s6.set_delete/multi_delete.yaml @@ -1,4 +1,5 @@ args: - [ myset_a, myset_b, myset_c ] + - /etc/s6/repo commands: - - s6 set delete myset_a myset_b myset_c + - s6-rc-set-delete -r /etc/s6/repo myset_a myset_b myset_c diff --git a/tests/operations/s6.set_save/save.yaml b/tests/operations/s6.set_save/save.yaml deleted file mode 100644 index feaaa8a8d..000000000 --- a/tests/operations/s6.set_save/save.yaml +++ /dev/null @@ -1,4 +0,0 @@ -args: - - default -commands: - - s6 set save default diff --git a/tests/operations/s6.set_save/save_force.yaml b/tests/operations/s6.set_save/save_force.yaml deleted file mode 100644 index 4f424553b..000000000 --- a/tests/operations/s6.set_save/save_force.yaml +++ /dev/null @@ -1,7 +0,0 @@ -args: - - default -kwargs: - force: true - force_backup: false -commands: - - s6 set save -f default diff --git a/tests/operations/s6.set_save/save_force_backup.yaml b/tests/operations/s6.set_save/save_force_backup.yaml deleted file mode 100644 index 658521e54..000000000 --- a/tests/operations/s6.set_save/save_force_backup.yaml +++ /dev/null @@ -1,21 +0,0 @@ -# bug on windows runner, files.Directory expects path=/etc/s6\\repo -require_platform: - - "Linux" - - "Darwin" -args: - - myset -kwargs: - force: true - force_backup: true -facts: - files.FindInFile: - "extended_regex=True, interpolate_variables=False, path=/etc/s6-frontend.conf, pattern=repodir\\s*=": - - repodir=/etc/s6/repo - files.Directory: - path=/etc/s6/repo/myset: - user: pyinfra - group: pyinfra - mode: 644 -commands: - - mv /etc/s6/repo/myset /etc/s6/repo/myset.a-timestamp - - s6 set save myset From 1d60a71ef3dc0b3a3e1ab7fd727e2cd9698fba2d Mon Sep 17 00:00:00 2001 From: epicrazzmatazz Date: Wed, 26 Aug 2026 20:14:42 -0400 Subject: [PATCH 25/25] style fixes --- src/pyinfra/facts/s6.py | 2 -- src/pyinfra/operations/s6.py | 8 +++++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/pyinfra/facts/s6.py b/src/pyinfra/facts/s6.py index 1405e278c..68dfdc098 100644 --- a/src/pyinfra/facts/s6.py +++ b/src/pyinfra/facts/s6.py @@ -2,8 +2,6 @@ from pyinfra.api import FactBase, QuoteString from pyinfra.api.command import make_formatted_string_command, StringCommand -from pyinfra.facts.server import Command -from pyinfra.facts.files import File class S6RepositoryList(FactBase[list[str]]): diff --git a/src/pyinfra/operations/s6.py b/src/pyinfra/operations/s6.py index 5d9d288bf..a13c7a517 100644 --- a/src/pyinfra/operations/s6.py +++ b/src/pyinfra/operations/s6.py @@ -1,6 +1,5 @@ """Manage s6-rc services (https://www.skarnet.org/software/s6-rc/).""" -import os import re import shlex from collections.abc import Sequence @@ -16,9 +15,8 @@ ) from pyinfra.api.command import make_formatted_string_command from pyinfra.facts.s6 import S6LiveStatus, S6SetStatus, S6RepositoryList -from pyinfra.facts.files import FindInFile, Directory, File, FileContents, Sha256File +from pyinfra.facts.files import FileContents from pyinfra.facts.server import Command -from pyinfra.operations.files import _raise_or_remove_invalid_path def _get_s6_frontend_conf_contents(host: Host) -> list[str] | None: @@ -36,6 +34,8 @@ def _get_s6_frontend_conf_contents(host: Host) -> list[str] | None: if contents := host.get_fact(FileContents, "/etc/s6.conf"): return contents + return None + def _get_value_from_conf(key: str, content: list[str] | None) -> str | None: """Get the value associated with a key in an s6-frontend configuration. @@ -57,6 +57,8 @@ def _get_value_from_conf(key: str, content: list[str] | None) -> str | None: ): return match.group(1) + return None + ## TODO edge case # for line, next_line in itertools.pairwise(content): # # ignore commented, empty and whitespace lines