This commit is contained in:
@@ -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/
|
||||
|
||||
@@ -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)
|
||||
========================================================= */
|
||||
@@ -599,4 +844,4 @@ public class BuildController {
|
||||
this.lastExitCode = lastExitCode;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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()
|
||||
));
|
||||
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<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 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<String, Object> lastRunsSnapshot() {
|
||||
Map<String, Object> 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) {}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user