From cebdf726351eb6a7b9949d322b9f1c9e7210e81b Mon Sep 17 00:00:00 2001 From: Zaine Date: Fri, 26 Jun 2026 12:36:09 +0100 Subject: [PATCH] nostalgia integration --- misc/org-backend.env.example | 19 +- .../app/controller/zone/BuildController.java | 247 +++++++++++++++++- .../app/service/BuildRunStateService.java | 228 +++++++++------- 3 files changed, 399 insertions(+), 95 deletions(-) diff --git a/misc/org-backend.env.example b/misc/org-backend.env.example index 9229c97..a6a30ae 100755 --- a/misc/org-backend.env.example +++ b/misc/org-backend.env.example @@ -20,11 +20,20 @@ ZONE_AUTHORING_URL=http://127.0.0.1:8765 ZONE_WATCHER_UNIT=watcher.service ZONE_SCRIPTS_LOG_DIR=/home/zaine/logs -EMACS_RUN_DIR=/home/zaine -EMACS_RUN_LOG=/home/zaine/logs/emacs.log -COMBINED_RUN_LOG=/home/zaine/logs/combined.log - -PLAY_RPG_SAVE_DIR=/home/zaine/master-folder/org-platform/org_backend/data/rpg-saves +EMACS_RUN_DIR=/home/zaine +EMACS_RUN_LOG=/home/zaine/logs/emacs.log +COMBINED_RUN_LOG=/home/zaine/logs/combined.log +ADVENTURE_RESOURCES_FREE_DIR=/home/zaine +ADVENTURE_RESOURCES_FREE_LOG=/home/zaine/logs/adventure-resources.log +ADVENTURE_RESOURCES_FREE_COMMAND="/usr/bin/docker system prune -af" +GUACAMOLE_RUN_DIR=/home/zaine +GUACAMOLE_CONTAINER_NAME=guacamole +GUACAMOLE_START_LOG=/home/zaine/logs/guacamole-start.log +GUACAMOLE_STOP_LOG=/home/zaine/logs/guacamole-stop.log +NOSTALGIA_RUN_DIR=/home/zaine/master-folder/projects/_personal/nostalgia +NOSTALGIA_RUN_LOG=/home/zaine/logs/nostalgia-prod.log + +PLAY_RPG_SAVE_DIR=/home/zaine/master-folder/org-platform/org_backend/data/rpg-saves # Calendar sync NEXTCLOUD_CALDAV_URL=https://nextcloud.zainezq.com/remote.php/dav/calendars/zaine/ diff --git a/src/main/java/org/zaine/app/controller/zone/BuildController.java b/src/main/java/org/zaine/app/controller/zone/BuildController.java index 2ca4e01..be4d40f 100755 --- a/src/main/java/org/zaine/app/controller/zone/BuildController.java +++ b/src/main/java/org/zaine/app/controller/zone/BuildController.java @@ -11,6 +11,7 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.time.Instant; +import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicReference; import java.util.regex.Matcher; @@ -63,6 +64,10 @@ public class BuildController { private final AtomicReference webProcess = new AtomicReference<>(); private final AtomicReference emacsProcess = new AtomicReference<>(); + private final AtomicReference resourceProcess = new AtomicReference<>(); + private final AtomicReference guacamoleStartProcess = new AtomicReference<>(); + private final AtomicReference guacamoleStopProcess = new AtomicReference<>(); + private final AtomicReference nostalgiaProcess = new AtomicReference<>(); @Autowired public BuildController(BuildRunStateService buildRunState) { @@ -78,6 +83,15 @@ public class BuildController { @Value("${emacs.run.dir}") private String emacsRunDirectory; @Value("${emacs.run.log}") private String emacsRunLogFile; @Value("${combined.run.log}") private String combinedRunLogFile; + @Value("${adventure.resources.free.dir:/home/zaine}") private String resourceFreeDirectory; + @Value("${adventure.resources.free.log:/home/zaine/logs/adventure-resources.log}") private String resourceFreeLogFile; + @Value("${adventure.resources.free.command:/usr/bin/docker system prune -af}") private String resourceFreeCommand; + @Value("${guacamole.run.dir:/home/zaine}") private String guacamoleRunDirectory; + @Value("${guacamole.container.name:guacamole}") private String guacamoleContainerName; + @Value("${guacamole.start.log:/home/zaine/logs/guacamole-start.log}") private String guacamoleStartLogFile; + @Value("${guacamole.stop.log:/home/zaine/logs/guacamole-stop.log}") private String guacamoleStopLogFile; + @Value("${nostalgia.run.dir:/home/zaine/master-folder/projects/_personal/nostalgia}") private String nostalgiaRunDirectory; + @Value("${nostalgia.run.log:/home/zaine/logs/nostalgia-prod.log}") private String nostalgiaRunLogFile; /* ========================================================= HEALTH + UPTIME @@ -230,6 +244,154 @@ public class BuildController { return streamLogs(emacsRunLogFile); } + /* ========================================================= + ADVENTUREOS COMMANDS + ========================================================= */ + + @Operation( + summary = "Free AdventureOS resources", + description = "Runs the configured resource cleanup command" + ) + @PostMapping("/adventure/resources/free") + public ResponseEntity freeAdventureResources() { + return startShellCommand( + BuildRunStateService.ADVENTURE_RESOURCES, + "resource cleanup", + resourceFreeDirectory, + resourceFreeLogFile, + resourceFreeCommand, + resourceProcess + ); + } + + @DeleteMapping("/adventure/resources/free") + public ResponseEntity cancelFreeAdventureResources() { + return killProcess( + resourceProcess, + BuildRunStateService.ADVENTURE_RESOURCES, + "resource cleanup" + ); + } + + @GetMapping("/adventure/resources/free/status") + public ResponseEntity getFreeAdventureResourcesStatus() { + var s = buildRunState.status(BuildRunStateService.ADVENTURE_RESOURCES); + return ResponseEntity.ok(new BuildStatus(s.running(), s.lastRun(), s.lastExitCode())); + } + + @GetMapping("/adventure/resources/free/logs") + public SseEmitter streamFreeAdventureResourcesLogs() { + return streamLogs(resourceFreeLogFile); + } + + @Operation( + summary = "Start Guacamole container", + description = "Starts the configured Apache Guacamole container" + ) + @PostMapping("/guacamole/start") + public ResponseEntity startGuacamole() { + return startCommand( + BuildRunStateService.GUACAMOLE_START, + "guacamole start", + guacamoleRunDirectory, + guacamoleStartLogFile, + List.of("docker", "start", guacamoleContainerName), + guacamoleStartProcess + ); + } + + @DeleteMapping("/guacamole/start") + public ResponseEntity cancelStartGuacamole() { + return killProcess( + guacamoleStartProcess, + BuildRunStateService.GUACAMOLE_START, + "guacamole start" + ); + } + + @GetMapping("/guacamole/start/status") + public ResponseEntity getStartGuacamoleStatus() { + var s = buildRunState.status(BuildRunStateService.GUACAMOLE_START); + return ResponseEntity.ok(new BuildStatus(s.running(), s.lastRun(), s.lastExitCode())); + } + + @GetMapping("/guacamole/start/logs") + public SseEmitter streamStartGuacamoleLogs() { + return streamLogs(guacamoleStartLogFile); + } + + @Operation( + summary = "Stop Guacamole container", + description = "Stops the configured Apache Guacamole container" + ) + @PostMapping("/guacamole/stop") + public ResponseEntity stopGuacamole() { + return startCommand( + BuildRunStateService.GUACAMOLE_STOP, + "guacamole stop", + guacamoleRunDirectory, + guacamoleStopLogFile, + List.of("docker", "stop", guacamoleContainerName), + guacamoleStopProcess + ); + } + + @DeleteMapping("/guacamole/stop") + public ResponseEntity cancelStopGuacamole() { + return killProcess( + guacamoleStopProcess, + BuildRunStateService.GUACAMOLE_STOP, + "guacamole stop" + ); + } + + @GetMapping("/guacamole/stop/status") + public ResponseEntity getStopGuacamoleStatus() { + var s = buildRunState.status(BuildRunStateService.GUACAMOLE_STOP); + return ResponseEntity.ok(new BuildStatus(s.running(), s.lastRun(), s.lastExitCode())); + } + + @GetMapping("/guacamole/stop/logs") + public SseEmitter streamStopGuacamoleLogs() { + return streamLogs(guacamoleStopLogFile); + } + + @Operation( + summary = "Run Nostalgia production container", + description = "Runs docker compose up -d --build in the Nostalgia project directory" + ) + @PostMapping("/nostalgia/production") + public ResponseEntity runNostalgiaProduction() { + return startCommand( + BuildRunStateService.NOSTALGIA_PROD, + "nostalgia production", + nostalgiaRunDirectory, + nostalgiaRunLogFile, + List.of("docker", "compose", "up", "-d", "--build"), + nostalgiaProcess + ); + } + + @DeleteMapping("/nostalgia/production") + public ResponseEntity cancelNostalgiaProduction() { + return killProcess( + nostalgiaProcess, + BuildRunStateService.NOSTALGIA_PROD, + "nostalgia production" + ); + } + + @GetMapping("/nostalgia/production/status") + public ResponseEntity getNostalgiaProductionStatus() { + var s = buildRunState.status(BuildRunStateService.NOSTALGIA_PROD); + return ResponseEntity.ok(new BuildStatus(s.running(), s.lastRun(), s.lastExitCode())); + } + + @GetMapping("/nostalgia/production/logs") + public SseEmitter streamNostalgiaProductionLogs() { + return streamLogs(nostalgiaRunLogFile); + } + /* ========================================================= LAST RUN TIMESTAMPS ========================================================= */ @@ -355,6 +517,73 @@ public class BuildController { } } + private ResponseEntity startShellCommand( + String commandId, + String label, + String runDir, + String logPath, + String command, + AtomicReference processRef) { + + return startCommand( + commandId, + label, + runDir, + logPath, + List.of("bash", "-lc", command), + processRef + ); + } + + private ResponseEntity startCommand( + String commandId, + String label, + String runDir, + String logPath, + List command, + AtomicReference processRef) { + + if (!buildRunState.tryStart(commandId)) { + return ResponseEntity.status(HttpStatus.CONFLICT).body(label + " already running"); + } + + try { + File logFile = prepareLogFile(logPath); + appendToLog(logFile, "$ " + String.join(" ", command) + "\n\n"); + + ProcessBuilder pb = new ProcessBuilder(command); + pb.directory(Path.of(runDir).toFile()); + pb.redirectErrorStream(true); + pb.redirectOutput(ProcessBuilder.Redirect.appendTo(logFile)); + + log.info("Starting {} in {}", label, runDir); + Process process = pb.start(); + processRef.set(process); + buildRunState.markStarted(commandId); + + new Thread(() -> { + try { + int exit = process.waitFor(); + log.info("{} finished with exit code {}", label, exit); + buildRunState.finish(commandId, exit); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + buildRunState.clearRunning(commandId); + } finally { + processRef.set(null); + } + }).start(); + + return ResponseEntity.ok(label + " started"); + + } catch (IOException e) { + buildRunState.clearRunning(commandId); + log.error("{} failed to start", label, e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body("Failed to start " + label + ": " + e.getMessage()); + } + } + private ResponseEntity killProcess( AtomicReference processRef, boolean webBuild, @@ -375,6 +604,22 @@ public class BuildController { return ResponseEntity.ok("Killed " + label); } + private ResponseEntity killProcess( + AtomicReference processRef, + String commandId, + String label) { + + Process p = processRef.get(); + if (p == null || !p.isAlive()) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body("No running " + label); + } + p.destroyForcibly(); + buildRunState.clearRunning(commandId); + processRef.set(null); + log.info("Killed {}", label); + return ResponseEntity.ok("Killed " + label); + } + /* ========================================================= LOG STREAMING (SSE) ========================================================= */ @@ -599,4 +844,4 @@ public class BuildController { this.lastExitCode = lastExitCode; } } -} \ No newline at end of file +} diff --git a/src/main/java/org/zaine/app/service/BuildRunStateService.java b/src/main/java/org/zaine/app/service/BuildRunStateService.java index 2211da3..8f68fbc 100755 --- a/src/main/java/org/zaine/app/service/BuildRunStateService.java +++ b/src/main/java/org/zaine/app/service/BuildRunStateService.java @@ -1,93 +1,143 @@ package org.zaine.app.service; -import java.time.Instant; -import java.util.LinkedHashMap; -import java.util.Map; -import java.util.concurrent.atomic.AtomicBoolean; - -import org.springframework.stereotype.Service; +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.springframework.stereotype.Service; @Service -public class BuildRunStateService { - - private final AtomicBoolean webBuildRunning = new AtomicBoolean(false); - private final AtomicBoolean emacsRunning = new AtomicBoolean(false); - - private volatile Instant emacsLastRun; - private volatile Integer emacsLastExitCode; - private volatile Instant webLastRun; - private volatile Integer webLastExitCode; - - public boolean tryStartWeb() { - return webBuildRunning.compareAndSet(false, true); - } - - public void markWebStarted() { - webLastRun = Instant.now(); - } - - public void finishWeb(int exitCode) { - webLastRun = Instant.now(); - webLastExitCode = exitCode; - webBuildRunning.set(false); - } - - public void clearWebRunning() { - webBuildRunning.set(false); - } - - public boolean isWebRunning() { - return webBuildRunning.get(); - } - - public boolean tryStartEmacs() { - return emacsRunning.compareAndSet(false, true); - } - - public void markEmacsStarted() { - emacsLastRun = Instant.now(); - } - - public void finishEmacs(int exitCode) { - emacsLastRun = Instant.now(); - emacsLastExitCode = exitCode; - emacsRunning.set(false); - } - - public void clearEmacsRunning() { - emacsRunning.set(false); - } - - public boolean isEmacsRunning() { - return emacsRunning.get(); - } - - public Map lastRunsSnapshot() { - Map runs = new LinkedHashMap<>(); - runs.put("org_web", Map.of( - "lastRun", nullSafe(webLastRun), - "exitCode", nullSafe(webLastExitCode), - "running", webBuildRunning.get() - )); - runs.put("emacs", Map.of( - "lastRun", nullSafe(emacsLastRun), - "exitCode", nullSafe(emacsLastExitCode), - "running", emacsRunning.get() - )); - return runs; - } - - public BuildStatus webStatus() { - return new BuildStatus(webBuildRunning.get(), webLastRun, webLastExitCode); - } - - public BuildStatus emacsStatus() { - return new BuildStatus(emacsRunning.get(), emacsLastRun, emacsLastExitCode); - } - - private static Object nullSafe(Object value) { - return value != null ? value : ""; - } - - public record BuildStatus(boolean running, Instant lastRun, Integer lastExitCode) {} -} +public class BuildRunStateService { + + public static final String WEB = "org_web"; + public static final String EMACS = "emacs"; + public static final String ADVENTURE_RESOURCES = "adventure_resources"; + public static final String GUACAMOLE_START = "guacamole_start"; + public static final String GUACAMOLE_STOP = "guacamole_stop"; + public static final String NOSTALGIA_PROD = "nostalgia_prod"; + + private final Map states = new ConcurrentHashMap<>(); + + public BuildRunStateService() { + for (String id : new String[] { + WEB, + EMACS, + ADVENTURE_RESOURCES, + GUACAMOLE_START, + GUACAMOLE_STOP, + NOSTALGIA_PROD + }) { + states.put(id, new CommandState()); + } + } + + public boolean tryStartWeb() { + return tryStart(WEB); + } + + public void markWebStarted() { + markStarted(WEB); + } + + public void finishWeb(int exitCode) { + finish(WEB, exitCode); + } + + public void clearWebRunning() { + clearRunning(WEB); + } + + public boolean isWebRunning() { + return status(WEB).running(); + } + + public boolean tryStartEmacs() { + return tryStart(EMACS); + } + + public void markEmacsStarted() { + markStarted(EMACS); + } + + public void finishEmacs(int exitCode) { + finish(EMACS, exitCode); + } + + public void clearEmacsRunning() { + clearRunning(EMACS); + } + + public boolean isEmacsRunning() { + return status(EMACS).running(); + } + + public boolean tryStart(String id) { + return state(id).running.compareAndSet(false, true); + } + + public void markStarted(String id) { + state(id).lastRun = Instant.now(); + } + + public void finish(String id, int exitCode) { + CommandState state = state(id); + state.lastRun = Instant.now(); + state.lastExitCode = exitCode; + state.running.set(false); + } + + public void clearRunning(String id) { + state(id).running.set(false); + } + + public Map lastRunsSnapshot() { + Map runs = new LinkedHashMap<>(); + for (String id : new String[] { + WEB, + EMACS, + ADVENTURE_RESOURCES, + GUACAMOLE_START, + GUACAMOLE_STOP, + NOSTALGIA_PROD + }) { + CommandState state = state(id); + runs.put(id, Map.of( + "lastRun", nullSafe(state.lastRun), + "exitCode", nullSafe(state.lastExitCode), + "running", state.running.get() + )); + } + return runs; + } + + public BuildStatus webStatus() { + return status(WEB); + } + + public BuildStatus emacsStatus() { + return status(EMACS); + } + + public BuildStatus status(String id) { + CommandState state = state(id); + return new BuildStatus(state.running.get(), state.lastRun, state.lastExitCode); + } + + private static Object nullSafe(Object value) { + return value != null ? value : ""; + } + + private CommandState state(String id) { + return states.computeIfAbsent(id, ignored -> new CommandState()); + } + + private static class CommandState { + private final AtomicBoolean running = new AtomicBoolean(false); + private volatile Instant lastRun; + private volatile Integer lastExitCode; + } + + public record BuildStatus(boolean running, Instant lastRun, Integer lastExitCode) {} +}