From 88bdbb07fda374f79032df7b71ddbcbb99e7025e Mon Sep 17 00:00:00 2001 From: LarsvanDartel Date: Mon, 17 Aug 2026 17:03:44 +0200 Subject: [PATCH] fix(minecraft-control): a stopped server stays stopped across a deploy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stopping a world from the page held only until the next switch. The unit is wantedBy=multi-user.target, and switch-to-configuration starts every enabled unit that is not currently running, so comin quietly undid the stop on its next deploy — observed doing exactly that to smp, three minutes after it was stopped on purpose. `systemctl disable` is not the answer either: the .wants symlinks come from the generation and are rewritten on every switch. So the intent gets recorded where systemd will consult it. Stopping touches /var/lib/minecraft-control/.stopped and the unit carries ConditionPathExists=!/var/lib/minecraft-control/.stopped A failed condition is not a failure: systemd skips the unit and leaves it inactive, which is what was asked for and what the page already displays. Starting removes the file first, so the button still works. The file is written before the stop rather than after, so a crash in between leaves a running server with a stop recorded — recoverable — rather than a stopped server with nothing to keep it down. Two deliberate consequences: * it governs `systemctl start` by hand too. That is the point rather than a side effect: the file is the desired state, and the ways to change it are the page or deleting the file. * the directory is persisted, so this also outlives a reboot. "Stopped until the next reboot" is the same surprise as "stopped until the next deploy", only rarer and harder to spot. It is root-owned for the same reason the rest of this aspect is careful: the unprivileged half asks, and the root oneshot records the answer. Verified on endeavour with both worlds empty: stop, then a full switch, and smp stayed inactive while hardcore stayed up; then start, and the flag cleared and the world was back in under two seconds. Co-Authored-By: Claude Opus 5 --- .../aspects/services/minecraft-control.nix | 70 +++++++++++++++++-- 1 file changed, 65 insertions(+), 5 deletions(-) diff --git a/modules/den/aspects/services/minecraft-control.nix b/modules/den/aspects/services/minecraft-control.nix index e950061..c87fb77 100644 --- a/modules/den/aspects/services/minecraft-control.nix +++ b/modules/den/aspects/services/minecraft-control.nix @@ -96,6 +96,19 @@ flagDir = "/run/minecraft-control"; flag = server: verb: "${flagDir}/${verb}-${server}"; + # Where the *desired* state lives, which is a different thing entirely + # and therefore not on tmpfs. Present means "this server is meant to be + # down"; the unit below refuses to start while it exists. + # + # Without it, stopping a server from the page held only until the next + # deploy. The unit is wantedBy=multi-user.target, and + # switch-to-configuration starts every enabled unit that is not running — + # so a stop lasted until comin's next switch and then quietly undid + # itself. `systemctl disable` does not help either: the .wants symlinks + # are regenerated from the generation on every switch. + stateDir = "/var/lib/minecraft-control"; + stopped = server: "${stateDir}/${server}.stopped"; + script = name: text: pkgs.writeShellApplication {inherit name text;}; # Who is online, via the server's own status ping. @@ -424,12 +437,35 @@ # `stop` the server and `op` anyone. # The drop box. Only this user may create anything in it, and the only # names that mean anything are the ones a path unit below watches for. - systemd.tmpfiles.settings."10-minecraft-control".${flagDir}.d = { - inherit user; - group = user; - mode = "0700"; + systemd.tmpfiles.settings."10-minecraft-control" = { + ${flagDir}.d = { + inherit user; + group = user; + mode = "0700"; + }; + + # The desired-state directory, root-owned: the unprivileged side must + # not be able to pin a server down by writing here directly. It only + # ever asks, and the root oneshot records the answer. + ${stateDir}.d = { + user = "root"; + group = "root"; + mode = "0755"; + }; }; + # Survives the rollback, or "stopped" would mean "stopped until the + # next reboot" — which is the same surprise as "stopped until the next + # deploy", just rarer and harder to spot. + cosmos.system.impermanence.persist.directories = [ + { + directory = stateDir; + user = "root"; + group = "root"; + mode = "0755"; + } + ]; + # The privilege boundary, one pair of units per action. # # The path unit fires on the file existing and starts the service; the @@ -468,6 +504,19 @@ # already `stop` the server and `op` anyone. webhook.serviceConfig.SupplementaryGroups = ["minecraft"]; } + # What makes a stop outlast a deploy. A failed condition is not a + # failure: systemd skips the unit and reports it inactive, which is + # exactly what an operator asked for and exactly what the page then + # displays. + # + # It governs `systemctl start` by hand as well, which is the point + # rather than a side effect — the file is the desired state, and one + # way to change it is the page. The other is deleting the file. + // lib.listToAttrs (map (server: + lib.nameValuePair "minecraft-server-${server}" { + unitConfig.ConditionPathExists = "!${stopped server}"; + }) + cfg.servers) // lib.listToAttrs (map ({ server, verb, @@ -476,7 +525,18 @@ description = "${verb} the ${server} Minecraft server on request"; serviceConfig = { Type = "oneshot"; - ExecStartPre = "${pkgs.coreutils}/bin/rm -f ${flag server verb}"; + ExecStartPre = + [ + "${pkgs.coreutils}/bin/rm -f ${flag server verb}" + ] + # Record the intent before acting on it, so a crash between + # the two leaves the server running with a stop recorded + # rather than stopped with nothing to keep it that way. + ++ ( + if verb == "stop" + then ["${pkgs.coreutils}/bin/touch ${stopped server}"] + else ["${pkgs.coreutils}/bin/rm -f ${stopped server}"] + ); # The entire grant. No argument reaches this from anywhere. ExecStart = "${systemctl} ${verb} ${unit server}"; # Blocking is deliberate — systemd keeps the path unit inactive -- 2.51.2