nostalgia integration
All checks were successful
Build Org Backend / build (push) Successful in 12s

This commit is contained in:
2026-06-26 12:36:09 +01:00
parent 478484ce76
commit cebdf72635
3 changed files with 399 additions and 95 deletions

View File

@@ -23,6 +23,15 @@ 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
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

View File

@@ -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<Process> webProcess = new AtomicReference<>();
private final AtomicReference<Process> emacsProcess = new AtomicReference<>();
private final AtomicReference<Process> resourceProcess = new AtomicReference<>();
private final AtomicReference<Process> guacamoleStartProcess = new AtomicReference<>();
private final AtomicReference<Process> guacamoleStopProcess = new AtomicReference<>();
private final AtomicReference<Process> 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<String> freeAdventureResources() {
return startShellCommand(
BuildRunStateService.ADVENTURE_RESOURCES,
"resource cleanup",
resourceFreeDirectory,
resourceFreeLogFile,
resourceFreeCommand,
resourceProcess
);
}
@DeleteMapping("/adventure/resources/free")
public ResponseEntity<String> cancelFreeAdventureResources() {
return killProcess(
resourceProcess,
BuildRunStateService.ADVENTURE_RESOURCES,
"resource cleanup"
);
}
@GetMapping("/adventure/resources/free/status")
public ResponseEntity<BuildStatus> 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<String> startGuacamole() {
return startCommand(
BuildRunStateService.GUACAMOLE_START,
"guacamole start",
guacamoleRunDirectory,
guacamoleStartLogFile,
List.of("docker", "start", guacamoleContainerName),
guacamoleStartProcess
);
}
@DeleteMapping("/guacamole/start")
public ResponseEntity<String> cancelStartGuacamole() {
return killProcess(
guacamoleStartProcess,
BuildRunStateService.GUACAMOLE_START,
"guacamole start"
);
}
@GetMapping("/guacamole/start/status")
public ResponseEntity<BuildStatus> 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<String> stopGuacamole() {
return startCommand(
BuildRunStateService.GUACAMOLE_STOP,
"guacamole stop",
guacamoleRunDirectory,
guacamoleStopLogFile,
List.of("docker", "stop", guacamoleContainerName),
guacamoleStopProcess
);
}
@DeleteMapping("/guacamole/stop")
public ResponseEntity<String> cancelStopGuacamole() {
return killProcess(
guacamoleStopProcess,
BuildRunStateService.GUACAMOLE_STOP,
"guacamole stop"
);
}
@GetMapping("/guacamole/stop/status")
public ResponseEntity<BuildStatus> 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<String> runNostalgiaProduction() {
return startCommand(
BuildRunStateService.NOSTALGIA_PROD,
"nostalgia production",
nostalgiaRunDirectory,
nostalgiaRunLogFile,
List.of("docker", "compose", "up", "-d", "--build"),
nostalgiaProcess
);
}
@DeleteMapping("/nostalgia/production")
public ResponseEntity<String> cancelNostalgiaProduction() {
return killProcess(
nostalgiaProcess,
BuildRunStateService.NOSTALGIA_PROD,
"nostalgia production"
);
}
@GetMapping("/nostalgia/production/status")
public ResponseEntity<BuildStatus> 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<String> startShellCommand(
String commandId,
String label,
String runDir,
String logPath,
String command,
AtomicReference<Process> processRef) {
return startCommand(
commandId,
label,
runDir,
logPath,
List.of("bash", "-lc", command),
processRef
);
}
private ResponseEntity<String> startCommand(
String commandId,
String label,
String runDir,
String logPath,
List<String> command,
AtomicReference<Process> 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<String> killProcess(
AtomicReference<Process> processRef,
boolean webBuild,
@@ -375,6 +604,22 @@ public class BuildController {
return ResponseEntity.ok("Killed " + label);
}
private ResponseEntity<String> killProcess(
AtomicReference<Process> 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)
========================================================= */

View File

@@ -3,6 +3,7 @@ package org.zaine.app.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;
@@ -10,84 +11,133 @@ import org.springframework.stereotype.Service;
@Service
public class BuildRunStateService {
private final AtomicBoolean webBuildRunning = new AtomicBoolean(false);
private final AtomicBoolean emacsRunning = new AtomicBoolean(false);
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 volatile Instant emacsLastRun;
private volatile Integer emacsLastExitCode;
private volatile Instant webLastRun;
private volatile Integer webLastExitCode;
private final Map<String, CommandState> 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 webBuildRunning.compareAndSet(false, true);
return tryStart(WEB);
}
public void markWebStarted() {
webLastRun = Instant.now();
markStarted(WEB);
}
public void finishWeb(int exitCode) {
webLastRun = Instant.now();
webLastExitCode = exitCode;
webBuildRunning.set(false);
finish(WEB, exitCode);
}
public void clearWebRunning() {
webBuildRunning.set(false);
clearRunning(WEB);
}
public boolean isWebRunning() {
return webBuildRunning.get();
return status(WEB).running();
}
public boolean tryStartEmacs() {
return emacsRunning.compareAndSet(false, true);
return tryStart(EMACS);
}
public void markEmacsStarted() {
emacsLastRun = Instant.now();
markStarted(EMACS);
}
public void finishEmacs(int exitCode) {
emacsLastRun = Instant.now();
emacsLastExitCode = exitCode;
emacsRunning.set(false);
finish(EMACS, exitCode);
}
public void clearEmacsRunning() {
emacsRunning.set(false);
clearRunning(EMACS);
}
public boolean isEmacsRunning() {
return emacsRunning.get();
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<String, Object> lastRunsSnapshot() {
Map<String, Object> 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()
));
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 new BuildStatus(webBuildRunning.get(), webLastRun, webLastExitCode);
return status(WEB);
}
public BuildStatus emacsStatus() {
return new BuildStatus(emacsRunning.get(), emacsLastRun, emacsLastExitCode);
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) {}
}