diff --git a/.gitignore b/.gitignore index 3582f8cf..0a4682ac 100644 --- a/.gitignore +++ b/.gitignore @@ -33,7 +33,7 @@ dependency-reduced-pom.xml # Compiled binaries slimepacks/slimepacks.so *.so -daemon/daemon +daemon/daemon* daemon/sls -protocube/protocube +protocube/protocube* diff --git a/S4J/ATTRIBUTION b/S4J/ATTRIBUTION old mode 100755 new mode 100644 diff --git a/S4J/pom.xml b/S4J/pom.xml index 6a7e13ce..5aa1af68 100644 --- a/S4J/pom.xml +++ b/S4J/pom.xml @@ -98,7 +98,7 @@ org.sonatype.central central-publishing-maven-plugin - 0.10.0 + 0.11.0 true ossrh @@ -133,7 +133,7 @@ ch.qos.logback logback-classic - 1.5.32 + 1.5.37 diff --git a/S4J/src/main/java/com/protoxon/S4J/InstallInfo.java b/S4J/src/main/java/com/protoxon/S4J/InstallInfo.java new file mode 100644 index 00000000..8e0687e6 --- /dev/null +++ b/S4J/src/main/java/com/protoxon/S4J/InstallInfo.java @@ -0,0 +1,100 @@ +package com.protoxon.S4J; + +import org.json.JSONObject; + +import java.time.Instant; + +/** + * Represents the current installation state for a server instance. + */ +public class InstallInfo { + private final InstallPhase phase; + private final String containerId; + private final String containerName; + private final String status; + private final Long exitCode; + private final Instant startedAt; + private final Instant finishedAt; + private final String failureReason; + + public InstallInfo( + InstallPhase phase, + String containerId, + String containerName, + String status, + Long exitCode, + Instant startedAt, + Instant finishedAt, + String failureReason) { + this.phase = phase; + this.containerId = containerId; + this.containerName = containerName; + this.status = status; + this.exitCode = exitCode; + this.startedAt = startedAt; + this.finishedAt = finishedAt; + this.failureReason = failureReason; + } + + public static InstallInfo fromJSON(JSONObject json) { + InstallPhase phase = InstallPhase.fromString(json.optString("phase", "unknown")); + String containerId = json.optString("container_id", null); + String containerName = json.optString("container_name", null); + String status = json.optString("status", null); + Long exitCode = json.has("exit_code") && !json.isNull("exit_code") + ? json.getLong("exit_code") + : null; + Instant startedAt = parseInstant(json.optString("started_at", null)); + Instant finishedAt = parseInstant(json.optString("finished_at", null)); + String failureReason = json.optString("failure_reason", null); + + return new InstallInfo( + phase, + containerId, + containerName, + status, + exitCode, + startedAt, + finishedAt, + failureReason); + } + + private static Instant parseInstant(String value) { + if (value == null || value.isBlank()) { + return null; + } + return Instant.parse(value); + } + + public InstallPhase getPhase() { + return phase; + } + + public String getContainerId() { + return containerId; + } + + public String getContainerName() { + return containerName; + } + + public String getStatus() { + return status; + } + + public Long getExitCode() { + return exitCode; + } + + public Instant getStartedAt() { + return startedAt; + } + + public Instant getFinishedAt() { + return finishedAt; + } + + public String getFailureReason() { + return failureReason; + } +} diff --git a/S4J/src/main/java/com/protoxon/S4J/InstallPhase.java b/S4J/src/main/java/com/protoxon/S4J/InstallPhase.java new file mode 100644 index 00000000..7664eea7 --- /dev/null +++ b/S4J/src/main/java/com/protoxon/S4J/InstallPhase.java @@ -0,0 +1,47 @@ +package com.protoxon.S4J; + +/** + * Represents the current phase of a server installation. + */ +public enum InstallPhase { + UNKNOWN("unknown"), + IDLE("idle"), + INSTALLING("installing"), + WARMING("warming"), + POST_WARMUP("post_warmup"), + INSTALL_FAILED("install_failed"), + WARMUP_FAILED("warmup_failed"), + POST_WARMUP_FAILED("post_warmup_failed"), + COMPLETED("completed"); + + private final String phase; + + InstallPhase(String phase) { + this.phase = phase; + } + + public String getPhase() { + return phase; + } + + /** + * Parses a string phase value to the corresponding enum. + * Case-insensitive matching is performed. + * + * @param phase the phase string (e.g., "installing", "completed") + * @return the corresponding InstallPhase enum, or UNKNOWN if no match is found + */ + public static InstallPhase fromString(String phase) { + if (phase == null || phase.isBlank()) { + return UNKNOWN; + } + + String normalized = phase.trim().toLowerCase(); + for (InstallPhase installPhase : values()) { + if (installPhase.getPhase().equals(normalized)) { + return installPhase; + } + } + return UNKNOWN; + } +} diff --git a/S4J/src/main/java/com/protoxon/S4J/client/entites/ClientServer.java b/S4J/src/main/java/com/protoxon/S4J/client/entites/ClientServer.java index 6abf8162..d00ca1cf 100644 --- a/S4J/src/main/java/com/protoxon/S4J/client/entites/ClientServer.java +++ b/S4J/src/main/java/com/protoxon/S4J/client/entites/ClientServer.java @@ -1,10 +1,12 @@ package com.protoxon.S4J.client.entites; +import com.protoxon.S4J.InstallInfo; import com.protoxon.S4J.PowerAction; import com.protoxon.S4J.SLSAction; import com.protoxon.S4J.ServerStats; import com.protoxon.S4J.ServerStatus; +import com.protoxon.S4J.requests.PaginationAction; import java.util.List; @@ -138,20 +140,57 @@ default SLSAction unpause() { SLSAction sendCommands(List commands); /** - * Retrieves the server logs - * @param size The number of log lines to retrieve (defaults to 100, max 100, min 1) - * @return SLSAction that returns a list of log lines + * Retrieves server logs with pagination. Page 1 contains the most recent lines. + * + * @return PaginationAction that returns a page of log lines */ - SLSAction> getLogs(int size); + PaginationAction getLogs(); /** - * Retrieves the server logs with default size of 100 - * @return SLSAction that returns a list of log lines + * Retrieves server logs with a custom page size. Page 1 contains the most recent lines. + * + * @param perPage The number of log lines per page (defaults to 50, max 100, min 1) + * @return PaginationAction that returns a page of log lines */ - default SLSAction> getLogs() { - return getLogs(100); + default PaginationAction getLogs(int perPage) { + int size = perPage <= 0 ? 50 : Math.min(perPage, 100); + return getLogs().limit(size); } + /** + * Retrieves the current installation state for this server. + * + * @return SLSAction that resolves to the install info + */ + SLSAction getInstallInfo(); + + /** + * Retrieves install logs from all install phases (install, warmup, post-warmup) with pagination. + * Page 1 contains the most recent lines. + * + * @return PaginationAction that returns a page of install log lines + */ + PaginationAction getInstallLogs(); + + /** + * Retrieves install logs with a custom page size. Page 1 contains the most recent lines. + * + * @param perPage The number of log lines per page (defaults to 50, max 100, min 1) + * @return PaginationAction that returns a page of install log lines + */ + default PaginationAction getInstallLogs(int perPage) { + int size = perPage <= 0 ? 50 : Math.min(perPage, 100); + return getInstallLogs().limit(size); + } + + /** + * Reinstalls the server software using its configured installation script. + * Every server using the same installed artifact must be stopped first. + * + * @return SLSAction that completes when the reinstall request is accepted + */ + SLSAction reinstall(); + /** * Resets the server by deleting the overlay filesystem and restarting if it was running. * This will stop the server if it's running, wait for it to fully stop, reset the overlay, diff --git a/S4J/src/main/java/com/protoxon/S4J/client/entites/impl/ClientServerImpl.java b/S4J/src/main/java/com/protoxon/S4J/client/entites/impl/ClientServerImpl.java index 394e9ff6..6b745431 100644 --- a/S4J/src/main/java/com/protoxon/S4J/client/entites/impl/ClientServerImpl.java +++ b/S4J/src/main/java/com/protoxon/S4J/client/entites/impl/ClientServerImpl.java @@ -1,5 +1,6 @@ package com.protoxon.S4J.client.entites.impl; +import com.protoxon.S4J.InstallInfo; import com.protoxon.S4J.PowerAction; import com.protoxon.S4J.SLSAction; import com.protoxon.S4J.ServerStats; @@ -7,8 +8,10 @@ import com.protoxon.S4J.client.entites.Allocation; import com.protoxon.S4J.client.entites.ClientServer; import com.protoxon.S4J.client.entities.impl.SLSClientImpl; +import com.protoxon.S4J.requests.PaginationAction; import com.protoxon.S4J.requests.Route; import com.protoxon.S4J.requests.SLSActionImpl; +import com.protoxon.S4J.requests.action.operator.impl.StringPaginationResponseImpl; import org.json.JSONArray; import org.json.JSONObject; @@ -130,34 +133,23 @@ public SLSAction sendCommands(List commands) { } @Override - public SLSAction> getLogs(int size) { - // Validate and clamp size parameter (1-100, default 100) - if (size <= 0) { - size = 100; - } else if (size > 100) { - size = 100; - } - - Route.CompiledRoute route = Route.Server.LOGS.compile(getId()) - .withQueryParams("size", String.valueOf(size)); + public PaginationAction getLogs() { + return StringPaginationResponseImpl.onPagination( + impl.getS4J(), Route.Server.LOGS.compile(getId())); + } + @Override + public SLSAction getInstallInfo() { return SLSActionImpl.onRequestExecute( impl.getS4J(), - route, - (response, request) -> { - JSONObject responseObj = response.getObject(); - JSONArray dataArray = responseObj.optJSONArray("data"); - - if (dataArray == null) { - return new ArrayList<>(); - } - - List logs = new ArrayList<>(); - for (int i = 0; i < dataArray.length(); i++) { - logs.add(dataArray.getString(i)); - } - return logs; - }); + Route.Server.INSTALL.compile(getId()), + (response, request) -> InstallInfo.fromJSON(response.getObject())); + } + + @Override + public PaginationAction getInstallLogs() { + return StringPaginationResponseImpl.onPagination( + impl.getS4J(), Route.Server.INSTALL_LOGS.compile(getId())); } @Override @@ -175,6 +167,12 @@ public SLSAction delete(boolean force) { impl.getS4J(), route); } + @Override + public SLSAction reinstall() { + return SLSActionImpl.onRequestExecute( + impl.getS4J(), Route.Server.REINSTALL.compile(getId())); + } + @Override public SLSAction reset() { return SLSActionImpl.onRequestExecute( diff --git a/S4J/src/main/java/com/protoxon/S4J/client/entities/ClientServer.java b/S4J/src/main/java/com/protoxon/S4J/client/entities/ClientServer.java index b37ff0cc..7b3c8acc 100644 --- a/S4J/src/main/java/com/protoxon/S4J/client/entities/ClientServer.java +++ b/S4J/src/main/java/com/protoxon/S4J/client/entities/ClientServer.java @@ -1,10 +1,12 @@ package com.protoxon.S4J.client.entities; +import com.protoxon.S4J.InstallInfo; import com.protoxon.S4J.PowerAction; import com.protoxon.S4J.SLSAction; import com.protoxon.S4J.ServerStats; import com.protoxon.S4J.ServerStatus; +import com.protoxon.S4J.requests.PaginationAction; import java.util.List; @@ -56,6 +58,34 @@ public interface ClientServer { */ ServerOverrides getOverrides(); + /** + * The software id this server is running (from configuration). + * + * @return the software id, or null if not set + */ + String getSoftwareId(); + + /** + * The software version this server is running (from configuration). + * + * @return the software version, or null if not set + */ + String getSoftwareVersion(); + + /** + * The container image this server is using. + * + * @return the image, or null if not set + */ + String getImage(); + + /** + * The effective resource limits for this server. + * + * @return the limits, or null if not set + */ + ServerLimits getLimits(); + SLSAction setPower(PowerAction powerAction); /** @@ -146,20 +176,57 @@ default SLSAction unpause() { SLSAction sendCommands(List commands); /** - * Retrieves the server logs - * @param size The number of log lines to retrieve (defaults to 100, max 100, min 1) - * @return SLSAction that returns a list of log lines + * Retrieves server logs with pagination. Page 1 contains the most recent lines. + * + * @return PaginationAction that returns a page of log lines */ - SLSAction> getLogs(int size); + PaginationAction getLogs(); /** - * Retrieves the server logs with default size of 100 - * @return SLSAction that returns a list of log lines + * Retrieves server logs with a custom page size. Page 1 contains the most recent lines. + * + * @param perPage The number of log lines per page (defaults to 50, max 100, min 1) + * @return PaginationAction that returns a page of log lines */ - default SLSAction> getLogs() { - return getLogs(100); + default PaginationAction getLogs(int perPage) { + int size = perPage <= 0 ? 50 : Math.min(perPage, 100); + return getLogs().limit(size); } + /** + * Retrieves the current installation state for this server. + * + * @return SLSAction that resolves to the install info + */ + SLSAction getInstallInfo(); + + /** + * Retrieves install logs from all install phases (install, warmup, post-warmup) with pagination. + * Page 1 contains the most recent lines. + * + * @return PaginationAction that returns a page of install log lines + */ + PaginationAction getInstallLogs(); + + /** + * Retrieves install logs with a custom page size. Page 1 contains the most recent lines. + * + * @param perPage The number of log lines per page (defaults to 50, max 100, min 1) + * @return PaginationAction that returns a page of install log lines + */ + default PaginationAction getInstallLogs(int perPage) { + int size = perPage <= 0 ? 50 : Math.min(perPage, 100); + return getInstallLogs().limit(size); + } + + /** + * Reinstalls the server software using its configured installation script. + * Every server using the same installed artifact must be stopped first. + * + * @return SLSAction that completes when the reinstall request is accepted + */ + SLSAction reinstall(); + /** * Resets the server by deleting the overlay filesystem and restarting if it was running. * This will stop the server if it's running, wait for it to fully stop, reset the overlay, diff --git a/S4J/src/main/java/com/protoxon/S4J/client/entities/impl/ClientServerImpl.java b/S4J/src/main/java/com/protoxon/S4J/client/entities/impl/ClientServerImpl.java index 341da496..57658413 100644 --- a/S4J/src/main/java/com/protoxon/S4J/client/entities/impl/ClientServerImpl.java +++ b/S4J/src/main/java/com/protoxon/S4J/client/entities/impl/ClientServerImpl.java @@ -1,14 +1,18 @@ package com.protoxon.S4J.client.entities.impl; +import com.protoxon.S4J.InstallInfo; import com.protoxon.S4J.PowerAction; import com.protoxon.S4J.SLSAction; import com.protoxon.S4J.ServerStats; import com.protoxon.S4J.ServerStatus; import com.protoxon.S4J.client.entities.Allocation; import com.protoxon.S4J.client.entities.ClientServer; +import com.protoxon.S4J.client.entities.ServerLimits; import com.protoxon.S4J.client.entities.ServerOverrides; +import com.protoxon.S4J.requests.PaginationAction; import com.protoxon.S4J.requests.Route; import com.protoxon.S4J.requests.SLSActionImpl; +import com.protoxon.S4J.requests.action.operator.impl.StringPaginationResponseImpl; import org.json.JSONArray; import org.json.JSONObject; @@ -21,6 +25,7 @@ public class ClientServerImpl implements ClientServer { private final SLSClientImpl impl; private final Allocation allocation; private final ServerOverrides overrides; + private final ServerLimits limits; public ClientServerImpl(JSONObject json, SLSClientImpl impl) { this.json = json; @@ -28,6 +33,7 @@ public ClientServerImpl(JSONObject json, SLSClientImpl impl) { JSONObject allocationsObj = json.optJSONObject("allocations"); this.allocation = allocationsObj != null ? new AllocationImpl(allocationsObj) : null; this.overrides = ServerOverrides.fromJson(json.optJSONObject("overrides")); + this.limits = ServerLimits.fromJson(json.optJSONObject("limits")); } @Override @@ -60,6 +66,26 @@ public ServerOverrides getOverrides() { return overrides; } + @Override + public String getSoftwareId() { + return json.optString("software_id", null); + } + + @Override + public String getSoftwareVersion() { + return json.optString("software_version", null); + } + + @Override + public String getImage() { + return json.optString("image", null); + } + + @Override + public ServerLimits getLimits() { + return limits; + } + @Override public SLSAction setPower(PowerAction powerAction) { JSONObject obj = new JSONObject().put("action", powerAction.name().toLowerCase()); @@ -137,34 +163,23 @@ public SLSAction sendCommands(List commands) { } @Override - public SLSAction> getLogs(int size) { - // Validate and clamp size parameter (1-100, default 100) - if (size <= 0) { - size = 100; - } else if (size > 100) { - size = 100; - } - - Route.CompiledRoute route = Route.Server.LOGS.compile(getId()) - .withQueryParams("size", String.valueOf(size)); + public PaginationAction getLogs() { + return StringPaginationResponseImpl.onPagination( + impl.getS4J(), Route.Server.LOGS.compile(getId())); + } + @Override + public SLSAction getInstallInfo() { return SLSActionImpl.onRequestExecute( impl.getS4J(), - route, - (response, request) -> { - JSONObject responseObj = response.getObject(); - JSONArray dataArray = responseObj.optJSONArray("data"); - - if (dataArray == null) { - return new ArrayList<>(); - } - - List logs = new ArrayList<>(); - for (int i = 0; i < dataArray.length(); i++) { - logs.add(dataArray.getString(i)); - } - return logs; - }); + Route.Server.INSTALL.compile(getId()), + (response, request) -> InstallInfo.fromJSON(response.getObject())); + } + + @Override + public PaginationAction getInstallLogs() { + return StringPaginationResponseImpl.onPagination( + impl.getS4J(), Route.Server.INSTALL_LOGS.compile(getId())); } @Override @@ -182,6 +197,12 @@ public SLSAction delete(boolean force) { impl.getS4J(), route); } + @Override + public SLSAction reinstall() { + return SLSActionImpl.onRequestExecute( + impl.getS4J(), Route.Server.REINSTALL.compile(getId())); + } + @Override public SLSAction reset() { return SLSActionImpl.onRequestExecute( diff --git a/S4J/src/main/java/com/protoxon/S4J/requests/PaginationAction.java b/S4J/src/main/java/com/protoxon/S4J/requests/PaginationAction.java index 4a90ab87..4f7d1683 100644 --- a/S4J/src/main/java/com/protoxon/S4J/requests/PaginationAction.java +++ b/S4J/src/main/java/com/protoxon/S4J/requests/PaginationAction.java @@ -69,6 +69,14 @@ public interface PaginationAction extends SLSAction>, Iterable { */ int getTotalPages(); + /** + * The total number of entities available across all pages. + *
This is updated by each retrieve action. + * + * @return The total entity count + */ + int getTotal(); + @Override PaginationAction timeout(long timeout, TimeUnit unit); diff --git a/S4J/src/main/java/com/protoxon/S4J/requests/Route.java b/S4J/src/main/java/com/protoxon/S4J/requests/Route.java index 968dffbe..b87f0778 100644 --- a/S4J/src/main/java/com/protoxon/S4J/requests/Route.java +++ b/S4J/src/main/java/com/protoxon/S4J/requests/Route.java @@ -25,6 +25,9 @@ public static class Server { public static final Route STATS = new Route(GET, "servers/{server_id}/stats"); public static final Route COMMANDS = new Route(POST, "servers/{server_id}/commands"); public static final Route LOGS = new Route(GET, "servers/{server_id}/logs"); + public static final Route INSTALL = new Route(GET, "servers/{server_id}/install"); + public static final Route INSTALL_LOGS = new Route(GET, "servers/{server_id}/install/logs"); + public static final Route REINSTALL = new Route(POST, "servers/{server_id}/reinstall"); public static final Route DELETE = new Route(Method.DELETE, "servers/{server_id}"); public static final Route RESET = new Route(POST, "servers/{server_id}/reset"); diff --git a/S4J/src/main/java/com/protoxon/S4J/requests/action/operator/impl/PaginationActionImpl.java b/S4J/src/main/java/com/protoxon/S4J/requests/action/operator/impl/PaginationActionImpl.java index 29d45566..6cbd777e 100644 --- a/S4J/src/main/java/com/protoxon/S4J/requests/action/operator/impl/PaginationActionImpl.java +++ b/S4J/src/main/java/com/protoxon/S4J/requests/action/operator/impl/PaginationActionImpl.java @@ -30,6 +30,7 @@ public abstract class PaginationActionImpl extends SLSActionImpl> imp protected volatile int iteratorIndex = 0; protected volatile int currentPage = 1; protected volatile int totalPages = 1; + protected volatile int total = 0; protected volatile T last = null; protected volatile boolean useCache = true; @@ -70,6 +71,11 @@ public int getTotalPages() { return totalPages; } + @Override + public int getTotal() { + return total; + } + @Override public PaginationAction timeout(long timeout, TimeUnit unit) { return (PaginationAction) super.timeout(timeout, unit); diff --git a/S4J/src/main/java/com/protoxon/S4J/requests/action/operator/impl/PaginationResponseImpl.java b/S4J/src/main/java/com/protoxon/S4J/requests/action/operator/impl/PaginationResponseImpl.java index c3a8d03f..bc802cc4 100644 --- a/S4J/src/main/java/com/protoxon/S4J/requests/action/operator/impl/PaginationResponseImpl.java +++ b/S4J/src/main/java/com/protoxon/S4J/requests/action/operator/impl/PaginationResponseImpl.java @@ -5,6 +5,7 @@ import com.protoxon.S4J.requests.Response; import com.protoxon.S4J.requests.Route; import com.protoxon.S4J.utils.PaginatedEntity; +import org.json.JSONArray; import org.json.JSONObject; import java.util.LinkedList; @@ -30,10 +31,15 @@ public void handleSuccess(Response response, Request> request) { JSONObject object = response.getObject(); PaginatedEntity paginatedEntity = PaginatedEntity.create(object); + total = paginatedEntity.getTotal(); totalPages = paginatedEntity.getTotalPages(); List entities = new LinkedList<>(); - for (Object o : object.getJSONArray("data")) { + JSONArray dataArray = object.optJSONArray("data"); + if (dataArray == null) { + dataArray = new JSONArray(); + } + for (Object o : dataArray) { T entity = handler.apply(new JSONObject(o.toString())); entities.add(entity); diff --git a/S4J/src/main/java/com/protoxon/S4J/requests/action/operator/impl/StringPaginationResponseImpl.java b/S4J/src/main/java/com/protoxon/S4J/requests/action/operator/impl/StringPaginationResponseImpl.java new file mode 100644 index 00000000..83a1c136 --- /dev/null +++ b/S4J/src/main/java/com/protoxon/S4J/requests/action/operator/impl/StringPaginationResponseImpl.java @@ -0,0 +1,55 @@ +package com.protoxon.S4J.requests.action.operator.impl; + +import com.protoxon.S4J.entities.S4J; +import com.protoxon.S4J.requests.Request; +import com.protoxon.S4J.requests.Response; +import com.protoxon.S4J.requests.Route; +import com.protoxon.S4J.utils.PaginatedEntity; +import org.json.JSONArray; +import org.json.JSONObject; + +import java.util.LinkedList; +import java.util.List; + +public class StringPaginationResponseImpl extends PaginationActionImpl { + + private StringPaginationResponseImpl(S4J api, Route.CompiledRoute route) { + super(api, route); + limit.set(50); + } + + public static StringPaginationResponseImpl onPagination(S4J api, Route.CompiledRoute route) { + return new StringPaginationResponseImpl(api, route); + } + + @Override + public void handleSuccess(Response response, Request> request) { + JSONObject object = response.getObject(); + + PaginatedEntity paginatedEntity = PaginatedEntity.create(object); + total = paginatedEntity.getTotal(); + totalPages = paginatedEntity.getTotalPages(); + + List lines = new LinkedList<>(); + JSONArray dataArray = object.optJSONArray("data"); + if (dataArray == null) { + dataArray = new JSONArray(); + } + for (int i = 0; i < dataArray.length(); i++) { + String line = dataArray.getString(i); + lines.add(line); + + if (useCache) cached.add(line); + + last = line; + } + + PAGINATION_LOG.trace("Successfully retrieved {} log lines", lines.size()); + + if (useCache) + PAGINATION_LOG.debug("Cache enabled: caching {} log lines, cache size: {}", lines.size(), cached.size()); + + currentPage = getCurrentPage() + 1; + request.onSuccess(lines); + } +} diff --git a/daemon/ATTRIBUTION b/daemon/ATTRIBUTION old mode 100755 new mode 100644 diff --git a/daemon/LICENSE b/daemon/LICENSE old mode 100755 new mode 100644 diff --git a/daemon/api/router/middleware/middleware.go b/daemon/api/router/middleware/middleware.go old mode 100755 new mode 100644 diff --git a/daemon/api/router/middleware/request_error.go b/daemon/api/router/middleware/request_error.go old mode 100755 new mode 100644 diff --git a/daemon/api/router/router.go b/daemon/api/router/router.go old mode 100755 new mode 100644 index 9b3a3f16..9e60b342 --- a/daemon/api/router/router.go +++ b/daemon/api/router/router.go @@ -38,8 +38,9 @@ func (r *Router) Configure() *gin.Engine { server.GET("/stats", getServerStats) server.POST("/commands", postServerCommands) server.POST("/reset", postServerReset) - //server.POST("/install", postServerInstall) - //server.POST("/reinstall", postServerReinstall) + server.GET("/install/logs", getServerInstallLogs) + server.GET("/install", getServerInstallInfo) + server.POST("/reinstall", postServerReinstall) //server.POST("/sync", postServerSync) //server.POST("/ws/deny", postServerDenyWSTokens) } diff --git a/daemon/api/router/router_server.go b/daemon/api/router/router_server.go index b527842c..dcb53f73 100644 --- a/daemon/api/router/router_server.go +++ b/daemon/api/router/router_server.go @@ -94,6 +94,60 @@ func getServerStats(c *gin.Context) { c.JSON(http.StatusOK, s.Proc()) } +func getServerInstallInfo(c *gin.Context) { + s := middleware.ExtractServer(c) + c.JSON(http.StatusOK, s.InstallInfo(c.Request.Context())) +} + +func getServerInstallLogs(c *gin.Context) { + s := middleware.ExtractServer(c) + + page, perPage := logPaginationParams(c) + + out, err := s.InstallLogsAll(c.Request.Context()) + if err != nil { + middleware.CaptureAndAbort(c, err) + return + } + + stripped := stripLogLines(out) + paged, totalPages := paginateLogLines(stripped, page, perPage) + + c.JSON(http.StatusOK, gin.H{ + "meta": gin.H{ + "pagination": gin.H{ + "total": len(stripped), + "per_page": perPage, + "current_page": page, + "total_pages": totalPages, + }, + }, + "data": paged, + }) +} + +func postServerReinstall(c *gin.Context) { + s := middleware.ExtractServer(c) + + if err := s.ValidateReinstall(); err != nil { + if errors.Is(err, server.ErrInstalledServerArtifactInUse) { + httperror.AbortWithJSON(c, http.StatusConflict, err.Error(), + "Stop all servers using this installed artifact before reinstalling.") + return + } + middleware.CaptureAndAbort(c, err) + return + } + + go func(s *server.Server) { + if err := s.Reinstall(); err != nil { + s.Log().WithField("error", err).Error("failed to reinstall server") + } + }(s) + + c.Status(http.StatusAccepted) +} + // Sends an array of commands to a running server instance. func postServerCommands(c *gin.Context) { s := middleware.ExtractServer(c) @@ -159,20 +213,33 @@ var stripAnsiRegex = regexp.MustCompile("[\u001B\u009B][[\\]()#;?]*(?:(?:(?:[a-z func getServerLogs(c *gin.Context) { s := middleware.ExtractServer(c) - l, _ := strconv.Atoi(c.DefaultQuery("size", "100")) - if l <= 0 { - l = 100 - } else if l > 100 { - l = 100 - } + page, perPage := logPaginationParams(c) - out, err := s.ReadLogfile(l) + var out []string + var err error + out, err = s.ReadLogfile(c.Request.Context(), 0) if err != nil { middleware.CaptureAndAbort(c, err) return } - // Strip ANSI formatting codes and other formatting from each log line + stripped := stripLogLines(out) + paged, totalPages := paginateLogLines(stripped, page, perPage) + + c.JSON(http.StatusOK, gin.H{ + "meta": gin.H{ + "pagination": gin.H{ + "total": len(stripped), + "per_page": perPage, + "current_page": page, + "total_pages": totalPages, + }, + }, + "data": paged, + }) +} + +func stripLogLines(out []string) []string { stripped := make([]string, len(out)) for i, line := range out { // Strip ANSI escape codes @@ -206,6 +273,46 @@ func getServerLogs(c *gin.Context) { stripped[i] = cleaned } + return stripped +} + +func paginateLogLines(lines []string, page, perPage int) ([]string, int) { + total := len(lines) + totalPages := 0 + if total > 0 { + totalPages = (total + perPage - 1) / perPage + } + + end := total - (page-1)*perPage + if end <= 0 { + return []string{}, totalPages + } - c.JSON(http.StatusOK, gin.H{"data": stripped}) + start := end - perPage + if start < 0 { + start = 0 + } + + return lines[start:end], totalPages } + +func logPaginationParams(c *gin.Context) (page, perPage int) { + page, _ = strconv.Atoi(c.DefaultQuery("page", "1")) + if page < 1 { + page = 1 + } + + perPageRaw := c.Query("per_page") + if perPageRaw == "" { + perPageRaw = c.DefaultQuery("size", c.DefaultQuery("lines", "100")) + } + perPage, _ = strconv.Atoi(perPageRaw) + if perPage <= 0 { + perPage = 100 + } + if perPage > 100 { + perPage = 100 + } + return page, perPage +} + diff --git a/daemon/api/router/router_system.go b/daemon/api/router/router_system.go index bfd02591..448f44af 100644 --- a/daemon/api/router/router_system.go +++ b/daemon/api/router/router_system.go @@ -17,7 +17,7 @@ import ( func (r *Router) postCreateServer(c *gin.Context) { // Parse incoming JSON body - var req models.ServerConfigurationResponse + var req models.ServerConfiguration if err := c.ShouldBindJSON(&req); err != nil { httperror.JSON(c, http.StatusInternalServerError, err.Error(), "Invalid request body.") return diff --git a/daemon/cmd/command.go b/daemon/cmd/command.go old mode 100755 new mode 100644 diff --git a/daemon/cmd/root.go b/daemon/cmd/root.go old mode 100755 new mode 100644 diff --git a/daemon/config/config.go b/daemon/config/config.go old mode 100755 new mode 100644 diff --git a/daemon/config/config_docker.go b/daemon/config/config_docker.go old mode 100755 new mode 100644 diff --git a/daemon/config/config_test.go b/daemon/config/config_test.go old mode 100755 new mode 100644 diff --git a/daemon/docker-compose.yml b/daemon/docker-compose.yml index ac8c4ea8..acbdad58 100644 --- a/daemon/docker-compose.yml +++ b/daemon/docker-compose.yml @@ -1,6 +1,11 @@ services: daemon: image: ghcr.io/jessefaler/sls/daemon:latest + build: + context: . + dockerfile: Dockerfile + args: + VERSION: v0.1.2 # Overlay mounts (used for server filesystems) need one of: # - userns_mode: host (when Docker uses userns-remap, container root cannot mount) # - apparmor=unconfined (Docker's default AppArmor profile can deny mount) @@ -28,7 +33,7 @@ services: - "/var/run/docker.sock:/var/run/docker.sock" - "/var/lib/docker/containers/:/var/lib/docker/containers/" - "/etc/sls/daemon/:/etc/sls/daemon/" - - "/var/log/sls/daemon/:/var/log/sls/daemon" + - "/var/log/sls/daemon/:/var/log/sls" - "/tmp/sls/daemon/:/tmp/sls/daemon" - "/etc/ssl/certs:/etc/ssl/certs:ro" @@ -50,4 +55,4 @@ networks: config: - subnet: "172.30.0.0/16" driver_opts: - com.docker.network.bridge.name: sls \ No newline at end of file + com.docker.network.bridge.name: sls diff --git a/daemon/environment/allocations.go b/daemon/environment/allocations.go old mode 100755 new mode 100644 diff --git a/daemon/environment/config.go b/daemon/environment/config.go old mode 100755 new mode 100644 diff --git a/daemon/environment/docker.go b/daemon/environment/docker.go old mode 100755 new mode 100644 diff --git a/daemon/environment/docker/api.go b/daemon/environment/docker/api.go old mode 100755 new mode 100644 diff --git a/daemon/environment/docker/container.go b/daemon/environment/docker/container.go old mode 100755 new mode 100644 index 0c2b4277..40cb7b12 --- a/daemon/environment/docker/container.go +++ b/daemon/environment/docker/container.go @@ -315,11 +315,14 @@ func (e *Environment) SendCommand(c string) error { // is running or not, it will simply try to read the last X bytes of the file // and return them. func (e *Environment) Readlog(lines int) ([]string, error) { - r, err := e.client.ContainerLogs(context.Background(), e.Id, container.LogsOptions{ + opts := container.LogsOptions{ ShowStdout: true, ShowStderr: true, - Tail: strconv.Itoa(lines), - }) + } + if lines > 0 { + opts.Tail = strconv.Itoa(lines) + } + r, err := e.client.ContainerLogs(context.Background(), e.Id, opts) if err != nil { return nil, errors.WithStack(err) } diff --git a/daemon/environment/docker/environment.go b/daemon/environment/docker/environment.go old mode 100755 new mode 100644 diff --git a/daemon/environment/docker/power.go b/daemon/environment/docker/power.go old mode 100755 new mode 100644 diff --git a/daemon/environment/docker/stats.go b/daemon/environment/docker/stats.go old mode 100755 new mode 100644 diff --git a/daemon/environment/environment.go b/daemon/environment/environment.go old mode 100755 new mode 100644 diff --git a/daemon/environment/settings.go b/daemon/environment/settings.go old mode 100755 new mode 100644 index f650e499..cdc2dae5 --- a/daemon/environment/settings.go +++ b/daemon/environment/settings.go @@ -106,11 +106,14 @@ func (l Limits) AsContainerResources() container.Resources { Memory: l.BoundedMemoryLimit(), MemoryReservation: l.MemoryLimit * 1024 * 1024, MemorySwap: l.ConvertedSwap(), - BlkioWeight: l.IoWeight, OomKillDisable: &l.OOMDisabled, PidsLimit: &pids, } + if l.IoWeight > 0 { + resources.BlkioWeight = l.IoWeight + } + // If the CPU Limit is not set, don't send any of these fields through. Providing // them seems to break some Java services that try to read the available processors. if l.CpuLimit > 0 { diff --git a/daemon/environment/stats.go b/daemon/environment/stats.go old mode 100755 new mode 100644 diff --git a/daemon/events/events.go b/daemon/events/events.go old mode 100755 new mode 100644 diff --git a/daemon/events/events_test.go b/daemon/events/events_test.go old mode 100755 new mode 100644 diff --git a/daemon/go.mod b/daemon/go.mod index 4aa7c175..e5d54c25 100644 --- a/daemon/go.mod +++ b/daemon/go.mod @@ -25,18 +25,18 @@ require ( github.com/google/uuid v1.6.0 github.com/iancoleman/strcase v0.3.0 github.com/icza/dyno v0.0.0-20230330125955-09f820a8d9c0 - github.com/klauspost/compress v1.18.6 + github.com/klauspost/compress v1.19.0 github.com/klauspost/pgzip v1.2.6 github.com/magiconair/properties v1.8.10 - github.com/mattn/go-colorable v0.1.14 + github.com/mattn/go-colorable v0.1.15 github.com/mholt/archives v0.1.5 github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db github.com/robfig/cron/v3 v3.0.1 github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 github.com/spf13/cobra v1.10.2 - golang.org/x/sync v0.20.0 - golang.org/x/sys v0.45.0 - gopkg.in/ini.v1 v1.67.2 + golang.org/x/sync v0.21.0 + golang.org/x/sys v0.46.0 + gopkg.in/ini.v1 v1.67.3 gopkg.in/yaml.v3 v3.0.1 gorm.io/gorm v1.31.1 ) diff --git a/daemon/go.sum b/daemon/go.sum index 97d7f244..17e62b19 100644 --- a/daemon/go.sum +++ b/daemon/go.sum @@ -199,8 +199,8 @@ github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1 github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= -github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= -github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ= +github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= @@ -221,8 +221,8 @@ github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8S github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ= github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= -github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= -github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= +github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= @@ -440,8 +440,8 @@ golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -463,8 +463,8 @@ golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -559,8 +559,8 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= -gopkg.in/ini.v1 v1.67.2 h1:JtOSMb9OuaCZKr7h5D/h6iii14sK0hLbplTc6frx4Ss= -gopkg.in/ini.v1 v1.67.2/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss= +gopkg.in/ini.v1 v1.67.3 h1:iM9Lhz5MRSGhHVGGwCuzG9KO8PoirCXj/m/qTmOJJQw= +gopkg.in/ini.v1 v1.67.3/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/daemon/internal/message/banner.go b/daemon/internal/message/banner.go old mode 100755 new mode 100644 diff --git a/daemon/internal/message/banner_test.go b/daemon/internal/message/banner_test.go old mode 100755 new mode 100644 diff --git a/daemon/internal/progress/progress.go b/daemon/internal/progress/progress.go old mode 100755 new mode 100644 diff --git a/daemon/internal/progress/progress_test.go b/daemon/internal/progress/progress_test.go old mode 100755 new mode 100644 diff --git a/daemon/internal/ufs/LICENSE b/daemon/internal/ufs/LICENSE old mode 100755 new mode 100644 diff --git a/daemon/internal/ufs/README.md b/daemon/internal/ufs/README.md old mode 100755 new mode 100644 diff --git a/daemon/internal/ufs/error.go b/daemon/internal/ufs/error.go old mode 100755 new mode 100644 diff --git a/daemon/internal/ufs/file.go b/daemon/internal/ufs/file.go old mode 100755 new mode 100644 diff --git a/daemon/internal/ufs/file_posix.go b/daemon/internal/ufs/file_posix.go old mode 100755 new mode 100644 diff --git a/daemon/internal/ufs/filesystem.go b/daemon/internal/ufs/filesystem.go old mode 100755 new mode 100644 diff --git a/daemon/internal/ufs/fs_quota.go b/daemon/internal/ufs/fs_quota.go old mode 100755 new mode 100644 diff --git a/daemon/internal/ufs/fs_unix.go b/daemon/internal/ufs/fs_unix.go old mode 100755 new mode 100644 diff --git a/daemon/internal/ufs/fs_unix_test.go b/daemon/internal/ufs/fs_unix_test.go old mode 100755 new mode 100644 diff --git a/daemon/internal/ufs/go.LICENSE b/daemon/internal/ufs/go.LICENSE old mode 100755 new mode 100644 diff --git a/daemon/internal/ufs/mkdir_unix.go b/daemon/internal/ufs/mkdir_unix.go old mode 100755 new mode 100644 diff --git a/daemon/internal/ufs/path_unix.go b/daemon/internal/ufs/path_unix.go old mode 100755 new mode 100644 diff --git a/daemon/internal/ufs/quota_writer.go b/daemon/internal/ufs/quota_writer.go old mode 100755 new mode 100644 diff --git a/daemon/internal/ufs/removeall_unix.go b/daemon/internal/ufs/removeall_unix.go old mode 100755 new mode 100644 diff --git a/daemon/internal/ufs/stat_unix.go b/daemon/internal/ufs/stat_unix.go old mode 100755 new mode 100644 diff --git a/daemon/internal/ufs/walk.go b/daemon/internal/ufs/walk.go old mode 100755 new mode 100644 diff --git a/daemon/internal/ufs/walk_unix.go b/daemon/internal/ufs/walk_unix.go old mode 100755 new mode 100644 diff --git a/daemon/log/cli/cli.go b/daemon/log/cli/cli.go old mode 100755 new mode 100644 diff --git a/daemon/log/log.go b/daemon/log/log.go old mode 100755 new mode 100644 diff --git a/daemon/models/server.go b/daemon/models/server.go index 5b312ad2..ec533cad 100644 --- a/daemon/models/server.go +++ b/daemon/models/server.go @@ -4,7 +4,7 @@ import ( "protoxon.com/sls/daemon/environment" ) -// ServerConfigurationResponse holds the server configuration data returned from +// ServerConfiguration holds the server configuration data returned from // Protocube. When a server process is started, The daemon communicates with Protocube // to fetch the latest build information. // @@ -13,7 +13,7 @@ import ( // means if a configuration is accidentally wiped on the daemon we can self-recover // without too much hassle, so long as the daemon is aware of what servers should // exist on it. -type ServerConfigurationResponse struct { +type ServerConfiguration struct { Id string `json:"id"` ProcessConfiguration *ProcessConfiguration `json:"process-configuration"` Image string `json:"image"` @@ -29,7 +29,7 @@ type ServerConfigurationResponse struct { SkipInstallScript bool `json:"skip-install-script"` } -// Server data state configuration +// State holds the server's state configuration type State struct { Volumes []Volume `yaml:"volumes,omitempty" json:"volumes,omitempty"` Mounts []environment.Mount `yaml:"mounts,omitempty" json:"mounts,omitempty"` @@ -42,9 +42,7 @@ type Copy struct { Target string `yaml:"target" json:"target"` } -// Volumes are managed storage units. -// They must exist within the configured volumes directory -// (e.g. /sls/volumes). +// Volume holds mounting information for SLS managed volumes type Volume struct { Name string `yaml:"name" json:"name"` Source string `yaml:"source" json:"source"` diff --git a/daemon/parser/helpers.go b/daemon/parser/helpers.go old mode 100755 new mode 100644 diff --git a/daemon/parser/parser.go b/daemon/parser/parser.go old mode 100755 new mode 100644 diff --git a/daemon/remote/client.go b/daemon/remote/client.go index 5807857e..349b3e98 100644 --- a/daemon/remote/client.go +++ b/daemon/remote/client.go @@ -17,8 +17,8 @@ type Client interface { StatusUpdate(ctx context.Context, status string, id string) error // Sends a server status update CrashReport(ctx context.Context, data CrashData, id string) error // Sends a server crash report ServerDeleted(ctx context.Context, id string) error // Sends a server deleted event - GetServers(context context.Context, perPage int) ([]models.ServerConfigurationResponse, error) - GetServerConfiguration(ctx context.Context, uuid string) (models.ServerConfigurationResponse, error) + GetServers(context context.Context, perPage int) ([]models.ServerConfiguration, error) + GetServerConfiguration(ctx context.Context, uuid string) (models.ServerConfiguration, error) GetInstallationScript(ctx context.Context, serverId string) (InstallationScript, error) SetInstallationStatus(ctx context.Context, uuid string, data InstallStatusRequest) error SetOnConnected(callback func(context.Context)) diff --git a/daemon/remote/errors.go b/daemon/remote/errors.go old mode 100755 new mode 100644 diff --git a/daemon/remote/requests.go b/daemon/remote/requests.go old mode 100755 new mode 100644 diff --git a/daemon/remote/servers.go b/daemon/remote/servers.go old mode 100755 new mode 100644 index 92c34952..b2300bfe --- a/daemon/remote/servers.go +++ b/daemon/remote/servers.go @@ -33,7 +33,7 @@ func (c *client) ServerDeleted(ctx context.Context, id string) error { // GetServers returns all the servers that are present on the Protocube that are part of this node making // parallel API calls to the endpoint if more than one page of servers is // returned. -func (c *client) GetServers(ctx context.Context, limit int) ([]models.ServerConfigurationResponse, error) { +func (c *client) GetServers(ctx context.Context, limit int) ([]models.ServerConfiguration, error) { servers, meta, err := c.getServersPaged(ctx, 0, limit) if err != nil { return nil, err @@ -65,10 +65,10 @@ func (c *client) GetServers(ctx context.Context, limit int) ([]models.ServerConf // getServersPaged returns a subset of servers from the Protocube API using the // pagination query parameters. -func (c *client) getServersPaged(ctx context.Context, page, limit int) ([]models.ServerConfigurationResponse, Pagination, error) { +func (c *client) getServersPaged(ctx context.Context, page, limit int) ([]models.ServerConfiguration, Pagination, error) { type r struct { - Data []models.ServerConfigurationResponse `json:"data"` - Meta Pagination `json:"meta"` + Data []models.ServerConfiguration `json:"data"` + Meta Pagination `json:"meta"` } res, err := Get[r](c, ctx, "/internal/servers", q{ "page": strconv.Itoa(page), @@ -80,9 +80,9 @@ func (c *client) getServersPaged(ctx context.Context, page, limit int) ([]models return res.Data, res.Meta, nil } -func (c *client) GetServerConfiguration(ctx context.Context, uuid string) (models.ServerConfigurationResponse, error) { - var config models.ServerConfigurationResponse - res, err := Get[models.ServerConfigurationResponse](c, ctx, fmt.Sprintf("/internal/servers/%s", uuid), nil) +func (c *client) GetServerConfiguration(ctx context.Context, uuid string) (models.ServerConfiguration, error) { + var config models.ServerConfiguration + res, err := Get[models.ServerConfiguration](c, ctx, fmt.Sprintf("/internal/servers/%s", uuid), nil) if err != nil { return config, err } diff --git a/daemon/remote/types.go b/daemon/remote/types.go old mode 100755 new mode 100644 index 809d8438..0d778751 --- a/daemon/remote/types.go +++ b/daemon/remote/types.go @@ -54,11 +54,17 @@ type InstallStatusRequest struct { // process. This is used when a server version is installed for the first time, and when // a server version is marked for re-installation. type InstallationScript struct { - ContainerImage string `json:"container_image"` - Entrypoint string `json:"entrypoint"` - Script string `json:"script"` - SkipScripts bool `json:"skip_scripts"` - InstallLimits environment.Limits `json:"limits"` + ContainerImage string `json:"container_image"` + Entrypoint string `json:"entrypoint"` + Script string `json:"script"` + SkipScripts bool `json:"skip_scripts"` + InstallLimits environment.Limits `json:"limits"` + Warmup bool `json:"warmup"` + WarmupTimeout int `json:"warmup_timeout"` + WarmupRetries int `json:"warmup_retries"` + PostWarmupScript string `json:"post_warmup_script"` + PostWarmupTimeout int `json:"post_warmup_timeout"` + WarmupFailurePolicy string `json:"warmup_failure_policy"` } type HeartBeat struct { diff --git a/daemon/run.sh b/daemon/run.sh old mode 100755 new mode 100644 diff --git a/daemon/server/config_parser.go b/daemon/server/config_parser.go old mode 100755 new mode 100644 diff --git a/daemon/server/configuration.go b/daemon/server/configuration.go old mode 100755 new mode 100644 diff --git a/daemon/server/console.go b/daemon/server/console.go old mode 100755 new mode 100644 diff --git a/daemon/server/crash.go b/daemon/server/crash.go old mode 100755 new mode 100644 diff --git a/daemon/server/errors.go b/daemon/server/errors.go old mode 100755 new mode 100644 index 2ab3a758..ffe8e519 --- a/daemon/server/errors.go +++ b/daemon/server/errors.go @@ -5,10 +5,11 @@ import ( ) var ( - ErrIsRunning = errors.New("server is running") - ErrIsPaused = errors.New("server is paused") - ErrSuspended = errors.New("server is currently in a suspended state") - ErrInvalidServerConfig = errors.Sentinel("invalid server configuration") + ErrIsRunning = errors.New("server is running") + ErrIsPaused = errors.New("server is paused") + ErrSuspended = errors.New("server is currently in a suspended state") + ErrInvalidServerConfig = errors.Sentinel("invalid server configuration") + ErrInstalledServerArtifactInUse = errors.New("servers are currently using the installed server artifact") ) type crashTooFrequent struct{} diff --git a/daemon/server/events.go b/daemon/server/events.go old mode 100755 new mode 100644 diff --git a/daemon/server/install.go b/daemon/server/install.go index 9044bc23..d7fef155 100644 --- a/daemon/server/install.go +++ b/daemon/server/install.go @@ -37,19 +37,40 @@ func (s *Server) Install(ctx context.Context) error { } func (s *Server) install(ctx context.Context, reinstall bool) error { + if s.installLock == nil { + s.installLock = system.NewLocker() + } + if err := s.installLock.Acquire(); err != nil { + return errors.Wrap(err, "install: failed to acquire exclusive install lifecycle lock") + } + defer s.installLock.Release() + var err error if !s.Config().SkipInstallScripts { + installerName := s.ID() + "_installer" + s.StartInstallPhase(InstallPhaseInstalling, installerName, installLogDiskPath(s.installLogBasePath(), "")) + // Send the start event so protocube can automatically update. s.Events().Publish(InstallStartedEvent, "") err = s.internalInstall(ctx) } else { s.Log().Info("server configured to skip running installation scripts for this software, not executing process") + s.FinishInstallPhase(InstallPhaseCompleted, "") } // Notify protocube of install state. On failure, do this in the background so we return // (and the caller can log the error) immediately instead of blocking on a slow/timeout HTTP call. successful := err == nil + if successful { + s.FinishInstallPhase(InstallPhaseCompleted, "") + } else { + switch s.installPhase() { + case InstallPhaseWarmupFailed, InstallPhasePostWarmupFailed: + default: + s.FinishInstallPhase(InstallPhaseInstallFailed, err.Error()) + } + } s.Log().WithField("was_successful", successful).Debug("notifying protocube of server install state") notifyProtocube := func() { if serr := s.SyncInstallState(successful, reinstall); serr != nil { @@ -73,15 +94,21 @@ func (s *Server) install(ctx context.Context, reinstall bool) error { return errors.WithStackIf(err) } +// ValidateReinstall reports whether reinstall can proceed. Every server using the +// same installed artifact must be stopped first. +func (s *Server) ValidateReinstall() error { + if s.BaseReinstallAllowed != nil && !s.BaseReinstallAllowed(s.sharedBasePath()) { + return ErrInstalledServerArtifactInUse + } + return nil +} + // Reinstall reinstalls a server's software by utilizing the installation script // for the server software. This does not touch any existing files for the server, // other than what the script modifies. func (s *Server) Reinstall() error { - if s.Environment.State() != environment.ProcessOfflineState { - s.Log().Debug("waiting for server instance to enter a stopped state") - if err := s.Environment.WaitForStop(s.Context(), time.Second*10, true); err != nil { - return errors.WrapIf(err, "install: failed to stop running environment") - } + if err := s.ValidateReinstall(); err != nil { + return err } s.Log().Info("syncing server state with remote source before executing re-installation process") @@ -89,7 +116,17 @@ func (s *Server) Reinstall() error { return errors.WrapIf(err, "install: failed to sync server state with Protocube") } - return s.install(s.Context(), true) + installCtx, cancel := context.WithTimeout(s.Context(), InstallLockTimeout) + defer cancel() + + return s.install(installCtx, true) +} + +func (s *Server) sharedBasePath() string { + if s.fs == nil { + return "" + } + return filepath.Clean(s.Filesystem().Overlay().ServerPath) } // Internal installation function used to simplify reporting back to Protocube. @@ -108,6 +145,10 @@ func (s *Server) internalInstall(ctx context.Context) error { return err } + if err := p.RunWarmup(ctx); err != nil { + return err + } + s.Log().Info("completed installation process for server") return nil } @@ -353,7 +394,7 @@ func chownRecursiveTo(path string, uid, gid int) error { // GetLogPath returns the log path for the installation process. func (ip *InstallationProcess) GetLogPath() string { - return filepath.Join(config.Get().System.LogDirectory, "/install", ip.Server.ID()+".log") + return installLogDiskPath(ip.Server.installLogBasePath(), "") } // writeFailedInstallLog writes container stdout/stderr to the install log when the script exits non-zero. @@ -401,7 +442,11 @@ func (ip *InstallationProcess) AfterExecute(containerId string) error { return err } - f, err := os.OpenFile(ip.GetLogPath(), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600) + logPath := ip.GetLogPath() + if err := os.MkdirAll(filepath.Dir(logPath), 0o755); err != nil { + return err + } + f, err := os.OpenFile(logPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600) if err != nil { return err } @@ -410,7 +455,7 @@ func (ip *InstallationProcess) AfterExecute(containerId string) error { // We write the contents of the container output to a more "permanent" file so that they // can be referenced after this container is deleted. We'll also include the environment // variables passed into the container to make debugging things a little easier. - ip.Server.Log().WithField("path", ip.GetLogPath()).Debug("writing most recent installation logs to disk") + ip.Server.Log().WithField("path", logPath).Debug("writing most recent installation logs to disk") tmpl, err := template.New("header").Parse(`SLS Server Installation Log @@ -464,7 +509,7 @@ func (ip *InstallationProcess) Execute() (string, error) { // writes directly to the base on the host. baseServerFolder := ip.Server.Filesystem().Overlay().ServerPath containerUser, hostUID, hostGID := ip.installContainerUser() - if err := chownRecursiveTo(baseServerFolder, hostUID, hostGID); err != nil { + if err := ip.prepareLifecyclePathWritable(ctx, baseServerFolder, containerUser, hostUID, hostGID); err != nil { return "", errors.Wrapf(err, "install: chown base server folder for container user") } @@ -528,11 +573,13 @@ func (ip *InstallationProcess) Execute() (string, error) { if err != nil { return "", err } + ip.Server.SetInstallContainer(r.ID) ip.Server.Log().WithField("container_id", r.ID).Info("running installation script for server in container") if err := ip.client.ContainerStart(ctx, r.ID, container.StartOptions{}); err != nil { return "", err } + ip.Server.SetInstallStatus("running") // Process the install event in the background by listening to the stream output until the // container has stopped, at which point we'll disconnect from it. @@ -551,11 +598,14 @@ func (ip *InstallationProcess) Execute() (string, error) { case err := <-eChan: // Once the container has stopped running we can mark the install process as being completed. if err == nil { + ip.Server.SetInstallStatus("exited") ip.Server.Events().Publish(DaemonMessageEvent, "Installation process completed.") } else { return "", err } case res := <-sChan: + ip.Server.SetInstallExitCode(res.StatusCode) + ip.Server.SetInstallStatus("exited") if res.StatusCode != 0 { ip.writeFailedInstallLog(ctx, r.ID, res.StatusCode) return "", errors.Errorf("install script exited with code %d (see install log for output)", res.StatusCode) diff --git a/daemon/server/install_lock.go b/daemon/server/install_lock.go index 5ea5f7c4..0e380305 100644 --- a/daemon/server/install_lock.go +++ b/daemon/server/install_lock.go @@ -5,6 +5,7 @@ import ( "errors" "os" "path/filepath" + "strings" "time" ) @@ -43,6 +44,14 @@ func lockPath(serverPath string) string { return filepath.Join(serverPath, InstallLockFile) } +func InstallLockOwner(serverPath string) string { + data, err := os.ReadFile(lockPath(serverPath)) + if err != nil { + return "" + } + return strings.TrimSpace(string(data)) +} + // WaitForInstallLockReleased blocks until serverPath/.lock does not exist (install // finished or failed), the timeout expires, or ctx is canceled. If the lock still // exists after timeout, the server folder is removed (stale lock) and @@ -82,7 +91,7 @@ func WaitForInstallLockReleased(ctx context.Context, serverPath string, timeout // Caller with needInstall true must run install then release(). The folder // already exists (created here). On install failure, caller must RemoveAll(serverPath) // then release(). On success, just release(). -func AcquireInstallLock(serverPath string) (release func(), needInstall bool, err error) { +func AcquireInstallLock(serverPath, ownerID string) (release func(), needInstall bool, err error) { for { if IsBaseInstalled(serverPath) { return func() {}, false, nil @@ -93,6 +102,7 @@ func AcquireInstallLock(serverPath string) (release func(), needInstall bool, er lp := lockPath(serverPath) f, err := os.OpenFile(lp, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644) if err == nil { + _, _ = f.WriteString(ownerID) releaseFn := func() { _ = f.Close() _ = os.Remove(lp) diff --git a/daemon/server/install_log_path_test.go b/daemon/server/install_log_path_test.go new file mode 100644 index 00000000..70fab5c1 --- /dev/null +++ b/daemon/server/install_log_path_test.go @@ -0,0 +1,22 @@ +package server + +import "testing" + +func TestSanitizeInstallLogKey(t *testing.T) { + tests := []struct { + in string + want string + }{ + {"paper/1.20.1", "paper-1.20.1"}, + {"/paper/1.20.1/", "paper-1.20.1"}, + {"minigames/lobby", "minigames-lobby"}, + {"", "unknown"}, + {"/", "root"}, + } + + for _, tc := range tests { + if got := sanitizeInstallLogKey(tc.in); got != tc.want { + t.Fatalf("sanitizeInstallLogKey(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} diff --git a/daemon/server/install_state.go b/daemon/server/install_state.go new file mode 100644 index 00000000..b6d73368 --- /dev/null +++ b/daemon/server/install_state.go @@ -0,0 +1,451 @@ +package server + +import ( + "bufio" + "context" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "time" + + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/client" + "protoxon.com/sls/daemon/config" + "protoxon.com/sls/daemon/environment" +) + +type InstallPhase string + +const ( + InstallPhaseIdle InstallPhase = "idle" + InstallPhaseInstalling InstallPhase = "installing" + InstallPhaseWarming InstallPhase = "warming" + InstallPhasePostWarmup InstallPhase = "post_warmup" + InstallPhaseInstallFailed InstallPhase = "install_failed" + InstallPhaseWarmupFailed InstallPhase = "warmup_failed" + InstallPhasePostWarmupFailed InstallPhase = "post_warmup_failed" + InstallPhaseCompleted InstallPhase = "completed" +) + +type InstallInfo struct { + Phase InstallPhase `json:"phase"` + ContainerID string `json:"container_id,omitempty"` + ContainerName string `json:"container_name,omitempty"` + Status string `json:"status,omitempty"` + ExitCode *int64 `json:"exit_code,omitempty"` + StartedAt *time.Time `json:"started_at,omitempty"` + FinishedAt *time.Time `json:"finished_at,omitempty"` + FailureReason string `json:"failure_reason,omitempty"` +} + +type installState struct { + mu sync.RWMutex + + phase InstallPhase + containerID string + containerName string + status string + exitCode *int64 + startedAt *time.Time + finishedAt *time.Time + failureReason string + logPath string +} + +func newInstallState() *installState { + return &installState{phase: InstallPhaseIdle} +} + +func (s *Server) StartInstallPhase(phase InstallPhase, containerName, logPath string) { + now := time.Now() + s.installState.mu.Lock() + defer s.installState.mu.Unlock() + s.installState.phase = phase + s.installState.containerID = "" + s.installState.containerName = containerName + s.installState.status = "" + s.installState.exitCode = nil + s.installState.startedAt = &now + s.installState.finishedAt = nil + s.installState.failureReason = "" + s.installState.logPath = logPath +} + +func (s *Server) SetInstallContainer(containerID string) { + s.installState.mu.Lock() + defer s.installState.mu.Unlock() + s.installState.containerID = containerID + s.installState.status = "created" +} + +func (s *Server) SetInstallStatus(status string) { + s.installState.mu.Lock() + defer s.installState.mu.Unlock() + s.installState.status = status +} + +func (s *Server) SetInstallExitCode(code int64) { + s.installState.mu.Lock() + defer s.installState.mu.Unlock() + s.installState.exitCode = &code +} + +func (s *Server) FinishInstallPhase(phase InstallPhase, failureReason string) { + now := time.Now() + s.installState.mu.Lock() + defer s.installState.mu.Unlock() + s.installState.phase = phase + s.installState.finishedAt = &now + s.installState.failureReason = failureReason + if phase == InstallPhaseCompleted && s.installState.exitCode == nil { + var code int64 + s.installState.exitCode = &code + } +} + +func (s *Server) InstallInfo(ctx context.Context) InstallInfo { + info := s.installInfoSnapshot() + s.enrichInstallInfoFromDocker(ctx, &info) + return info +} + +func (s *Server) installInfoSnapshot() InstallInfo { + s.installState.mu.RLock() + defer s.installState.mu.RUnlock() + return InstallInfo{ + Phase: s.installState.phase, + ContainerID: s.installState.containerID, + ContainerName: s.installState.containerName, + Status: s.installState.status, + ExitCode: cloneInt64(s.installState.exitCode), + StartedAt: cloneTime(s.installState.startedAt), + FinishedAt: cloneTime(s.installState.finishedAt), + FailureReason: s.installState.failureReason, + } +} + +func (s *Server) installLogPath() string { + s.installState.mu.RLock() + path := s.installState.logPath + s.installState.mu.RUnlock() + return path +} + +func (s *Server) installLogsForServer(ctx context.Context, lines int) ([]string, error) { + phase := s.installPhase() + if phase != InstallPhaseIdle && phase != InstallPhaseCompleted { + return s.ReadInstallLogfile(ctx, lines) + } + + logs := s.readStoredInstallLogs(lines) + if len(logs) > 0 { + return logs, nil + } + + ownerID := InstallLockOwner(s.installLogBasePath()) + if ownerID == "" || ownerID == s.ID() { + return nil, nil + } + + return readInstallLogsFromDocker(ctx, ownerID+"_installer", lines) +} + +type installLogSource struct { + phase InstallPhase + diskPath string + containerName string +} + +func (s *Server) installLogSources() []installLogSource { + id := s.ID() + basePath := s.installLogBasePath() + return []installLogSource{ + { + phase: InstallPhaseInstalling, + diskPath: installLogDiskPath(basePath, ""), + containerName: id + "_installer", + }, + { + phase: InstallPhaseWarming, + diskPath: installLogDiskPath(basePath, "-warmup"), + containerName: id + "_warmup", + }, + { + phase: InstallPhasePostWarmup, + diskPath: installLogDiskPath(basePath, "-post-warmup"), + containerName: id + "_post_warmup", + }, + } +} + +// InstallLogsAll returns install logs from every phase (install, warmup, post-warmup) +// concatenated in order. During an active phase, live container output is preferred. +func (s *Server) InstallLogsAll(ctx context.Context) ([]string, error) { + current := s.installPhase() + ownerID := InstallLockOwner(s.installLogBasePath()) + + var out []string + for _, src := range s.installLogSources() { + logs, err := s.installPhaseLogs(ctx, current, src, ownerID) + if err != nil { + return nil, err + } + if len(logs) == 0 { + continue + } + if len(out) > 0 { + out = append(out, "") + } + out = append(out, logs...) + } + return out, nil +} + +func (s *Server) installPhaseLogs(ctx context.Context, current InstallPhase, src installLogSource, ownerID string) ([]string, error) { + if current == src.phase { + logs, err := readInstallLogsFromDocker(ctx, src.containerName, 0) + if err != nil { + return nil, err + } + if len(logs) > 0 { + return logs, nil + } + + if path := s.installLogPath(); path == src.diskPath { + if logs := readTailLines(path, 0); len(logs) > 0 { + return logs, nil + } + } + + if logs := readTailLines(src.diskPath, 0); len(logs) > 0 { + return logs, nil + } + } else if logs := readTailLines(src.diskPath, 0); len(logs) > 0 { + return logs, nil + } + + if src.phase != InstallPhaseInstalling || ownerID == "" || ownerID == s.ID() { + return nil, nil + } + + return readInstallLogsFromDocker(ctx, ownerID+"_installer", 0) +} + +func (s *Server) installPhase() InstallPhase { + s.installState.mu.RLock() + defer s.installState.mu.RUnlock() + return s.installState.phase +} + +func (s *Server) enrichInstallInfoFromDocker(ctx context.Context, info *InstallInfo) { + containerRef := info.ContainerID + if containerRef == "" { + containerRef = info.ContainerName + } + if containerRef == "" { + return + } + + cli, err := environment.Docker() + if err != nil { + return + } + + inspect, err := cli.ContainerInspect(ctx, containerRef) + if err != nil { + if client.IsErrNotFound(err) { + return + } + return + } + + info.ContainerID = inspect.ID + if info.ContainerName == "" && inspect.Name != "" { + info.ContainerName = inspect.Name + } + if inspect.State != nil { + info.Status = inspect.State.Status + code := int64(inspect.State.ExitCode) + if inspect.State.Status == "exited" || inspect.State.Status == "dead" { + info.ExitCode = &code + } + if info.StartedAt == nil && inspect.State.StartedAt != "" { + if t, err := time.Parse(time.RFC3339Nano, inspect.State.StartedAt); err == nil && !t.IsZero() { + info.StartedAt = &t + } + } + if info.FinishedAt == nil && inspect.State.FinishedAt != "" { + if t, err := time.Parse(time.RFC3339Nano, inspect.State.FinishedAt); err == nil && !t.IsZero() { + info.FinishedAt = &t + } + } + } +} + +func (s *Server) ReadInstallLogfile(ctx context.Context, lines int) ([]string, error) { + logs, err := readInstallLogsFromDocker(ctx, s.installContainerRef(), lines) + if err != nil { + return nil, err + } + if len(logs) > 0 { + return logs, nil + } + + logs = readTailLines(s.installLogPath(), lines) + if len(logs) > 0 { + return logs, nil + } + return s.readStoredInstallLogs(lines), nil +} + +func (s *Server) readStoredInstallLogs(lines int) []string { + basePath := s.installLogBasePath() + paths := []string{ + installLogDiskPath(basePath, ""), + installLogDiskPath(basePath, "-warmup"), + installLogDiskPath(basePath, "-post-warmup"), + } + + var out []string + for _, path := range paths { + logs := readTailLines(path, lines) + if len(logs) == 0 { + continue + } + if len(out) > 0 { + out = append(out, "") + } + out = append(out, logs...) + } + if lines > 0 && len(out) > lines { + out = out[len(out)-lines:] + } + return out +} + +func (s *Server) installContainerRef() string { + s.installState.mu.RLock() + defer s.installState.mu.RUnlock() + if s.installState.containerID != "" { + return s.installState.containerID + } + return s.installState.containerName +} + +func readInstallLogsFromDocker(ctx context.Context, containerRef string, lines int) ([]string, error) { + if containerRef == "" { + return nil, nil + } + + cli, err := environment.Docker() + if err != nil { + return nil, err + } + + opts := container.LogsOptions{ + ShowStdout: true, + ShowStderr: true, + } + if lines > 0 { + opts.Tail = strconv.Itoa(lines) + } + reader, err := cli.ContainerLogs(ctx, containerRef, opts) + if err != nil { + if client.IsErrNotFound(err) { + return nil, nil + } + return nil, err + } + defer reader.Close() + + var logs []string + scanner := bufio.NewScanner(reader) + for scanner.Scan() { + logs = append(logs, scanner.Text()) + } + return logs, scanner.Err() +} + +func readTailLines(path string, max int) []string { + if path == "" { + return nil + } + f, err := os.Open(filepath.Clean(path)) + if err != nil { + return nil + } + defer f.Close() + + var lines []string + scanner := bufio.NewScanner(f) + for scanner.Scan() { + lines = append(lines, scanner.Text()) + if max > 0 && len(lines) > max { + lines = lines[len(lines)-max:] + } + } + return lines +} + +func (s *Server) installLogBasePath() string { + return s.Filesystem().Overlay().ServerPath +} + +// installLogDiskPath returns the on-disk install log path for a server base folder. +// Example: paper/1.20.1 -> {logDirectory}/install/paper-1.20.1.log +func installLogDiskPath(serverPath, suffix string) string { + return filepath.Join(installLogDirectory(), sanitizeInstallLogKey(serverPath)+suffix+".log") +} + +func installLogDirectory() string { + return filepath.Join(config.Get().System.LogDirectory, "install") +} + +func sanitizeInstallLogKey(serverPath string) string { + if serverPath == "" { + return "unknown" + } + + p := strings.Trim(filepath.ToSlash(filepath.Clean(serverPath)), "/") + if p == "" { + return "root" + } + + var b strings.Builder + for _, r := range p { + switch { + case (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9'): + b.WriteRune(r) + case r == '-', r == '_', r == '.': + b.WriteRune(r) + case r == '/', r == '\\': + b.WriteRune('-') + default: + b.WriteRune('-') + } + } + + key := strings.Trim(b.String(), "-") + if key == "" { + return "unknown" + } + return key +} + +func cloneInt64(v *int64) *int64 { + if v == nil { + return nil + } + c := *v + return &c +} + +func cloneTime(v *time.Time) *time.Time { + if v == nil { + return nil + } + c := *v + return &c +} diff --git a/daemon/server/listeners.go b/daemon/server/listeners.go old mode 100755 new mode 100644 diff --git a/daemon/server/manager.go b/daemon/server/manager.go old mode 100755 new mode 100644 index 878a2246..274c584a --- a/daemon/server/manager.go +++ b/daemon/server/manager.go @@ -44,9 +44,32 @@ func NewManager(client remote.Client) *Manager { func (m *Manager) Add(server *Server) { m.mutex.Lock() defer m.mutex.Unlock() + server.BaseReinstallAllowed = m.baseReinstallAllowed m.servers[server.id] = server } +// baseReinstallAllowed reports whether every server on this node sharing basePath +// is offline so the installed artifact may be reinstalled. +func (m *Manager) baseReinstallAllowed(basePath string) bool { + clean := filepath.Clean(basePath) + if clean == "" { + return true + } + + m.mutex.RLock() + defer m.mutex.RUnlock() + + for _, s := range m.servers { + if s.sharedBasePath() != clean { + continue + } + if s.Environment.State() != environment.ProcessOfflineState { + return false + } + } + return true +} + // Get returns a single server instance and a boolean value indicating if it was // found in the global collection or not. func (m *Manager) Get(id string) (*Server, bool) { @@ -92,7 +115,7 @@ func (m *Manager) All() []*Server { } // InitServer initializes a server using the provided server configuration data -func (m *Manager) InitServer(req models.ServerConfigurationResponse) (*Server, error) { +func (m *Manager) InitServer(req models.ServerConfiguration) (*Server, error) { s, err := New(m.client) if err != nil { return nil, err diff --git a/daemon/server/mounts.go b/daemon/server/mounts.go old mode 100755 new mode 100644 diff --git a/daemon/server/power.go b/daemon/server/power.go old mode 100755 new mode 100644 index 6abafb37..2fbb69da --- a/daemon/server/power.go +++ b/daemon/server/power.go @@ -288,10 +288,6 @@ func (s *Server) onBeforeStart() error { return ErrSuspended } - // Ensure we sync the server information with the environment so that any new environment variables - // and process resource limits are correctly applied. - s.SyncWithEnvironment() - // Install server base files only if the folder does not exist. Use a lock // file serverPath/.lock so only one process installs; others wait and then // skip when the folder has content (installed). If the lock is held longer @@ -300,7 +296,7 @@ func (s *Server) onBeforeStart() error { serverPath := s.Filesystem().Overlay().ServerPath for { if !IsBaseInstalled(serverPath) { - release, needInstall, err := AcquireInstallLock(serverPath) + release, needInstall, err := AcquireInstallLock(serverPath, s.ID()) if err != nil { return errors.Wrap(err, "install lock") } diff --git a/daemon/server/resources.go b/daemon/server/resources.go old mode 100755 new mode 100644 diff --git a/daemon/server/server.go b/daemon/server/server.go old mode 100755 new mode 100644 index 4c982268..d500c623 --- a/daemon/server/server.go +++ b/daemon/server/server.go @@ -26,8 +26,9 @@ type Server struct { // Internal mutex used to block actions that need to occur sequentially, such as // writing the configuration to the disk. sync.RWMutex - ctx context.Context - ctxCancel *context.CancelFunc + installLock *system.Locker + ctx context.Context + ctxCancel *context.CancelFunc client remote.Client @@ -65,16 +66,24 @@ type Server struct { // Tracks if we've already emitted the very first status update. initialStateBroadcast *system.AtomicBool + + installState *installState + + // BaseReinstallAllowed reports whether reinstall may proceed for servers using + // basePath. When true, no server on the node sharing that installed artifact + // is currently active. + BaseReinstallAllowed func(basePath string) bool } func New(client remote.Client) (*Server, error) { ctx, cancel := context.WithCancel(context.Background()) server := &Server{ - ctx: ctx, - ctxCancel: &cancel, - client: client, - powerLock: system.NewLocker(), + ctx: ctx, + ctxCancel: &cancel, + installLock: system.NewLocker(), + client: client, + powerLock: system.NewLocker(), sinks: map[system.SinkName]*system.SinkPool{ system.LogSink: system.NewSinkPool(), system.InstallSink: system.NewSinkPool(), @@ -83,6 +92,7 @@ func New(client remote.Client) (*Server, error) { State: system.NewAtomicString("offline"), }, initialStateBroadcast: system.NewAtomicBool(false), + installState: newInstallState(), } return server, nil @@ -139,9 +149,24 @@ func (s *Server) IsRunning() bool { return st == environment.ProcessRunningState || st == environment.ProcessStartingState } -// Reads the log file for a server up to a specified number of bytes. -func (s *Server) ReadLogfile(len int) ([]string, error) { - return s.Environment.Readlog(len) +// Reads the log file for a server up to a specified number of lines. +func (s *Server) ReadLogfile(ctx context.Context, lines int) ([]string, error) { + installLogs, installErr := s.installLogsForServer(ctx, lines) + + out, err := s.Environment.Readlog(lines) + if err != nil { + out = nil + } + + if installErr != nil && err != nil { + return nil, err + } + + out = append(installLogs, out...) + if lines > 0 && len(out) > lines { + out = out[len(out)-lines:] + } + return out, nil } // Checks if the server is marked as being suspended or not on the system. @@ -234,6 +259,9 @@ func (s *Server) CleanupForDestroy() { s.CtxCancel() s.Events().Destroy() s.DestroyAllSinks() + if s.installLock != nil { + s.installLock.Destroy() + } // per-server websockets are not implemented yet // this will be needed when they are implemented //s.Websockets().CancelAll() @@ -347,7 +375,7 @@ func (s *Server) Sync() error { // underlying environment will not be affected. This is because this function // can be called from scoped where the server may not be fully initialized, // therefore other things like the filesystem and environment may not exist yet. -func (s *Server) SyncWithConfiguration(cfg models.ServerConfigurationResponse) error { +func (s *Server) SyncWithConfiguration(cfg models.ServerConfiguration) error { s.Lock() s.procConfig = cfg.ProcessConfiguration s.cfg.mu.Lock() diff --git a/daemon/server/warmup.go b/daemon/server/warmup.go new file mode 100644 index 00000000..f3f34415 --- /dev/null +++ b/daemon/server/warmup.go @@ -0,0 +1,456 @@ +package server + +import ( + "bufio" + "context" + "fmt" + "io" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "emperror.dev/errors" + "github.com/containerd/errdefs" + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/mount" + "github.com/docker/docker/client" + "protoxon.com/sls/daemon/config" + "protoxon.com/sls/daemon/remote" + "protoxon.com/sls/daemon/system" +) + +const ( + warmupPolicyFail = "fail" + warmupPolicyContinue = "continue" + warmupPolicyRetry = "retry" +) + +func (ip *InstallationProcess) RunWarmup(ctx context.Context) error { + ip.applyWarmupDefaults() + if !ip.Script.Warmup { + return nil + } + + var lastErr error + for attempt := 1; attempt <= ip.warmupAttempts(); attempt++ { + if attempt > 1 { + ip.Server.Log().WithField("attempt", attempt).Info("retrying server warmup") + } + if err := ip.runWarmupAttempt(ctx, attempt); err != nil { + lastErr = err + ip.Server.Log().WithField("attempt", attempt).WithField("error", err).Warn("server warmup failed") + continue + } + lastErr = nil + break + } + + if lastErr != nil { + if ip.Script.WarmupFailurePolicy == warmupPolicyContinue { + ip.Server.FinishInstallPhase(InstallPhaseWarmupFailed, lastErr.Error()) + ip.Server.Log().WithField("error", lastErr).Warn("continuing after warmup failure due to configured policy") + } else { + ip.Server.FinishInstallPhase(InstallPhaseWarmupFailed, lastErr.Error()) + return lastErr + } + } + + if strings.TrimSpace(ip.Script.PostWarmupScript) == "" { + return nil + } + if err := ip.runPostWarmup(ctx); err != nil { + ip.Server.FinishInstallPhase(InstallPhasePostWarmupFailed, err.Error()) + if ip.Script.WarmupFailurePolicy == warmupPolicyContinue { + ip.Server.Log().WithField("error", err).Warn("continuing after post-warmup failure due to configured policy") + return nil + } + return err + } + return nil +} + +func (ip *InstallationProcess) warmupAttempts() int { + ip.applyWarmupDefaults() + if ip.Script.WarmupFailurePolicy != warmupPolicyRetry { + return 1 + } + return 1 + ip.Script.WarmupRetries +} + +func (ip *InstallationProcess) applyWarmupDefaults() { + if ip.Script.WarmupTimeout <= 0 { + ip.Script.WarmupTimeout = 300 + } + if ip.Script.PostWarmupTimeout <= 0 { + ip.Script.PostWarmupTimeout = 120 + } + if strings.TrimSpace(ip.Script.WarmupFailurePolicy) == "" { + ip.Script.WarmupFailurePolicy = warmupPolicyFail + } +} + +func (ip *InstallationProcess) runWarmupAttempt(ctx context.Context, attempt int) error { + name := ip.Server.ID() + "_warmup" + ip.Server.StartInstallPhase(InstallPhaseWarming, name, installLogDiskPath(ip.Server.installLogBasePath(), "-warmup")) + + if err := ip.removeNamedContainer(ctx, name); err != nil { + return err + } + + runCtx, cancel := context.WithTimeout(ctx, time.Duration(ip.Script.WarmupTimeout)*time.Second) + defer cancel() + + id, err := ip.createLifecycleContainer(runCtx, name, "server_warmup", nil) + if err != nil { + return errors.Wrap(err, "warmup: create container") + } + ip.Server.SetInstallContainer(id) + + if err := ip.client.ContainerStart(runCtx, id, container.StartOptions{}); err != nil { + return errors.Wrap(err, "warmup: start container") + } + ip.Server.SetInstallStatus("running") + + err = ip.waitForWarmupSignal(runCtx, id) + if err != nil { + _ = ip.writeLifecycleContainerLog(context.Background(), id, ip.Server.installLogPath(), "Warmup failed") + _ = ip.removeNamedContainer(context.Background(), name) + return err + } + + _ = ip.stopWarmupContainer(context.Background(), id) + _ = ip.writeLifecycleContainerLog(context.Background(), id, ip.Server.installLogPath(), "Warmup completed") + _ = ip.removeNamedContainer(context.Background(), name) + ip.Server.SetInstallStatus("exited") + var code int64 + ip.Server.SetInstallExitCode(code) + ip.Server.Log().WithField("attempt", attempt).Info("server warmup completed") + return nil +} + +func (ip *InstallationProcess) runPostWarmup(ctx context.Context) error { + name := ip.Server.ID() + "_post_warmup" + ip.Server.StartInstallPhase(InstallPhasePostWarmup, name, installLogDiskPath(ip.Server.installLogBasePath(), "-post-warmup")) + + if err := ip.removeNamedContainer(ctx, name); err != nil { + return err + } + + runCtx, cancel := context.WithTimeout(ctx, time.Duration(ip.Script.PostWarmupTimeout)*time.Second) + defer cancel() + + cmd := []string{"/bin/sh", "-lc", ip.Script.PostWarmupScript} + id, err := ip.createLifecycleContainer(runCtx, name, "server_post_warmup", cmd) + if err != nil { + return errors.Wrap(err, "post-warmup: create container") + } + ip.Server.SetInstallContainer(id) + + if err := ip.client.ContainerStart(runCtx, id, container.StartOptions{}); err != nil { + return errors.Wrap(err, "post-warmup: start container") + } + ip.Server.SetInstallStatus("running") + + status, err := ip.waitForContainerExit(runCtx, id) + if err != nil { + _ = ip.writeLifecycleContainerLog(context.Background(), id, ip.Server.installLogPath(), "Post-warmup failed") + _ = ip.removeNamedContainer(context.Background(), name) + return err + } + ip.Server.SetInstallExitCode(status) + ip.Server.SetInstallStatus("exited") + + _ = ip.writeLifecycleContainerLog(context.Background(), id, ip.Server.installLogPath(), "Post-warmup completed") + _ = ip.removeNamedContainer(context.Background(), name) + if status != 0 { + return errors.Errorf("post-warmup script exited with code %d", status) + } + return nil +} + +func (ip *InstallationProcess) createLifecycleContainer(ctx context.Context, name, containerType string, cmd []string) (string, error) { + img := ip.ContainerImage() + if img == "" { + return "", errors.New("server has no container image configured") + } + + containerUser, hostUID, hostGID := ip.installContainerUser() + baseServerFolder := ip.Server.Filesystem().Overlay().ServerPath + if err := ip.prepareLifecyclePathWritable(ctx, baseServerFolder, containerUser, hostUID, hostGID); err != nil { + return "", errors.Wrap(err, "warmup: chown base server folder for container user") + } + + conf := &container.Config{ + Hostname: name, + AttachStdout: true, + AttachStderr: true, + AttachStdin: true, + OpenStdin: true, + Tty: true, + User: containerUser, + WorkingDir: "/home/container", + Image: strings.TrimPrefix(img, "~"), + Env: ip.Server.GetEnvironmentVariables(), + Labels: map[string]string{ + "Service": "SLS", + "ContainerType": containerType, + }, + } + if len(cmd) > 0 { + conf.Entrypoint = cmd[:1] + conf.Cmd = cmd[1:] + } + + cfg := config.Get() + hostConf := &container.HostConfig{ + Mounts: ip.lifecycleMounts(baseServerFolder), + Resources: ip.resourceLimits(), + DNS: cfg.Docker.Network.Dns, + LogConfig: cfg.Docker.ContainerLogConfig(), + NetworkMode: container.NetworkMode(cfg.Docker.Network.Mode), + UsernsMode: container.UsernsMode(cfg.Docker.UsernsMode), + Tmpfs: map[string]string{ + "/tmp": "rw,exec,nosuid,size=" + strconv.Itoa(int(cfg.Docker.TmpfsSize)) + "M", + }, + } + + r, err := ip.client.ContainerCreate(ctx, conf, hostConf, nil, nil, name) + if err != nil { + return "", err + } + return r.ID, nil +} + +func (ip *InstallationProcess) lifecycleMounts(baseServerFolder string) []mount.Mount { + mounts := []mount.Mount{ + { + Target: "/home/container", + Source: baseServerFolder, + Type: mount.TypeBind, + ReadOnly: false, + }, + } + for _, m := range append(ip.Server.customMounts(), ip.Server.volumeMounts()...) { + mounts = append(mounts, mount.Mount{ + Target: m.Target, + Source: m.Source, + Type: mount.TypeBind, + ReadOnly: m.ReadOnly, + }) + } + return mounts +} + +func (ip *InstallationProcess) prepareLifecyclePathWritable(ctx context.Context, path, containerUser string, hostUID, hostGID int) error { + if err := chownRecursiveTo(path, hostUID, hostGID); err != nil { + return err + } + + name := ip.Server.ID() + "_permissions" + if err := ip.removeNamedContainer(ctx, name); err != nil { + return err + } + + cmd := fmt.Sprintf("chown -R %s /home/container && chmod -R u+rwX /home/container", containerUser) + conf := &container.Config{ + Hostname: name, + Image: strings.TrimPrefix(ip.ContainerImage(), "~"), + User: "0:0", + Entrypoint: []string{"/bin/sh"}, + Cmd: []string{"-lc", cmd}, + Labels: map[string]string{ + "Service": "SLS", + "ContainerType": "server_lifecycle_permissions", + }, + } + hostConf := &container.HostConfig{ + Mounts: []mount.Mount{ + { + Target: "/home/container", + Source: path, + Type: mount.TypeBind, + ReadOnly: false, + }, + }, + } + + r, err := ip.client.ContainerCreate(ctx, conf, hostConf, nil, nil, name) + if err != nil { + return err + } + defer ip.removeNamedContainer(context.Background(), name) + + if err := ip.client.ContainerStart(ctx, r.ID, container.StartOptions{}); err != nil { + return err + } + status, err := ip.waitForContainerExit(ctx, r.ID) + if err != nil { + return err + } + if status != 0 { + return errors.Errorf("lifecycle permission helper exited with code %d", status) + } + return nil +} + +func (ip *InstallationProcess) waitForWarmupSignal(ctx context.Context, id string) error { + reader, err := ip.client.ContainerLogs(ctx, id, container.LogsOptions{ + ShowStdout: true, + ShowStderr: true, + Follow: true, + }) + if err != nil { + return err + } + defer reader.Close() + + exitCode := make(chan int64, 1) + exitErr := make(chan error, 1) + go func() { + code, err := ip.waitForContainerExit(ctx, id) + if err != nil { + exitErr <- err + return + } + exitCode <- code + }() + + matched := make(chan struct{}, 1) + scanErr := make(chan error, 1) + go func() { + scanner := bufio.NewScanner(reader) + for scanner.Scan() { + line := scanner.Bytes() + ip.Server.Sink(system.InstallSink).Push(append([]byte(nil), line...)) + if ip.matchesStartupSignal(line) { + matched <- struct{}{} + return + } + } + if err := scanner.Err(); err != nil && !errors.Is(err, context.Canceled) { + scanErr <- err + } + }() + + select { + case <-matched: + return nil + case code := <-exitCode: + return errors.Errorf("warmup container exited before online signal with code %d", code) + case err := <-exitErr: + return err + case err := <-scanErr: + return err + case <-ctx.Done(): + return errors.Wrap(ctx.Err(), "warmup timed out before online signal") + } +} + +func (ip *InstallationProcess) matchesStartupSignal(line []byte) bool { + processConfiguration := ip.Server.ProcessConfiguration() + v := append([]byte(nil), line...) + if processConfiguration.Startup.StripAnsi { + v = stripAnsiRegex.ReplaceAll(v, []byte("")) + } + for _, matcher := range processConfiguration.Startup.Done { + if matcher.Matches(v) { + return true + } + } + return false +} + +func (ip *InstallationProcess) waitForContainerExit(ctx context.Context, id string) (int64, error) { + sChan, eChan := ip.client.ContainerWait(ctx, id, container.WaitConditionNotRunning) + select { + case err := <-eChan: + return 0, err + case res := <-sChan: + return res.StatusCode, nil + case <-ctx.Done(): + return 0, ctx.Err() + } +} + +func (ip *InstallationProcess) stopWarmupContainer(ctx context.Context, id string) error { + stop := ip.Server.ProcessConfiguration().Stop + if stop.Type == remote.ProcessStopCommand && strings.TrimSpace(stop.Value) != "" { + if err := ip.sendContainerCommand(ctx, id, stop.Value); err == nil { + if _, err := ip.waitForContainerExitWithTimeout(ctx, id, 30*time.Second); err == nil { + return nil + } + } + } + + if stop.Type == remote.ProcessStopSignal && strings.TrimSpace(stop.Value) != "" { + if err := ip.client.ContainerKill(ctx, id, strings.ToUpper(stop.Value)); err == nil { + if _, err := ip.waitForContainerExitWithTimeout(ctx, id, 30*time.Second); err == nil { + return nil + } + } + } + + timeout := 30 + if err := ip.client.ContainerStop(ctx, id, container.StopOptions{Timeout: &timeout}); err != nil && !client.IsErrNotFound(err) { + return err + } + return nil +} + +func (ip *InstallationProcess) sendContainerCommand(ctx context.Context, id, command string) error { + resp, err := ip.client.ContainerAttach(ctx, id, container.AttachOptions{ + Stdin: true, + Stream: true, + }) + if err != nil { + return err + } + defer resp.Close() + + _, err = io.WriteString(resp.Conn, command+"\n") + return err +} + +func (ip *InstallationProcess) waitForContainerExitWithTimeout(ctx context.Context, id string, timeout time.Duration) (int64, error) { + waitCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + return ip.waitForContainerExit(waitCtx, id) +} + +func (ip *InstallationProcess) removeNamedContainer(ctx context.Context, name string) error { + err := ip.client.ContainerRemove(ctx, name, container.RemoveOptions{ + RemoveVolumes: true, + Force: true, + }) + if err != nil && !client.IsErrNotFound(err) && !errdefs.IsNotFound(err) { + return err + } + return nil +} + +func (ip *InstallationProcess) writeLifecycleContainerLog(ctx context.Context, id, path, title string) error { + reader, err := ip.client.ContainerLogs(ctx, id, container.LogsOptions{ + ShowStdout: true, + ShowStderr: true, + Follow: false, + }) + if err != nil { + return err + } + defer reader.Close() + + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600) + if err != nil { + return err + } + defer f.Close() + + _, _ = io.WriteString(f, title+"\n\n") + _, err = io.Copy(f, reader) + return err +} diff --git a/daemon/server/warmup_test.go b/daemon/server/warmup_test.go new file mode 100644 index 00000000..3ee1a345 --- /dev/null +++ b/daemon/server/warmup_test.go @@ -0,0 +1,73 @@ +package server + +import ( + "testing" + + "protoxon.com/sls/daemon/models" + "protoxon.com/sls/daemon/remote" +) + +func TestWarmupDefaults(t *testing.T) { + ip := &InstallationProcess{Script: &remote.InstallationScript{}} + + ip.applyWarmupDefaults() + + if ip.Script.WarmupTimeout != 300 { + t.Fatalf("expected default warmup timeout 300, got %d", ip.Script.WarmupTimeout) + } + if ip.Script.PostWarmupTimeout != 120 { + t.Fatalf("expected default post-warmup timeout 120, got %d", ip.Script.PostWarmupTimeout) + } + if ip.Script.WarmupFailurePolicy != warmupPolicyFail { + t.Fatalf("expected default warmup failure policy %q, got %q", warmupPolicyFail, ip.Script.WarmupFailurePolicy) + } +} + +func TestWarmupAttemptsOnlyRetriesWhenPolicyIsRetry(t *testing.T) { + cases := []struct { + name string + policy string + retries int + expected int + }{ + {name: "default", expected: 1}, + {name: "fail ignores retries", policy: warmupPolicyFail, retries: 3, expected: 1}, + {name: "continue ignores retries", policy: warmupPolicyContinue, retries: 3, expected: 1}, + {name: "retry adds retries", policy: warmupPolicyRetry, retries: 3, expected: 4}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ip := &InstallationProcess{Script: &remote.InstallationScript{ + WarmupFailurePolicy: tc.policy, + WarmupRetries: tc.retries, + }} + + if got := ip.warmupAttempts(); got != tc.expected { + t.Fatalf("expected %d attempts, got %d", tc.expected, got) + } + }) + } +} + +func TestWarmupMatchesStartupSignal(t *testing.T) { + s, err := New(nil) + if err != nil { + t.Fatal(err) + } + matcher, err := models.NewOutputLineMatcher("server online") + if err != nil { + t.Fatal(err) + } + s.SetProcessConfiguration(&models.ProcessConfiguration{}) + s.ProcessConfiguration().Startup.Done = []*models.OutputLineMatcher{matcher} + + ip := &InstallationProcess{Server: s, Script: &remote.InstallationScript{}} + + if !ip.matchesStartupSignal([]byte("prefix server online suffix")) { + t.Fatal("expected warmup startup signal to match") + } + if ip.matchesStartupSignal([]byte("still booting")) { + t.Fatal("did not expect non-matching line to match") + } +} diff --git a/daemon/sls.go b/daemon/sls.go old mode 100755 new mode 100644 diff --git a/daemon/system/const.go b/daemon/system/const.go old mode 100755 new mode 100644 index 225b2a0a..c25556fa --- a/daemon/system/const.go +++ b/daemon/system/const.go @@ -1,5 +1,5 @@ package system -var Version = "0.1.2" +var Version = "0.2.0" var Authors = "Jesse Faler & Contributors" diff --git a/daemon/system/locker.go b/daemon/system/locker.go old mode 100755 new mode 100644 diff --git a/daemon/system/rate.go b/daemon/system/rate.go old mode 100755 new mode 100644 diff --git a/daemon/system/sink_pool.go b/daemon/system/sink_pool.go old mode 100755 new mode 100644 diff --git a/daemon/system/sink_pool_test.go b/daemon/system/sink_pool_test.go old mode 100755 new mode 100644 diff --git a/daemon/system/utils.go b/daemon/system/utils.go old mode 100755 new mode 100644 diff --git a/protocube/ATTRIBUTION b/protocube/ATTRIBUTION old mode 100755 new mode 100644 diff --git a/protocube/api/router/middleware/middleware.go b/protocube/api/router/middleware/middleware.go old mode 100755 new mode 100644 diff --git a/protocube/api/router/middleware/ratelimit_test.sh b/protocube/api/router/middleware/ratelimit_test.sh old mode 100755 new mode 100644 diff --git a/protocube/api/router/middleware/request_error.go b/protocube/api/router/middleware/request_error.go old mode 100755 new mode 100644 diff --git a/protocube/api/router/router.go b/protocube/api/router/router.go index 496eb908..bd4590fd 100644 --- a/protocube/api/router/router.go +++ b/protocube/api/router/router.go @@ -58,8 +58,9 @@ func (r *Router) Configure() *gin.Engine { server.GET("/stats", getServerStats) server.POST("/commands", postServerCommands) server.POST("/reset", postServerReset) - server.GET("/install", r.getInstallationScript) - //server.POST("/reinstall", postServerReinstall) + server.GET("/install/logs", getServerInstallLogs) + server.GET("/install", getServerInstallInfo) + server.POST("/reinstall", postServerReinstall) //server.POST("/sync", postServerSync) //server.POST("/ws/deny", postServerDenyWSTokens) } diff --git a/protocube/api/router/router_node.go b/protocube/api/router/router_node.go index f8b6d957..eaa52834 100644 --- a/protocube/api/router/router_node.go +++ b/protocube/api/router/router_node.go @@ -201,17 +201,7 @@ func toggleNodeDrained(c *gin.Context) { func (r *Router) getServerConfiguration(c *gin.Context) { s := middleware.ExtractServer(c) - bp := r.BlueprintRegistry.Get(s.BlueprintId()) - if bp == nil { - httperror.JSON(c, http.StatusNotFound, "blueprint_id="+s.BlueprintId(), "Referenced blueprint does not exist.") - return - } - configuration, err := server.GetServerConfiguration(s, bp, r.SoftwareRegistry) - if err != nil { - httperror.JSON(c, http.StatusNotFound, err.Error(), "Could not build server configuration.") - return - } - c.JSON(http.StatusOK, configuration) + c.JSON(http.StatusOK, s.GetServerConfiguration()) } func (r *Router) getAllServerConfigurations(c *gin.Context) { @@ -236,25 +226,9 @@ func (r *Router) getAllServerConfigurations(c *gin.Context) { servers := r.ServerManager.ServersByNode(nodeId) // Build configurations for all servers - configurations := make([]*models.ServerConfigurationResponse, 0, len(servers)) + configurations := make([]*models.ServerConfiguration, 0, len(servers)) for _, s := range servers { - bp := r.BlueprintRegistry.Get(s.BlueprintId()) - if bp == nil { - // Skip servers with missing blueprints, log but don't fail the request - log.WithField("server", s.Id()).WithField("blueprint_id", s.BlueprintId()). - Warn("skipping server configuration: referenced blueprint does not exist") - continue - } - - configuration, err := server.GetServerConfiguration(s, bp, r.SoftwareRegistry) - if err != nil { - // Skip servers with configuration errors, log but don't fail the request - log.WithField("server", s.Id()).WithError(err). - Warn("skipping server configuration: failed to generate configuration") - continue - } - - configurations = append(configurations, configuration) + configurations = append(configurations, s.GetServerConfiguration()) } // Calculate pagination @@ -270,11 +244,11 @@ func (r *Router) getAllServerConfigurations(c *gin.Context) { } // Get the paged slice - var paged []*models.ServerConfigurationResponse + var paged []*models.ServerConfiguration if start < total { paged = configurations[start:end] } else { - paged = []*models.ServerConfigurationResponse{} + paged = []*models.ServerConfiguration{} } // Calculate pagination metadata diff --git a/protocube/api/router/router_server.go b/protocube/api/router/router_server.go index 3c1026ba..7cb8b7b0 100644 --- a/protocube/api/router/router_server.go +++ b/protocube/api/router/router_server.go @@ -12,14 +12,14 @@ import ( ) func postServerPower(c *gin.Context) { - server := middleware.ExtractServer(c) + s := middleware.ExtractServer(c) powerAction := models.PowerAction{} if err := c.BindJSON(&powerAction); err != nil { httperror.JSON(c, http.StatusBadRequest, err.Error(), "Failed to bind json") return } // Make the request - err := server.Power(c.Request.Context(), powerAction) + err := s.Power(c.Request.Context(), powerAction) // Handle any errors if err != nil { client.HandleError(c, err) @@ -30,18 +30,18 @@ func postServerPower(c *gin.Context) { } func getServer(c *gin.Context) { - server := middleware.ExtractServer(c) - c.JSON(http.StatusOK, server.ServerData()) + s := middleware.ExtractServer(c) + c.JSON(http.StatusOK, s.ServerData()) } func deleteServer(c *gin.Context) { - server := middleware.ExtractServer(c) - err := server.Delete(c.Request.Context()) + s := middleware.ExtractServer(c) + err := s.Delete(c.Request.Context()) if err != nil { // If force is enabled, ensure the server is cleaned up on Protocube // even if deletion from the daemon fails if c.Query("force") == "true" { - server.CleanupForDestroy() + s.CleanupForDestroy() c.Status(http.StatusOK) return } @@ -76,7 +76,7 @@ func getServerStats(c *gin.Context) { } func postServerCommands(c *gin.Context) { - server := middleware.ExtractServer(c) + s := middleware.ExtractServer(c) var data struct { Commands []string `json:"commands"` @@ -87,7 +87,7 @@ func postServerCommands(c *gin.Context) { } // Make the request - err := server.SendCommands(c.Request.Context(), data.Commands) + err := s.SendCommands(c.Request.Context(), data.Commands) // Handle any errors if err != nil { client.HandleError(c, err) @@ -98,10 +98,9 @@ func postServerCommands(c *gin.Context) { } func postServerReset(c *gin.Context) { - server := middleware.ExtractServer(c) - // Make the request - err := server.Reset(c.Request.Context()) - // Handle any errors + s := middleware.ExtractServer(c) + // Reset overlay filesystem + err := s.Reset(c.Request.Context()) if err != nil { client.HandleError(c, err) return @@ -110,43 +109,73 @@ func postServerReset(c *gin.Context) { } func getServerLogs(c *gin.Context) { - server := middleware.ExtractServer(c) + s := middleware.ExtractServer(c) - // Parse the size query parameter, defaulting to 100 - size, _ := strconv.Atoi(c.DefaultQuery("size", "100")) - if size <= 0 { - size = 100 - } else if size > 100 { - size = 100 - } + page, perPage := logPaginationParams(c) - // Make the request to the daemon - logs, err := server.GetLogs(c.Request.Context(), size) + logs, err := s.GetLogs(c.Request.Context(), page, perPage) if err != nil { client.HandleError(c, err) return } - // Return the logs response c.JSON(http.StatusOK, logs) } -func (r *Router) getInstallInfo(c *gin.Context) { - server := middleware.ExtractServer(c) +func logPaginationParams(c *gin.Context) (page, perPage int) { + page, _ = strconv.Atoi(c.DefaultQuery("page", "1")) + if page < 1 { + page = 1 + } + + perPageRaw := c.Query("per_page") + if perPageRaw == "" { + perPageRaw = c.DefaultQuery("size", c.DefaultQuery("lines", "100")) + } + perPage, _ = strconv.Atoi(perPageRaw) + if perPage <= 0 { + perPage = 100 + } + if perPage > 100 { + perPage = 100 + } + return page, perPage +} + +func getServerInstallInfo(c *gin.Context) { + s := middleware.ExtractServer(c) + info, err := s.InstallInfo(c.Request.Context()) + if err != nil { + client.HandleError(c, err) + return + } + c.JSON(http.StatusOK, info) +} + +func getServerInstallLogs(c *gin.Context) { + s := middleware.ExtractServer(c) + + page, perPage := logPaginationParams(c) - // Get the servers blueprint - bp := r.BlueprintRegistry.Get(server.BlueprintId()) - if bp == nil { - httperror.JSON(c, http.StatusNotFound, "blueprint not found for install info", "Blueprint not found") + logs, err := s.InstallLogs(c.Request.Context(), page, perPage) + if err != nil { + client.HandleError(c, err) return } - // Get the software used by the blueprint - sw := r.SoftwareRegistry.Get(bp.Server.Software) - if sw == nil { - httperror.JSON(c, http.StatusNotFound, "software not found for blueprint", "Software not found") + + c.JSON(http.StatusOK, logs) +} + +func postServerReinstall(c *gin.Context) { + s := middleware.ExtractServer(c) + if err := s.Reinstall(c.Request.Context()); err != nil { + client.HandleError(c, err) return } + c.Status(http.StatusAccepted) +} - // Return the installation script from the software configuration - c.JSON(http.StatusOK, sw.InstallScript) +func (r *Router) getInstallInfo(c *gin.Context) { + s := middleware.ExtractServer(c) + c.JSON(http.StatusOK, s.InstallScript) } diff --git a/protocube/api/router/router_system.go b/protocube/api/router/router_system.go index 026d1e3c..4f4dd9b6 100644 --- a/protocube/api/router/router_system.go +++ b/protocube/api/router/router_system.go @@ -74,18 +74,11 @@ func getBlueprint(c *gin.Context) { func (r *Router) getInstallationScript(c *gin.Context) { s := middleware.ExtractServer(c) - s.BlueprintId() - bp := r.BlueprintRegistry.Get(s.BlueprintId()) - if bp == nil { - httperror.AbortWithJSON(c, http.StatusNotFound, "unknown blueprint "+s.BlueprintId(), "Blueprint not found for this server.") - return - } - sw := r.SoftwareRegistry.Get(bp.Server.Software) - if sw == nil { - httperror.AbortWithJSON(c, http.StatusNotFound, "unknown software "+bp.Server.Software, "Software not found for this server's blueprint.") + if s.InstallScript == nil { + httperror.AbortWithJSON(c, http.StatusInternalServerError, "install script is missing", "Server install script is missing.") return } - c.JSON(http.StatusOK, sw.InstallScript) + c.JSON(http.StatusOK, s.InstallScript) } // Returns all servers diff --git a/protocube/api/router/websocket/websocket.go b/protocube/api/router/websocket/websocket.go old mode 100755 new mode 100644 diff --git a/protocube/blueprint/blueprint.go b/protocube/blueprint/blueprint.go old mode 100755 new mode 100644 diff --git a/protocube/blueprint/blueprint_test.go b/protocube/blueprint/blueprint_test.go old mode 100755 new mode 100644 diff --git a/protocube/blueprint/example.yml b/protocube/blueprint/example.yml old mode 100755 new mode 100644 diff --git a/protocube/blueprint/helpers.go b/protocube/blueprint/helpers.go new file mode 100644 index 00000000..d9121e88 --- /dev/null +++ b/protocube/blueprint/helpers.go @@ -0,0 +1,27 @@ +package blueprint + +import "maps" + +// MergeState merges the provided environment variables into the base states environment variables +// (env wins on key collision) +func MergeState(base *State, env map[string]string) *State { + if len(env) == 0 { + return base + } + out := &State{} + if base != nil { + out.Volumes = base.Volumes + out.Mounts = base.Mounts + out.Copy = base.Copy + if len(base.Env) > 0 { + out.Env = maps.Clone(base.Env) + } + } + for k, v := range env { + if out.Env == nil { + out.Env = make(map[string]string, len(env)) + } + out.Env[k] = v + } + return out +} diff --git a/protocube/blueprint/parser.go b/protocube/blueprint/parser.go old mode 100755 new mode 100644 diff --git a/protocube/blueprint/registry.go b/protocube/blueprint/registry.go old mode 100755 new mode 100644 diff --git a/protocube/client/errors.go b/protocube/client/errors.go old mode 100755 new mode 100644 diff --git a/protocube/client/node_client.go b/protocube/client/node_client.go index e5b5c74c..59b63e8b 100644 --- a/protocube/client/node_client.go +++ b/protocube/client/node_client.go @@ -17,7 +17,7 @@ type NodeClient interface { Delete(ctx context.Context, path string) (*Response, error) // Requests - CreateServer(context context.Context, request models.ServerConfigurationResponse) (models.CreateServerResponse, error) + CreateServer(context context.Context, request models.ServerConfiguration) (models.CreateServerResponse, error) GetSystemInformation(context context.Context) (models.Information, error) Sync(context context.Context) error } @@ -49,7 +49,7 @@ func (nc *nodeClient) GetToken() string { // CreateServer sends a request to create a new server on the remote node // and returns the server's data upon successful creation. -func (nc *nodeClient) CreateServer(ctx context.Context, create models.ServerConfigurationResponse) (models.CreateServerResponse, error) { +func (nc *nodeClient) CreateServer(ctx context.Context, create models.ServerConfiguration) (models.CreateServerResponse, error) { return Post[models.CreateServerResponse](nc, ctx, "/servers", create) } diff --git a/protocube/client/server_client.go b/protocube/client/server_client.go index 0152c876..19483ffd 100644 --- a/protocube/client/server_client.go +++ b/protocube/client/server_client.go @@ -22,7 +22,10 @@ type ServerClient interface { // Requests Power(ctx context.Context, action models.PowerAction) error Reset(ctx context.Context) error - Logs(ctx context.Context, size int) (gin.H, error) + Reinstall(ctx context.Context) error + Logs(ctx context.Context, page, perPage int) (gin.H, error) + InstallInfo(ctx context.Context) (models.InstallInfo, error) + InstallLogs(ctx context.Context, page, perPage int) (gin.H, error) Stats(ctx context.Context, update bool) (models.ResourceUsage, error) Status(ctx context.Context) (string, error) GetServer(ctx context.Context) (models.ServerData, error) @@ -71,6 +74,11 @@ func (sc *serverClient) Reset(ctx context.Context) error { return err } +func (sc *serverClient) Reinstall(ctx context.Context) error { + _, err := sc.Post(ctx, "/reinstall", nil) + return err +} + // Stats fetches resource stats from the remote node. func (sc *serverClient) Stats(ctx context.Context, update bool) (models.ResourceUsage, error) { if update { @@ -110,7 +118,20 @@ func (sc *serverClient) Commands(ctx context.Context, commands []string) error { } // Logs fetches server logs from the remote node. -func (sc *serverClient) Logs(ctx context.Context, size int) (gin.H, error) { - query := q{"size": strconv.Itoa(size)} - return Get[gin.H](sc, ctx, "/logs", query) +func (sc *serverClient) Logs(ctx context.Context, page, perPage int) (gin.H, error) { + return Get[gin.H](sc, ctx, "/logs", q{ + "page": strconv.Itoa(page), + "per_page": strconv.Itoa(perPage), + }) +} + +func (sc *serverClient) InstallInfo(ctx context.Context) (models.InstallInfo, error) { + return Get[models.InstallInfo](sc, ctx, "/install", nil) +} + +func (sc *serverClient) InstallLogs(ctx context.Context, page, perPage int) (gin.H, error) { + return Get[gin.H](sc, ctx, "/install/logs", q{ + "page": strconv.Itoa(page), + "per_page": strconv.Itoa(perPage), + }) } diff --git a/protocube/cmd/command.go b/protocube/cmd/command.go old mode 100755 new mode 100644 diff --git a/protocube/cmd/root.go b/protocube/cmd/root.go old mode 100755 new mode 100644 index 78fd5ebf..850fe111 --- a/protocube/cmd/root.go +++ b/protocube/cmd/root.go @@ -1,8 +1,8 @@ package cmd import ( - log2 "log" "context" + log2 "log" "os" "os/signal" "syscall" @@ -48,15 +48,18 @@ func run(cmd *cobra.Command, _ []string) { // Create the node manager nodeManager := node.NewManager(remoteClient, loadBalancer) // Create the server manager - serverManager, err := server.NewManager(cmd.Context(), nodeManager) + serverManager, err := server.NewManager(nodeManager) if err != nil { log.Fatal(err.Error()) } - // Wire up node connection callback to attach node clients to servers + // Wire up node connection callbacks to attach/detach node clients on servers nodeManager.SetOnNodeRegistered(func(nodeId string, node *node.Node) { serverManager.AttachNodeClientToServers(nodeId, node) }) + nodeManager.SetOnNodeDisconnected(func(nodeId string) { + serverManager.DetachNodeClientFromServers(nodeId) + }) // Initialize the api key service keyService := auth.NewKeyService() diff --git a/protocube/config/config.go b/protocube/config/config.go old mode 100755 new mode 100644 diff --git a/protocube/config/config.yml b/protocube/config/config.yml old mode 100755 new mode 100644 diff --git a/protocube/config/config_test.go b/protocube/config/config_test.go old mode 100755 new mode 100644 diff --git a/protocube/docker-compose.yml b/protocube/docker-compose.yml index 69470505..2403e174 100644 --- a/protocube/docker-compose.yml +++ b/protocube/docker-compose.yml @@ -1,6 +1,11 @@ services: protocube: image: ghcr.io/jessefaler/sls/protocube:latest + build: + context: . + dockerfile: Dockerfile + args: + VERSION: v0.1.2 container_name: SLS restart: always ports: @@ -23,4 +28,4 @@ networks: default: ipam: config: - - subnet: 172.0.0.0/16 \ No newline at end of file + - subnet: 172.0.0.0/16 diff --git a/protocube/environment/helpers.go b/protocube/environment/helpers.go new file mode 100644 index 00000000..9013ffa4 --- /dev/null +++ b/protocube/environment/helpers.go @@ -0,0 +1,92 @@ +package environment + +import ( + "emperror.dev/errors" + "github.com/creasty/defaults" +) + +func CopyLimits(orig *Limits) *Limits { + if orig == nil { + return nil + } + + c := &Limits{} + + if orig.MemoryLimit != nil { + val := *orig.MemoryLimit + c.MemoryLimit = &val + } + if orig.Swap != nil { + val := *orig.Swap + c.Swap = &val + } + if orig.IoWeight != nil { + val := *orig.IoWeight + c.IoWeight = &val + } + if orig.CpuLimit != nil { + val := *orig.CpuLimit + c.CpuLimit = &val + } + if orig.DiskSpace != nil { + val := *orig.DiskSpace + c.DiskSpace = &val + } + if orig.Threads != nil { + val := *orig.Threads + c.Threads = &val + } + if orig.OOMDisabled != nil { + val := *orig.OOMDisabled + c.OOMDisabled = &val + } + + return c +} + +// MergeLimits merges override into base. Non-nil fields in override replace base. +// If base is nil, it starts as an empty Limits. If override is nil, base is returned unchanged. +func MergeLimits(base *Limits, override *Limits) *Limits { + if override == nil { + return base + } + if base == nil { + base = &Limits{} + } + if override.MemoryLimit != nil { + base.MemoryLimit = override.MemoryLimit + } + if override.Swap != nil { + base.Swap = override.Swap + } + if override.IoWeight != nil { + base.IoWeight = override.IoWeight + } + if override.CpuLimit != nil { + base.CpuLimit = override.CpuLimit + } + if override.DiskSpace != nil { + base.DiskSpace = override.DiskSpace + } + if override.Threads != nil { + base.Threads = override.Threads + } + if override.OOMDisabled != nil { + base.OOMDisabled = override.OOMDisabled + } + + return base +} + +// ValidateLimits validates limits and sets defaults for nil fields. +func ValidateLimits(limit *Limits) error { + if err := defaults.Set(limit); err != nil { + return err + } + + if limit.IoWeight != nil && *limit.IoWeight != 0 && (*limit.IoWeight < 10 || *limit.IoWeight > 1000) { + return errors.New("io_weight must be 0 or between 10 and 1000") + } + + return nil +} diff --git a/protocube/environment/settings.go b/protocube/environment/settings.go index eb99166b..92e2eab0 100644 --- a/protocube/environment/settings.go +++ b/protocube/environment/settings.go @@ -1,10 +1,5 @@ package environment -import ( - "emperror.dev/errors" - "github.com/creasty/defaults" -) - // Limits is the build settings for a given server that impact docker container // creation and resource limits for a server instance. type Limits struct { @@ -33,89 +28,3 @@ type Limits struct { // If true, disables the OOM killer for this container. OOMDisabled *bool `yaml:"oom_disabled" json:"oom_disabled" default:"true"` } - -func CopyLimits(orig *Limits) *Limits { - if orig == nil { - return nil - } - - copy := &Limits{} - - if orig.MemoryLimit != nil { - val := *orig.MemoryLimit - copy.MemoryLimit = &val - } - if orig.Swap != nil { - val := *orig.Swap - copy.Swap = &val - } - if orig.IoWeight != nil { - val := *orig.IoWeight - copy.IoWeight = &val - } - if orig.CpuLimit != nil { - val := *orig.CpuLimit - copy.CpuLimit = &val - } - if orig.DiskSpace != nil { - val := *orig.DiskSpace - copy.DiskSpace = &val - } - if orig.Threads != nil { - val := *orig.Threads - copy.Threads = &val - } - if orig.OOMDisabled != nil { - val := *orig.OOMDisabled - copy.OOMDisabled = &val - } - - return copy -} - -// MergeLimits merges override into base. Non-nil fields in override replace base. -// If base is nil, it starts as an empty Limits. If override is nil, base is returned unchanged. -func MergeLimits(base *Limits, override *Limits) *Limits { - if override == nil { - return base - } - if base == nil { - base = &Limits{} - } - if override.MemoryLimit != nil { - base.MemoryLimit = override.MemoryLimit - } - if override.Swap != nil { - base.Swap = override.Swap - } - if override.IoWeight != nil { - base.IoWeight = override.IoWeight - } - if override.CpuLimit != nil { - base.CpuLimit = override.CpuLimit - } - if override.DiskSpace != nil { - base.DiskSpace = override.DiskSpace - } - if override.Threads != nil { - base.Threads = override.Threads - } - if override.OOMDisabled != nil { - base.OOMDisabled = override.OOMDisabled - } - - return base -} - -// ValidateLimits validates limits and sets defaults for nil fields. -func ValidateLimits(limit *Limits) error { - if err := defaults.Set(limit); err != nil { - return err - } - - if limit.IoWeight != nil && (*limit.IoWeight < 10 || *limit.IoWeight > 1000) { - return errors.New("io_weight must be between 10 and 1000") - } - - return nil -} diff --git a/protocube/environment/stats.go b/protocube/environment/stats.go old mode 100755 new mode 100644 diff --git a/protocube/events/events.go b/protocube/events/events.go old mode 100755 new mode 100644 diff --git a/protocube/events/events_test.go b/protocube/events/events_test.go old mode 100755 new mode 100644 diff --git a/protocube/go.mod b/protocube/go.mod index 69e1a1cf..e68a4824 100644 --- a/protocube/go.mod +++ b/protocube/go.mod @@ -19,7 +19,7 @@ require ( github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 github.com/grokify/coreforge v0.6.0 - github.com/mattn/go-colorable v0.1.14 + github.com/mattn/go-colorable v0.1.15 github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db github.com/pkg/errors v0.9.1 github.com/spf13/cobra v1.10.2 diff --git a/protocube/go.sum b/protocube/go.sum index 14361113..896c04e8 100644 --- a/protocube/go.sum +++ b/protocube/go.sum @@ -108,8 +108,8 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ= github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= -github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= -github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= +github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs= diff --git a/protocube/internal/database/database.go b/protocube/internal/database/database.go index 1d089984..5b294d2f 100644 --- a/protocube/internal/database/database.go +++ b/protocube/internal/database/database.go @@ -65,7 +65,7 @@ func Initialize() error { } func migrations() error { - err := Instance().AutoMigrate(&models.ServerStore{}) + err := Instance().AutoMigrate(&models.ServerRecord{}) if err != nil { return errors.Wrap(err, "database: failed to auto migrate server data") } diff --git a/protocube/internal/message/banner.go b/protocube/internal/message/banner.go old mode 100755 new mode 100644 diff --git a/protocube/internal/message/banner_test.go b/protocube/internal/message/banner_test.go old mode 100755 new mode 100644 diff --git a/protocube/log/cli/cli.go b/protocube/log/cli/cli.go old mode 100755 new mode 100644 diff --git a/protocube/log/log.go b/protocube/log/log.go old mode 100755 new mode 100644 diff --git a/protocube/models/server.go b/protocube/models/server.go index 59d3cc7e..3a6c700b 100644 --- a/protocube/models/server.go +++ b/protocube/models/server.go @@ -1,8 +1,12 @@ package models import ( + "errors" + "time" + "protoxon.com/sls/protocube/blueprint" "protoxon.com/sls/protocube/environment" + "protoxon.com/sls/protocube/software" "protoxon.com/sls/protocube/system" ) @@ -11,29 +15,77 @@ type PowerAction struct { WaitSeconds int `json:"wait_seconds"` } +// ServerData is the public API representation of a managed server. type ServerData struct { - Id string `json:"id"` - BlueprintId string `json:"blueprint_id"` - NodeName string `json:"node_name"` - NodeId string `json:"node_id"` - Allocations environment.Allocations `json:"allocations"` - // Overrides that were set when the server was created (nil if none). - Overrides *ServerOverrides `json:"overrides,omitempty"` + Id string `json:"id"` + BlueprintId string `json:"blueprint_id"` + NodeName string `json:"node_name"` + NodeId string `json:"node_id"` + Allocations *environment.Allocations `json:"allocations"` + Overrides *ServerOverrides `json:"overrides,omitempty"` + SoftwareId string `json:"software_id"` + SoftwareVersion string `json:"software_version"` + Image string `json:"image"` + Limits *environment.Limits `json:"limits"` +} + +// ServerRecord is the database representation of a server. +type ServerRecord struct { + Id string `gorm:"primaryKey"` + NodeName string `gorm:"index"` + NodeId string `gorm:"index"` + BlueprintId string `gorm:"index"` + Overrides *ServerOverrides `gorm:"serializer:json"` + Configuration *ServerConfiguration `gorm:"serializer:json"` + InstallScript *software.InstallationScript `gorm:"serializer:json"` +} + +// Validate reports whether the server record has the required fields. +func (r *ServerRecord) Validate() error { + if r == nil { + return errors.New("server record is nil") + } + if r.Configuration == nil { + return errors.New("server configuration is missing") + } + if r.InstallScript == nil { + return errors.New("server install script is missing") + } + return nil } -type ServerStore struct { - Id string `gorm:"primaryKey"` - NodeName string `gorm:"index"` - NodeId string `gorm:"index"` - BlueprintId string `gorm:"index"` - Allocation environment.Allocations `gorm:"serializer:json"` - Overrides *ServerOverrides `gorm:"serializer:json"` +// ServerConfiguration is the servers runtime configuration information +type ServerConfiguration struct { + Id string `json:"id"` + ProcessConfiguration *ProcessConfiguration `json:"process-configuration"` + Image string `json:"image"` + Invocation string `json:"invocation"` + State *blueprint.State `json:"state"` + Limits *environment.Limits `json:"limits"` + Save bool `json:"save"` + Allocations environment.Allocations `json:"allocations"` + SoftwareId string `json:"software-id"` + ServerFolder string `json:"server-folder"` + SoftwareVersion string `json:"software-version"` + HasInstallScript bool `json:"has-install-script"` + SkipInstallScript bool `json:"skip-install-script"` } type StatusResponse struct { Status string `json:"status"` } +type InstallInfo struct { + Phase string `json:"phase"` + ContainerID string `json:"container_id,omitempty"` + ContainerName string `json:"container_name,omitempty"` + Status string `json:"status,omitempty"` + ExitCode *int64 `json:"exit_code,omitempty"` + StartedAt *time.Time `json:"started_at,omitempty"` + FinishedAt *time.Time `json:"finished_at,omitempty"` + FailureReason string `json:"failure_reason,omitempty"` +} + // ResourceUsage defines the current resource usage for a given server instance. If a server is offline you // should obviously expect memory and CPU usage to be 0. However, disk will always be returned // since that is not dependent on the server being running to collect that data. @@ -56,35 +108,6 @@ type ResourceUsage struct { Overlay int64 `json:"overlay_bytes"` } -// ContentItem is the wire format for content to copy into a server (replaces blueprint.Content). -type ContentItem struct { - Name string `json:"name"` - Source string `json:"source"` -} - -// MountConfig is the wire format for a bind mount (source/target match daemon environment.Mount). -type MountConfig struct { - Source string `json:"source"` - Target string `json:"target"` - ReadOnly bool `json:"read_only"` -} - -type ServerConfigurationResponse struct { - Id string `json:"id"` - ProcessConfiguration *ProcessConfiguration `json:"process-configuration"` - Image string `json:"image"` - Invocation string `json:"invocation"` - State *blueprint.State `json:"state"` - Limits *environment.Limits `json:"limits"` - Save bool `json:"save"` - Allocations environment.Allocations `json:"allocations"` - SoftwareId string `json:"software-id"` - ServerFolder string `json:"server-folder"` - SoftwareVersion string `json:"software-version"` - HasInstallScript bool `json:"has-install-script"` - SkipInstallScript bool `json:"skip-install-script"` -} - type CreateServerRequest struct { BlueprintID string `json:"blueprint_id"` NodeId string `json:"node_id,omitempty"` @@ -92,10 +115,12 @@ type CreateServerRequest struct { } type ServerOverrides struct { - Save *bool `json:"save,omitempty"` + // Save overrides the save flag + Save *bool `json:"save,omitempty"` + // Resource limit overrides Limits *environment.Limits `json:"limits,omitempty"` // Configs are configuration file patches applied after software and blueprint - // patches. Same file/key is overridden; new keys are merged. + // patches. Same file/key is overridden and new keys are merged. Configs map[string]blueprint.ConfigFile `json:"configs,omitempty"` // Software overrides the blueprint's server.software (registry lookup and path). Software *string `json:"software,omitempty"` diff --git a/protocube/node/allocator/allocator.go b/protocube/node/allocator/allocator.go index d12cffac..36ead709 100644 --- a/protocube/node/allocator/allocator.go +++ b/protocube/node/allocator/allocator.go @@ -19,7 +19,7 @@ var ( // Allocator manages IP and port assignments for servers. // -// Allocations are persisted in the database as part of server records. +// Allocations are persisted in the database as part of server configuration. // When a node connects, its allocator is initialized and existing allocations // are restored by claiming ports for each server on that node. type Allocator struct { diff --git a/protocube/node/manager.go b/protocube/node/manager.go index f33ffee3..2a903018 100644 --- a/protocube/node/manager.go +++ b/protocube/node/manager.go @@ -20,6 +20,9 @@ type Manager struct { // onNodeRegistered is called when a node is registered. // The callback receives the node ID and the node client. onNodeRegistered func(nodeId string, node *Node) + + // onNodeDisconnected is called when a node disconnects. + onNodeDisconnected func(nodeId string) } // NewManager returns a new server manager instance. @@ -56,6 +59,13 @@ func (m *Manager) SetOnNodeRegistered(callback func(nodeId string, node *Node)) m.onNodeRegistered = callback } +// SetOnNodeDisconnected sets a callback that will be invoked when a node disconnects. +func (m *Manager) SetOnNodeDisconnected(callback func(nodeId string)) { + m.mutex.Lock() + defer m.mutex.Unlock() + m.onNodeDisconnected = callback +} + // TriggerNodeRegistered invokes the onNodeRegistered callback if it's set. // This is used to re-attach node clients to existing servers when a node reconnects. func (m *Manager) TriggerNodeRegistered(nodeId string, node *Node) { @@ -111,6 +121,14 @@ func (m *Manager) Register(ctx context.Context, id string, name string, url stri func (m *Manager) Disconnect(node *Node) { node.Online = false m.lb.Get().RemoveNode(node) + + m.mutex.RLock() + callback := m.onNodeDisconnected + m.mutex.RUnlock() + if callback != nil { + callback(node.Id()) + } + m.remove(node.Id()) } diff --git a/protocube/node/node.go b/protocube/node/node.go index 50fc921e..c0982522 100644 --- a/protocube/node/node.go +++ b/protocube/node/node.go @@ -72,7 +72,7 @@ func (n *Node) Id() string { // and returns its data upon success. // This only sends the request; it does not configure the server locally. // Typically, servers should be created through the server manager. -func (n *Node) CreateServer(ctx context.Context, request models.ServerConfigurationResponse) (models.CreateServerResponse, error) { +func (n *Node) CreateServer(ctx context.Context, request models.ServerConfiguration) (models.CreateServerResponse, error) { return n.nc.CreateServer(ctx, request) } diff --git a/protocube/parser/parser.go b/protocube/parser/parser.go old mode 100755 new mode 100644 index 67166e7a..99049c1e --- a/protocube/parser/parser.go +++ b/protocube/parser/parser.go @@ -16,6 +16,14 @@ type ReplaceValue struct { // NewReplaceValue creates a new ReplaceValue from an interface{} value. // It marshals the value to JSON and determines its type by inspecting the JSON bytes. func NewReplaceValue(value interface{}) (*ReplaceValue, error) { + // Strings are stored unquoted to match jsonparser.Get and config file patching. + if s, ok := value.(string); ok { + return &ReplaceValue{ + value: []byte(s), + valueType: jsonparser.String, + }, nil + } + // Marshal the value to JSON to get the proper representation jsonBytes, err := json.Marshal(value) if err != nil { @@ -113,9 +121,11 @@ func (cv *ReplaceValue) Bytes() []byte { } // MarshalJSON returns the raw JSON bytes for the value. -// This allows the value to be properly serialized when sending to the daemon, -// matching how Pterodactyl Panel sends configuration values. func (cv *ReplaceValue) MarshalJSON() ([]byte, error) { + // Re-quote strings when writing JSON; unquoted bytes are not valid JSON tokens. + if cv.valueType == jsonparser.String { + return json.Marshal(string(cv.value)) + } return cv.value, nil } diff --git a/protocube/protocube.go b/protocube/protocube.go old mode 100755 new mode 100644 diff --git a/protocube/run.sh b/protocube/run.sh old mode 100755 new mode 100644 diff --git a/protocube/server/configuration.go b/protocube/server/configuration.go new file mode 100644 index 00000000..27f08903 --- /dev/null +++ b/protocube/server/configuration.go @@ -0,0 +1,119 @@ +package server + +import ( + "path/filepath" + + "emperror.dev/errors" + "github.com/apex/log" + "protoxon.com/sls/protocube/blueprint" + "protoxon.com/sls/protocube/environment" + "protoxon.com/sls/protocube/models" + "protoxon.com/sls/protocube/software" +) + +// BuildServerConfiguration constructs a servers runtime configuration and installation script +func BuildServerConfiguration(s *Server, bp *blueprint.Blueprint, swr *software.Registry, alloc environment.Allocations) (*models.ServerConfiguration, *software.InstallationScript, error) { + effectiveSoftware := bp.Server.Software + effectiveVersion := bp.Server.Version + if s.Overrides != nil { + if s.Overrides.Software != nil { + effectiveSoftware = *s.Overrides.Software + } + if s.Overrides.Version != nil { + effectiveVersion = *s.Overrides.Version + } + } + + sw := swr.Get(effectiveSoftware) + if sw == nil { + return nil, nil, errors.Errorf("software not found: %s", effectiveSoftware) + } + + matcher, err := models.NewOutputLineMatcher(sw.OnlineSignal) + if err != nil { + return nil, nil, errors.Wrapf(err, "failed to create output line matcher for the start configuration: %s", sw.OnlineSignal) + } + + // Convert software, blueprint, and optional request override patches + // to config file patches (overrides merge/override when applied last). + var overrideConfigs map[string]blueprint.ConfigFile + if s.Overrides != nil && s.Overrides.Configs != nil { + overrideConfigs = s.Overrides.Configs + } + configFiles, cfgErr := GetConfigFiles(sw, bp, overrideConfigs) + + // log any errors that occurred when converting configuration patches + if cfgErr != nil { + log.WithError(cfgErr).Warn("an error occurred while converting config patches for server " + s.Id()) + } + + pc := &models.ProcessConfiguration{ + Startup: struct { + Done []*models.OutputLineMatcher `json:"done"` + StripAnsi bool `json:"strip_ansi"` + }{ + Done: []*models.OutputLineMatcher{matcher}, + StripAnsi: false, + }, + Stop: models.ProcessStopConfiguration{ + Type: "command", + Value: sw.StopCommand, + }, + ConfigurationFiles: configFiles, + } + + // Handle Overrides + save := bp.Save + // We need to make a copy of the limits so we don't mutate the blueprints default limits + limits := environment.CopyLimits(bp.Server.Limits) + if s.Overrides != nil { + if s.Overrides.Save != nil { + save = *s.Overrides.Save + } + limits = environment.MergeLimits(limits, s.Overrides.Limits) + } + + serverFolder := bp.Server.Path + if s.Overrides != nil && (s.Overrides.Software != nil || s.Overrides.Version != nil) { + serverFolder = filepath.Join(effectiveSoftware, effectiveVersion) + } + + image := bp.Server.Image + if s.Overrides != nil && s.Overrides.Image != nil { + image = *s.Overrides.Image + } + // If no explicit image is set on the blueprint or via overrides, + // fall back to the software's image mappings selection. + if image == "" { + selectedImage, err := sw.ImageForVersion(effectiveVersion) + if err != nil { + return nil, nil, errors.Wrapf(err, "failed to select image for version %s", effectiveVersion) + } + image = selectedImage + } + + var env map[string]string + if s.Overrides != nil { + env = s.Overrides.Env + } + effectiveState := blueprint.MergeState(bp.State, env) + + cfg := models.ServerConfiguration{ + Id: s.Id(), + ProcessConfiguration: pc, + Image: image, + Invocation: sw.Invocation, + Limits: limits, + State: effectiveState, + ServerFolder: serverFolder, + Allocations: alloc, + Save: save, + SoftwareId: sw.Id, + SoftwareVersion: effectiveVersion, + HasInstallScript: sw.InstallScript.Script != "", + SkipInstallScript: sw.InstallScript.SkipScripts, + } + + installScript := sw.InstallScript + return &cfg, &installScript, nil +} diff --git a/protocube/server/crash.go b/protocube/server/crash.go old mode 100755 new mode 100644 diff --git a/protocube/server/manager.go b/protocube/server/manager.go index 0b1368e6..1b52dac8 100644 --- a/protocube/server/manager.go +++ b/protocube/server/manager.go @@ -2,8 +2,6 @@ package server import ( "context" - "maps" - "path/filepath" "runtime" "sync" "time" @@ -13,7 +11,6 @@ import ( "github.com/gammazero/workerpool" "protoxon.com/sls/protocube/blueprint" "protoxon.com/sls/protocube/client" - "protoxon.com/sls/protocube/environment" "protoxon.com/sls/protocube/events" "protoxon.com/sls/protocube/models" "protoxon.com/sls/protocube/node" @@ -34,12 +31,12 @@ type Manager struct { } // NewManager returns a new server manager instance. -func NewManager(ctx context.Context, nm *node.Manager) (*Manager, error) { +func NewManager(nm *node.Manager) (*Manager, error) { m := &Manager{ servers: make(map[string]*Server), emitter: events.NewBus(), } - if err := m.init(ctx, nm); err != nil { + if err := m.init(nm); err != nil { return nil, err } return m, nil @@ -115,11 +112,26 @@ func (m *Manager) AttachNodeClientToServers(nodeId string, node *node.Node) { server.sc.SetNodeClient(node.Client()) // Update the servers node name in case it changed server.nodeName = node.Name() - // Also claim the servers allocation in the allocator - node.Allocator.Claim(server.Allocations.DefaultMapping.Ip, server.Allocations.DefaultMapping.Port) - // Set the release function in the allocation - server.Allocations.Release = func() { - node.Allocator.Release(server.Allocations.DefaultMapping.Ip, server.Allocations.DefaultMapping.Port) + if a := server.allocations(); a != nil { + // Also claim the servers allocation in the allocator + node.Allocator.Claim(a.DefaultMapping.Ip, a.DefaultMapping.Port) + // Set the release function in the allocation + a.Release = func() { + node.Allocator.Release(a.DefaultMapping.Ip, a.DefaultMapping.Port) + } + } + } +} + +// DetachNodeClientFromServers clears the node client on all servers that belong to the +// specified node. This is called when a node disconnects so server operations fail +// fast with ErrNodeUnavailable instead of retrying against a dead endpoint. +func (m *Manager) DetachNodeClientFromServers(nodeId string) { + servers := m.ServersByNode(nodeId) + for _, server := range servers { + server.sc.SetNodeClient(nil) + if a := server.allocations(); a != nil { + a.Release = nil } } } @@ -149,7 +161,6 @@ func (m *Manager) CreateServer(ctx context.Context, node *node.Node, bp *bluepri Remove: func() { m.Remove(serverId) }, - Allocations: alloc, } // This will remove the server in the event any of the following steps fail @@ -165,26 +176,29 @@ func (m *Manager) CreateServer(ctx context.Context, node *node.Node, bp *bluepri } }() + cfg, installScript, err := BuildServerConfiguration(s, bp, swr, alloc) + if err != nil { + return nil, err + } + s.Configuration = cfg + s.InstallScript = installScript + // Add the server to the manager m.Add(s) // Write the server to the database - if err := repository.StoreServer(&models.ServerStore{ - Id: serverId, - NodeName: node.Name(), - NodeId: node.Id(), - BlueprintId: bp.Meta.ID, - Allocation: alloc, - Overrides: overrides, + if err := repository.StoreServer(&models.ServerRecord{ + Id: serverId, + NodeName: node.Name(), + NodeId: node.Id(), + BlueprintId: bp.Meta.ID, + Overrides: overrides, + Configuration: cfg, + InstallScript: installScript, }); err != nil { return nil, err } - cfg, err := GetServerConfiguration(s, bp, swr) - if err != nil { - return nil, err - } - // Request server creation on the remote node _, err = node.CreateServer(ctx, *cfg) if err != nil { @@ -196,133 +210,8 @@ func (m *Manager) CreateServer(ctx context.Context, node *node.Node, bp *bluepri return s, nil } -func GetServerConfiguration(s *Server, bp *blueprint.Blueprint, swr *software.Registry) (*models.ServerConfigurationResponse, error) { - effectiveSoftware := bp.Server.Software - effectiveVersion := bp.Server.Version - if s.Overrides != nil { - if s.Overrides.Software != nil { - effectiveSoftware = *s.Overrides.Software - } - if s.Overrides.Version != nil { - effectiveVersion = *s.Overrides.Version - } - } - - sw := swr.Get(effectiveSoftware) - if sw == nil { - return nil, errors.Errorf("software not found: %s", effectiveSoftware) - } - - matcher, err := models.NewOutputLineMatcher(sw.OnlineSignal) - if err != nil { - return nil, errors.Wrapf(err, "failed to create output line matcher for the start configuration: %s", sw.OnlineSignal) - } - - // Convert software, blueprint, and optional request override patches - // to config file patches (overrides merge/override when applied last). - var overrideConfigs map[string]blueprint.ConfigFile - if s.Overrides != nil && s.Overrides.Configs != nil { - overrideConfigs = s.Overrides.Configs - } - configFiles, cfgErr := GetConfigFiles(sw, bp, overrideConfigs) - - // log any errors that occurred when converting configuration patches - if cfgErr != nil { - log.WithError(cfgErr).Warn("an error occurred while converting config patches for server " + s.Id()) - } - - pc := &models.ProcessConfiguration{ - Startup: struct { - Done []*models.OutputLineMatcher `json:"done"` - StripAnsi bool `json:"strip_ansi"` - }{ - Done: []*models.OutputLineMatcher{matcher}, - StripAnsi: false, - }, - Stop: models.ProcessStopConfiguration{ - Type: "command", - Value: sw.StopCommand, - }, - ConfigurationFiles: configFiles, - } - - // Handle Overrides - save := bp.Save - // We need to make a copy of the limits so we don't mutate the blueprints default limits - limits := environment.CopyLimits(bp.Server.Limits) - if s.Overrides != nil { - if s.Overrides.Save != nil { - save = *s.Overrides.Save - } - limits = environment.MergeLimits(limits, s.Overrides.Limits) - } - - serverFolder := bp.Server.Path - if s.Overrides != nil && (s.Overrides.Software != nil || s.Overrides.Version != nil) { - serverFolder = filepath.Join(effectiveSoftware, effectiveVersion) - } - - image := bp.Server.Image - if s.Overrides != nil && s.Overrides.Image != nil { - image = *s.Overrides.Image - } - // If no explicit image is set on the blueprint or via overrides, - // fall back to the software's image mappings selection. - if image == "" { - selectedImage, err := sw.ImageForVersion(effectiveVersion) - if err != nil { - return nil, errors.Wrapf(err, "failed to select image for version %s", effectiveVersion) - } - image = selectedImage - } - - var effectiveState *blueprint.State - if s.Overrides != nil && len(s.Overrides.Env) > 0 { - effectiveState = mergeBlueprintState(bp.State, s.Overrides.Env) - } else { - effectiveState = bp.State - } - - nodeReq := models.ServerConfigurationResponse{ - Id: s.Id(), - ProcessConfiguration: pc, - Image: image, - Invocation: sw.Invocation, - Limits: limits, - State: effectiveState, - ServerFolder: serverFolder, - Allocations: s.Allocations, - Save: save, - SoftwareId: sw.Id, - SoftwareVersion: bp.Server.Version, - HasInstallScript: sw.InstallScript.Script != "", - SkipInstallScript: sw.InstallScript.SkipScripts, - } - return &nodeReq, nil -} - -// mergeBlueprintState returns a new State with blueprint env merged with envOverride (override wins on key collision). -func mergeBlueprintState(bpState *blueprint.State, envOverride map[string]string) *blueprint.State { - out := &blueprint.State{} - if bpState != nil { - out.Volumes = bpState.Volumes - out.Mounts = bpState.Mounts - out.Copy = bpState.Copy - if len(bpState.Env) > 0 { - out.Env = maps.Clone(bpState.Env) - } - } - for k, v := range envOverride { - if out.Env == nil { - out.Env = make(map[string]string, len(envOverride)) - } - out.Env[k] = v - } - return out -} - // Loads in all servers stored in the database -func (m *Manager) init(ctx context.Context, nm *node.Manager) error { +func (m *Manager) init(nm *node.Manager) error { servers, err := repository.GetAllServers() if err != nil { return errors.WrapIf(err, "failed to load servers from database") @@ -337,9 +226,9 @@ func (m *Manager) init(ctx context.Context, nm *node.Manager) error { data := data n, _ := nm.Get(data.NodeId) pool.Submit(func() { - s, err := m.InitServer(ctx, data, n) + s, err := m.InitServer(data, n) if err != nil { - log.WithField("server", data.Id).WithField("error", err).Error("failed to load server, skipping...") + log.WithField("server", data.Id).WithField("node_id", data.NodeId).WithField("error", err).Error("failed to load server, skipping...") return } m.Add(s) @@ -356,45 +245,49 @@ func (m *Manager) init(ctx context.Context, nm *node.Manager) error { return nil } -func (m *Manager) InitServer(ctx context.Context, data *models.ServerStore, n *node.Node) (*Server, error) { +func (m *Manager) InitServer(s *models.ServerRecord, n *node.Node) (*Server, error) { + if err := s.Validate(); err != nil { + return nil, errors.Wrap(err, "invalid server record") + } + // Create the server client. // The node client is likely nil at startup because servers are loaded // during program boot, before any nodes have connected. The node client will // be attached later when a node becomes available. - serverClient := client.NewServerClient(data.Id, data.NodeId) + serverClient := client.NewServerClient(s.Id, s.NodeId) if n != nil { // Set the node client if the node has already connected serverClient.SetNodeClient(n.Client()) // Update the servers node name in case it changed - data.NodeName = n.Name() - // Set the release function in the allocation - data.Allocation.Release = func() { - n.Allocator.Release(data.Allocation.DefaultMapping.Ip, data.Allocation.DefaultMapping.Port) + s.NodeName = n.Name() + a := &s.Configuration.Allocations + a.Release = func() { + n.Allocator.Release(a.DefaultMapping.Ip, a.DefaultMapping.Port) } } // Instantiate the server server := &Server{ - id: data.Id, - nodeName: data.NodeName, - nodeId: data.NodeId, - blueprintId: data.BlueprintId, - Overrides: data.Overrides, - sc: serverClient, - GlobalEvents: m.Events, - Allocations: data.Allocation, + id: s.Id, + nodeName: s.NodeName, + nodeId: s.NodeId, + blueprintId: s.BlueprintId, + Overrides: s.Overrides, + Configuration: s.Configuration, + InstallScript: s.InstallScript, + sc: serverClient, + GlobalEvents: m.Events, Remove: func() { - m.Remove(data.Id) + m.Remove(s.Id) }, } if n != nil { - // claim the servers allocation - n.Allocator.Claim(server.Allocations.DefaultMapping.Ip, server.Allocations.DefaultMapping.Port) + if a := server.allocations(); a != nil { + // claim the servers allocation + n.Allocator.Claim(a.DefaultMapping.Ip, a.DefaultMapping.Port) + } } - // Add the server to the manager - m.Add(server) return server, nil } - diff --git a/protocube/server/repository/repository.go b/protocube/server/repository/repository.go index ab1305e9..d21698d4 100644 --- a/protocube/server/repository/repository.go +++ b/protocube/server/repository/repository.go @@ -6,7 +6,7 @@ import ( "protoxon.com/sls/protocube/models" ) -func StoreServer(server *models.ServerStore) error { +func StoreServer(server *models.ServerRecord) error { if err := database.Instance().Create(server).Error; err != nil { return errors.Wrap(err, "failed to save server to database") } @@ -14,15 +14,15 @@ func StoreServer(server *models.ServerStore) error { } func RemoveServer(id string) error { - if err := database.Instance().Where("id = ?", id).Delete(&models.ServerStore{}).Error; err != nil { + if err := database.Instance().Where("id = ?", id).Delete(&models.ServerRecord{}).Error; err != nil { return errors.Wrap(err, "failed to remove server from database") } return nil } // GetAllServers returns all servers stored in the database -func GetAllServers() ([]*models.ServerStore, error) { - servers := make([]*models.ServerStore, 0) +func GetAllServers() ([]*models.ServerRecord, error) { + servers := make([]*models.ServerRecord, 0) if err := database.Instance().Find(&servers).Error; err != nil { return nil, errors.Wrap(err, "failed to fetch servers from database") } diff --git a/protocube/server/server.go b/protocube/server/server.go index cc2d4cb9..a93a1313 100644 --- a/protocube/server/server.go +++ b/protocube/server/server.go @@ -11,6 +11,7 @@ import ( "protoxon.com/sls/protocube/events" "protoxon.com/sls/protocube/models" "protoxon.com/sls/protocube/server/repository" + "protoxon.com/sls/protocube/software" "protoxon.com/sls/protocube/system" ) @@ -35,11 +36,26 @@ type Server struct { // The crash handler for this server instance. crasher CrashHandler + // Configuration is the servers runtime configuration information + Configuration *models.ServerConfiguration + + // InstallScript is copied from the selected software at creation time. + InstallScript *software.InstallationScript + // sc is the dedicated client used to interact with server-specific API endpoints. // A server client can only access its own endpoints and control itself. sc client.ServerClient +} - Allocations environment.Allocations +// GetServerConfiguration returns the runtime configuration info for the server. +func (s *Server) GetServerConfiguration() *models.ServerConfiguration { + cfg := *s.Configuration + cfg.Id = s.Id() + return &cfg +} + +func (s *Server) allocations() *environment.Allocations { + return &s.Configuration.Allocations } func (s *Server) Id() string { @@ -104,6 +120,10 @@ func (s *Server) Reset(ctx context.Context) error { return s.Client().Reset(ctx) } +func (s *Server) Reinstall(ctx context.Context) error { + return s.Client().Reinstall(ctx) +} + // Delete deletes the server from the daemon func (s *Server) Delete(ctx context.Context) error { return s.Client().DeleteServer(ctx) @@ -125,20 +145,34 @@ func (s *Server) SendCommands(ctx context.Context, commands []string) error { } // GetLogs fetches server logs from the remote node. -func (s *Server) GetLogs(ctx context.Context, size int) (gin.H, error) { - return s.Client().Logs(ctx, size) +func (s *Server) GetLogs(ctx context.Context, page, perPage int) (gin.H, error) { + return s.Client().Logs(ctx, page, perPage) +} + +func (s *Server) InstallInfo(ctx context.Context) (models.InstallInfo, error) { + return s.Client().InstallInfo(ctx) +} + +func (s *Server) InstallLogs(ctx context.Context, page, perPage int) (gin.H, error) { + return s.Client().InstallLogs(ctx, page, perPage) } // ServerData returns the ServerData model for this server. +// ServerData is the public API representation of a managed server. func (s *Server) ServerData() models.ServerData { - return models.ServerData{ - Id: s.id, - BlueprintId: s.BlueprintId(), - NodeId: s.NodeId(), - NodeName: s.NodeName(), - Allocations: s.Allocations, - Overrides: s.Overrides, + data := models.ServerData{ + Id: s.id, + BlueprintId: s.BlueprintId(), + NodeId: s.NodeId(), + NodeName: s.NodeName(), + Overrides: s.Overrides, + SoftwareId: s.Configuration.SoftwareId, + SoftwareVersion: s.Configuration.SoftwareVersion, + Image: s.Configuration.Image, + Limits: s.Configuration.Limits, + Allocations: s.allocations(), } + return data } func (s *Server) Log() *log.Entry { @@ -151,8 +185,8 @@ func (s *Server) CleanupForDestroy() { s.Events().Destroy() s.DestroyAllSinks() // Release allocations if they exist - if s.Allocations.Release != nil { - s.Allocations.Release() + if a := s.allocations(); a != nil && a.Release != nil { + a.Release() } // Remove the server from the manager s.Remove() diff --git a/protocube/software/bundled_test.go b/protocube/software/bundled_test.go new file mode 100644 index 00000000..ab86667e --- /dev/null +++ b/protocube/software/bundled_test.go @@ -0,0 +1,39 @@ +package software + +import ( + "os" + "path/filepath" + "testing" + + "gopkg.in/yaml.v3" +) + +func TestBundledSoftwareDefinitionsLoad(t *testing.T) { + for _, id := range []string{"paper", "spigot"} { + sw := loadBundledSoftwareForTest(t, id) + if !sw.InstallScript.Warmup { + t.Fatalf("expected bundled %s software to use daemon warmup", id) + } + if sw.InstallScript.PostWarmupScript == "" { + t.Fatalf("expected bundled %s software to define post-warmup cleanup", id) + } + } +} + +func loadBundledSoftwareForTest(t *testing.T, id string) *Software { + t.Helper() + + data, err := os.ReadFile(filepath.Join("..", "..", "software", id+".yml")) + if err != nil { + t.Fatal(err) + } + + var cfg config + if err := yaml.Unmarshal(data, &cfg); err != nil { + t.Fatal(err) + } + if cfg.Software.Id != id { + t.Fatalf("expected software id %q, got %q", id, cfg.Software.Id) + } + return &cfg.Software +} diff --git a/protocube/software/parser.go b/protocube/software/parser.go old mode 100755 new mode 100644 index 06509fbb..66e4706b --- a/protocube/software/parser.go +++ b/protocube/software/parser.go @@ -180,9 +180,36 @@ func (is *InstallationScript) Validate() error { if is.Script == "" { return errors.New("missing required field: install-script.script") } + is.ApplyDefaults() + if is.WarmupTimeout <= 0 { + return errors.New("install-script.warmup-timeout must be greater than zero") + } + if is.WarmupRetries < 0 { + return errors.New("install-script.warmup-retries cannot be negative") + } + if is.PostWarmupTimeout <= 0 { + return errors.New("install-script.post-warmup-timeout must be greater than zero") + } + switch is.WarmupFailurePolicy { + case "fail", "continue", "retry": + default: + return errors.New("install-script.warmup_failure_policy must be one of: fail, continue, retry") + } return nil } +func (is *InstallationScript) ApplyDefaults() { + if is.WarmupTimeout == 0 { + is.WarmupTimeout = 300 + } + if is.PostWarmupTimeout == 0 { + is.PostWarmupTimeout = 120 + } + if strings.TrimSpace(is.WarmupFailurePolicy) == "" { + is.WarmupFailurePolicy = "fail" + } +} + type config struct { Software Software `yaml:"software"` } diff --git a/protocube/software/registry.go b/protocube/software/registry.go old mode 100755 new mode 100644 diff --git a/protocube/software/software.go b/protocube/software/software.go old mode 100755 new mode 100644 index 98e7de46..0f178f0b --- a/protocube/software/software.go +++ b/protocube/software/software.go @@ -32,7 +32,13 @@ type ConfigFile struct { } type InstallationScript struct { - Entrypoint string `yaml:"entrypoint" json:"entrypoint"` - Script string `yaml:"script" json:"script"` - SkipScripts bool `yaml:"skip-scripts,omitempty" json:"skip_scripts" default:"false"` + Entrypoint string `yaml:"entrypoint" json:"entrypoint"` + Script string `yaml:"script" json:"script"` + SkipScripts bool `yaml:"skip-scripts,omitempty" json:"skip_scripts" default:"false"` + Warmup bool `yaml:"warmup,omitempty" json:"warmup"` + WarmupTimeout int `yaml:"warmup-timeout,omitempty" json:"warmup_timeout"` + WarmupRetries int `yaml:"warmup-retries,omitempty" json:"warmup_retries"` + PostWarmupScript string `yaml:"post-warmup-script,omitempty" json:"post_warmup_script"` + PostWarmupTimeout int `yaml:"post-warmup-timeout,omitempty" json:"post_warmup_timeout"` + WarmupFailurePolicy string `yaml:"warmup_failure_policy,omitempty" json:"warmup_failure_policy"` } diff --git a/protocube/software/software_test.go b/protocube/software/software_test.go old mode 100755 new mode 100644 diff --git a/protocube/software/warmup_test.go b/protocube/software/warmup_test.go new file mode 100644 index 00000000..173226e3 --- /dev/null +++ b/protocube/software/warmup_test.go @@ -0,0 +1,115 @@ +package software + +import ( + "strings" + "testing" + + "gopkg.in/yaml.v3" +) + +func TestInstallScriptWarmupDefaults(t *testing.T) { + sw := mustParseSoftware(t, ` +software: + id: old_yaml + name: Old YAML + images: + default: example/image:latest + invocation: "sleep 1" + stop-command: "stop" + online-signal: "online" + install-script: + entrypoint: sh + script: "echo install" +`) + + if sw.InstallScript.Warmup { + t.Fatal("expected warmup to default false") + } + if sw.InstallScript.WarmupTimeout != 300 { + t.Fatalf("expected default warmup timeout 300, got %d", sw.InstallScript.WarmupTimeout) + } + if sw.InstallScript.WarmupRetries != 0 { + t.Fatalf("expected default warmup retries 0, got %d", sw.InstallScript.WarmupRetries) + } + if sw.InstallScript.PostWarmupTimeout != 120 { + t.Fatalf("expected default post-warmup timeout 120, got %d", sw.InstallScript.PostWarmupTimeout) + } + if sw.InstallScript.WarmupFailurePolicy != "fail" { + t.Fatalf("expected default policy fail, got %q", sw.InstallScript.WarmupFailurePolicy) + } +} + +func TestInstallScriptWarmupFields(t *testing.T) { + sw := mustParseSoftware(t, ` +software: + id: new_yaml + name: New YAML + images: + default: example/image:latest + invocation: "sleep 1" + stop-command: "stop" + online-signal: "online" + install-script: + entrypoint: sh + script: "echo install" + warmup: true + warmup-timeout: 45 + warmup-retries: 2 + post-warmup-script: "echo post" + post-warmup-timeout: 15 + warmup_failure_policy: retry +`) + + if !sw.InstallScript.Warmup { + t.Fatal("expected warmup true") + } + if sw.InstallScript.WarmupTimeout != 45 { + t.Fatalf("expected warmup timeout 45, got %d", sw.InstallScript.WarmupTimeout) + } + if sw.InstallScript.WarmupRetries != 2 { + t.Fatalf("expected warmup retries 2, got %d", sw.InstallScript.WarmupRetries) + } + if sw.InstallScript.PostWarmupScript != "echo post" { + t.Fatalf("unexpected post-warmup script %q", sw.InstallScript.PostWarmupScript) + } + if sw.InstallScript.PostWarmupTimeout != 15 { + t.Fatalf("expected post-warmup timeout 15, got %d", sw.InstallScript.PostWarmupTimeout) + } + if sw.InstallScript.WarmupFailurePolicy != "retry" { + t.Fatalf("expected retry policy, got %q", sw.InstallScript.WarmupFailurePolicy) + } +} + +func TestInstallScriptInvalidWarmupPolicy(t *testing.T) { + var cfg config + err := yaml.Unmarshal([]byte(` +software: + id: invalid_policy + name: Invalid Policy + images: + default: example/image:latest + invocation: "sleep 1" + stop-command: "stop" + online-signal: "online" + install-script: + entrypoint: sh + script: "echo install" + warmup_failure_policy: explode +`), &cfg) + if err == nil { + t.Fatal("expected invalid warmup policy to fail") + } + if !strings.Contains(err.Error(), "warmup_failure_policy") { + t.Fatalf("expected warmup policy error, got %v", err) + } +} + +func mustParseSoftware(t *testing.T, data string) *Software { + t.Helper() + + var cfg config + if err := yaml.Unmarshal([]byte(data), &cfg); err != nil { + t.Fatal(err) + } + return &cfg.Software +} diff --git a/protocube/system/const.go b/protocube/system/const.go old mode 100755 new mode 100644 index b9349635..aaf0fd33 --- a/protocube/system/const.go +++ b/protocube/system/const.go @@ -1,5 +1,5 @@ package system -const Version = "0.1.2" +const Version = "0.2.0" var Authors = "Jesse Faler & Contributors" diff --git a/protocube/system/sink_pool.go b/protocube/system/sink_pool.go old mode 100755 new mode 100644 diff --git a/protocube/system/sink_pool_test.go b/protocube/system/sink_pool_test.go old mode 100755 new mode 100644 diff --git a/protocube/system/system.go b/protocube/system/system.go old mode 100755 new mode 100644 diff --git a/protocube/system/utils.go b/protocube/system/utils.go old mode 100755 new mode 100644 diff --git a/slimepacks/go.mod b/slimepacks/go.mod index b76ee900..fd1039d9 100644 --- a/slimepacks/go.mod +++ b/slimepacks/go.mod @@ -44,7 +44,7 @@ require ( github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect - github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-colorable v0.1.15 // indirect github.com/mattn/go-isatty v0.0.21 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect diff --git a/slimepacks/go.sum b/slimepacks/go.sum index bf6de198..ddf94016 100644 --- a/slimepacks/go.sum +++ b/slimepacks/go.sum @@ -105,6 +105,7 @@ github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcncea github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= diff --git a/software/paper.yml b/software/paper.yml index 98da9ffc..5e11b549 100644 --- a/software/paper.yml +++ b/software/paper.yml @@ -1,19 +1,13 @@ # Paper software configuration software: - - # Unique identifier for the software id: 'paper' - - # Human readable name for the software name: 'Paper' - # Checks the URL for updates and applies changes if needed update: enabled: true url: https://github.com/jessefaler/SLS/blob/main/software/paper.yml - # Images that can be used for this software images: "java_25": "ghcr.io/protoxon/images:java_25" "java_21": "ghcr.io/protoxon/images:java_21" @@ -22,31 +16,30 @@ software: "java_11": "ghcr.io/protoxon/images:java_11" "java_8": "ghcr.io/protoxon/images:java_8" - # Version to image mappings mappings: - java_8: "<=1.16.5" - java_16: ">=1.17 <=1.17.1" - java_17: ">=1.18 <=1.20.4" - java_21: ">=1.20.5 <=1.21.11" - java_25: ">=1.21.12" - - default: java_21 + - default: java_25 - # The command to start the server - invocation: "java -Xms128M -XX:MaxRAMPercentage=95.0 -Dterminal.jline=false -Dterminal.ansi=true -XX:+PerfDisableSharedMem -XX:+ParallelRefProcEnabled -jar server.jar" # Start command for the server - - # The command sent to stop the server gracefully + invocation: "java -Xms128M -XX:MaxRAMPercentage=95.0 -Dterminal.jline=false -Dterminal.ansi=true -XX:+PerfDisableSharedMem -XX:+ParallelRefProcEnabled -jar server.jar" stop-command: "stop" - - # The console output indicating the server is fully online online-signal: ")! For help, type" - # Installs the server and warms it up install-script: entrypoint: bash - script: "#!/bin/bash\n# Paper Installation Script\nPROJECT=paper\n\n# Set defaults if not provided\n: \"${SERVER_JARFILE:=server.jar}\"\n\nif [ -n \"${DL_PATH}\" ]; then\n echo -e \"Using supplied download url: ${DL_PATH}\"\n DOWNLOAD_URL=`eval echo $(echo ${DL_PATH} | sed -e 's/{{/${/g' -e 's/}}/}/g')`\nelse\n VER_EXISTS=`curl -s https://api.papermc.io/v2/projects/${PROJECT} | jq -r --arg VERSION $VERSION '.versions[] | contains($VERSION)' | grep -m1 true`\n LATEST_VERSION=`curl -s https://api.papermc.io/v2/projects/${PROJECT} | jq -r '.versions' | jq -r '.[-1]'`\n\n if [ \"${VER_EXISTS}\" == \"true\" ]; then\n echo -e \"Version is valid. Using version ${VERSION}\"\n else\n echo -e \"Specified version not found. Defaulting to the latest ${PROJECT} version\"\n VERSION=${LATEST_VERSION}\n fi\n\n BUILD_EXISTS=`curl -s https://api.papermc.io/v2/projects/${PROJECT}/versions/${VERSION} | jq -r --arg BUILD ${BUILD_NUMBER} '.builds[] | tostring | contains($BUILD)' | grep -m1 true`\n LATEST_BUILD=`curl -s https://api.papermc.io/v2/projects/${PROJECT}/versions/${VERSION} | jq -r '.builds' | jq -r '.[-1]'`\n\n if [ \"${BUILD_EXISTS}\" == \"true\" ]; then\n echo -e \"Build is valid for version ${VERSION}. Using build ${BUILD_NUMBER}\"\n else\n echo -e \"Using the latest ${PROJECT} build for version ${VERSION}\"\n BUILD_NUMBER=${LATEST_BUILD}\n fi\n\n JAR_NAME=${PROJECT}-${VERSION}-${BUILD_NUMBER}.jar\n echo \"Version being downloaded\"\n echo -e \"MC Version: ${VERSION}\"\n echo -e \"Build: ${BUILD_NUMBER}\"\n echo -e \"JAR Name of Build: ${JAR_NAME}\"\n DOWNLOAD_URL=https://api.papermc.io/v2/projects/${PROJECT}/versions/${VERSION}/builds/${BUILD_NUMBER}/downloads/${JAR_NAME}\nfi\n\ncd /home/container\n\necho -e \"Running curl -o ${SERVER_JARFILE} ${DOWNLOAD_URL}\"\nif [ -f ${SERVER_JARFILE} ]; then\n mv ${SERVER_JARFILE} ${SERVER_JARFILE}.old\nfi\n\ncurl -f -o ${SERVER_JARFILE} ${DOWNLOAD_URL}\nif [ $? -ne 0 ]; then\n echo \"ERROR: Failed to download server jar. Aborting.\"\n exit 1\nfi\n\nif [ ! -f ${SERVER_JARFILE} ]; then\n echo \"ERROR: Server jar not found after download. Aborting.\"\n exit 1\nfi\n\n# Wite the default server properties file\ncat > server.properties < bukkit.yml < spigot.yml < eula.txt\n\nFIFO=\"server.pipe\"\nmkfifo $FIFO\n\necho \"Starting server for warmup...\"\njava -Xms128M -XX:MaxRAMPercentage=80.0 -Dterminal.jline=false -Dterminal.ansi=true \\\n -jar ${SERVER_JARFILE} < $FIFO > server-output.log 2>&1 &\nSERVER_PID=$!\n\n# Check server actually started\nsleep 2\nif ! kill -0 $SERVER_PID 2>/dev/null; then\n echo \"ERROR: Server process failed to start. Aborting.\"\n cat server-output.log\n exit 1\nfi\n\n# Keep write end of pipe open so server doesn't block on stdin\nexec 3>$FIFO\n\nSTART_TIME=$(date +%s)\nTIMEOUT=600\nSUCCESS=false\n\necho \"Waiting for server to reach 'Preparing level'...\"\n\ntail -F server-output.log 2>/dev/null | while read -r line; do\n echo \"[SERVER] $line\"\ndone &\nTAIL_PID=$!\n\nwhile true; do\n if [ -f server-output.log ] && grep -q \"Preparing level\" server-output.log; then\n echo \"Server reached 'Preparing level'. Killing process...\"\n kill -9 $SERVER_PID 2>/dev/null || true\n SUCCESS=true\n break\n fi\n\n # Check if server died unexpectedly\n if ! kill -0 $SERVER_PID 2>/dev/null; then\n echo \"ERROR: Server process died unexpectedly. Aborting.\"\n cat server-output.log\n exit 1\n fi\n\n NOW=$(date +%s)\n ELAPSED=$((NOW - START_TIME))\n if [ \"$ELAPSED\" -ge \"$TIMEOUT\" ]; then\n echo \"ERROR: Timeout reached waiting for server. Aborting.\"\n kill -9 $SERVER_PID 2>/dev/null\n cat server-output.log\n exit 1\n fi\n\n sleep 2\ndone\n\n# Wait for process to exit after kill\nwait $SERVER_PID 2>/dev/null || true\n\n# Cleanup\nexec 3>&-\nkill $TAIL_PID 2>/dev/null || true\nrm -f $FIFO\nrm -f server-output.log\n\n# Clean up server-generated files not needed in the image\nrm -rf world world_nether world_the_end logs\n\nif [ \"$SUCCESS\" = true ]; then\n echo \"Warmup complete.\"\n exit 0\nelse\n echo \"ERROR: Warmup did not complete successfully.\"\n exit 1\nfi" - skip_scripts: false + script: "#!/bin/ash\n# Paper Installation Script\n#\n# Server Files: /home/container\nPROJECT=paper\nSERVER_JARFILE=server.jar\nUSER_AGENT=\"SLS (https://protoxon.github.io/sls-docs/)\"\n\nif [ -n \"${DL_PATH}\" ]; then\n echo -e \"Using supplied download url: ${DL_PATH}\"\n DOWNLOAD_URL=`eval echo $(echo ${DL_PATH} | sed -e 's/{{/${/g' -e 's/}}/}/g')`\nelse\n VER_EXISTS=`curl --user-agent \"${USER_AGENT}\" -s https://fill.papermc.io/v3/projects/${PROJECT} | jq -r --arg VERSION $VERSION '.versions | any(.[]; index($VERSION))' | grep -m1 true`\n LATEST_VERSION=`curl --user-agent \"${USER_AGENT}\" -s https://fill.papermc.io/v3/projects/${PROJECT} | jq -r '.versions | to_entries | .[0].value[0]'`\n\n if [ \"${VER_EXISTS}\" == \"true\" ]; then\n echo -e \"Version is valid. Using version ${VERSION}\"\n else\n echo -e \"Specified version not found. Defaulting to the latest ${PROJECT} version\"\n VERSION=${LATEST_VERSION}\n fi\n\n BUILD_EXISTS=`curl --user-agent \"${USER_AGENT}\" -s https://fill.papermc.io/v3/projects/${PROJECT}/versions/${VERSION} | jq -r --arg BUILD ${BUILD_NUMBER} '.builds[] | tostring | contains($BUILD)' | grep -m1 true`\n LATEST_BUILD=`curl --user-agent \"${USER_AGENT}\" -s https://fill.papermc.io/v3/projects/${PROJECT}/versions/${VERSION} | jq -r '.builds' | jq -r '.[0]'`\n\n if [ \"${BUILD_EXISTS}\" == \"true\" ]; then\n echo -e \"Build is valid for version ${VERSION}. Using build ${BUILD_NUMBER}\"\n else\n echo -e \"Using the latest ${PROJECT} build for version ${VERSION}\"\n BUILD_NUMBER=${LATEST_BUILD}\n fi\n\n echo \"Version being downloaded\"\n echo -e \"Project: ${PROJECT}\"\n echo -e \"MC Version: ${VERSION}\"\n echo -e \"Build: ${BUILD_NUMBER}\"\n DOWNLOAD_URL=`curl --user-agent \"${USER_AGENT}\" -s https://fill.papermc.io/v3/projects/${PROJECT}/versions/${VERSION}/builds/${BUILD_NUMBER} | jq -r '.downloads.\"server:default\".url'`\nfi\n\ncd /home/container\n\necho -e \"Running curl --user-agent \\\"${USER_AGENT}\\\" -o ${SERVER_JARFILE} ${DOWNLOAD_URL}\"\n\nif [ -f ${SERVER_JARFILE} ]; then\n mv ${SERVER_JARFILE} ${SERVER_JARFILE}.old\nfi\n\ncurl --user-agent \"${USER_AGENT}\" -o ${SERVER_JARFILE} ${DOWNLOAD_URL}\n\n\necho \"Writing default server.properties file\"\ncat > server.properties < bukkit.yml < spigot.yml < eula.txt" + skip-scripts: false + warmup: true + warmup-timeout: 600 + warmup-retries: 0 + post-warmup-script: | + rm -rf world world_nether world_the_end logs + post-warmup-timeout: 120 + warmup_failure_policy: fail - # Default limits limits: memory_limit: 4096 swap: 0 @@ -56,11 +49,10 @@ software: threads: "" oom_disabled: true - # Config patch to set the server ip and port configs: server.properties: parser: properties find: server-ip: "0.0.0.0" server-port: "{{server.build.default.port}}" - query.port: "{{server.build.default.port}}" \ No newline at end of file + query.port: "{{server.build.default.port}}" diff --git a/software/scripts/paper_installation_script.yml b/software/scripts/paper_installation_script.yml new file mode 100644 index 00000000..6f19dabe --- /dev/null +++ b/software/scripts/paper_installation_script.yml @@ -0,0 +1,82 @@ +#!/bin/ash +# Paper Installation Script +# +# Server Files: /home/container +PROJECT=paper +SERVER_JARFILE=server.jar +USER_AGENT="SLS (https://protoxon.github.io/sls-docs/)" + +if [ -n "${DL_PATH}" ]; then + echo -e "Using supplied download url: ${DL_PATH}" + DOWNLOAD_URL=`eval echo $(echo ${DL_PATH} | sed -e 's/{{/${/g' -e 's/}}/}/g')` +else + VER_EXISTS=`curl --user-agent "${USER_AGENT}" -s https://fill.papermc.io/v3/projects/${PROJECT} | jq -r --arg VERSION $VERSION '.versions | any(.[]; index($VERSION))' | grep -m1 true` + LATEST_VERSION=`curl --user-agent "${USER_AGENT}" -s https://fill.papermc.io/v3/projects/${PROJECT} | jq -r '.versions | to_entries | .[0].value[0]'` + + if [ "${VER_EXISTS}" == "true" ]; then + echo -e "Version is valid. Using version ${VERSION}" + else + echo -e "Specified version not found. Defaulting to the latest ${PROJECT} version" + VERSION=${LATEST_VERSION} + fi + + BUILD_EXISTS=`curl --user-agent "${USER_AGENT}" -s https://fill.papermc.io/v3/projects/${PROJECT}/versions/${VERSION} | jq -r --arg BUILD ${BUILD_NUMBER} '.builds[] | tostring | contains($BUILD)' | grep -m1 true` + LATEST_BUILD=`curl --user-agent "${USER_AGENT}" -s https://fill.papermc.io/v3/projects/${PROJECT}/versions/${VERSION} | jq -r '.builds' | jq -r '.[0]'` + + if [ "${BUILD_EXISTS}" == "true" ]; then + echo -e "Build is valid for version ${VERSION}. Using build ${BUILD_NUMBER}" + else + echo -e "Using the latest ${PROJECT} build for version ${VERSION}" + BUILD_NUMBER=${LATEST_BUILD} + fi + + echo "Version being downloaded" + echo -e "Project: ${PROJECT}" + echo -e "MC Version: ${VERSION}" + echo -e "Build: ${BUILD_NUMBER}" + DOWNLOAD_URL=`curl --user-agent "${USER_AGENT}" -s https://fill.papermc.io/v3/projects/${PROJECT}/versions/${VERSION}/builds/${BUILD_NUMBER} | jq -r '.downloads."server:default".url'` +fi + +cd /home/container + +echo -e "Running curl --user-agent \"${USER_AGENT}\" -o ${SERVER_JARFILE} ${DOWNLOAD_URL}" + +if [ -f ${SERVER_JARFILE} ]; then + mv ${SERVER_JARFILE} ${SERVER_JARFILE}.old +fi + +curl --user-agent "${USER_AGENT}" -o ${SERVER_JARFILE} ${DOWNLOAD_URL} + + +echo "Writing default server.properties file" +cat > server.properties < bukkit.yml < spigot.yml < eula.txt \ No newline at end of file diff --git a/software/spigot.yml b/software/spigot.yml index b7aae863..4546d309 100644 --- a/software/spigot.yml +++ b/software/spigot.yml @@ -1,66 +1,123 @@ -# Spigot Software configuration - -software: - - # Unique identifier for the software - id: 'spigot' - - # Human readable name for the software - name: 'Spigot' - - # Checks the URL for updates and applies changes if needed - update: - enabled: true - url: https://github.com/jessefaler/SLS/blob/main/software/spigot.yml - - # Images that can be used for this software - images: - "java_25": "ghcr.io/protoxon/images:java_25" - "java_21": "ghcr.io/protoxon/images:java_21" - "java_17": "ghcr.io/protoxon/images:java_17" - "java_16": "ghcr.io/protoxon/images:java_16" - "java_11": "ghcr.io/protoxon/images:java_11" - "java_8": "ghcr.io/protoxon/images:java_8" - - # Version to image mappings - mappings: - - java_8: "<=1.16.5" - - java_16: ">=1.17 <=1.17.1" - - java_17: ">=1.18 <=1.20.4" - - java_21: ">=1.20.5 <=1.21.11" - - java_25: ">=1.21.12" - - default: java_21 - - # The command to start the server - invocation: "java -Xms128M -XX:MaxRAMPercentage=95.0 -Dterminal.jline=false -Dterminal.ansi=true -Ddisable.watchdog=true -jar server.jar" # Start command for the server - - # The command sent to stop the server gracefully - stop-command: "stop" - - # The console output indicating the server is fully online - online-signal: ")! For help, type" # Signal to indicate server is online - - # Installs the server and warms it up - install-script: - image: "1" - entrypoint: bash - script: "#!/bin/bash\n# Spigot Installation Script\nPROJECT=spigot\n\n# Set defaults if not provided\n: \"${SERVER_JARFILE:=server.jar}\"\n\nif [ -n \"${DL_PATH}\" ]; then\n echo -e \"Using supplied download url: ${DL_PATH}\"\n DOWNLOAD_URL=`eval echo $(echo ${DL_PATH} | sed -e 's/{{/${/g' -e 's/}}/}/g')`\n cd /home/container\n echo -e \"Running curl -o ${SERVER_JARFILE} ${DOWNLOAD_URL}\"\n if [ -f ${SERVER_JARFILE} ]; then\n mv ${SERVER_JARFILE} ${SERVER_JARFILE}.old\n fi\n curl -f -o ${SERVER_JARFILE} ${DOWNLOAD_URL}\n if [ $? -ne 0 ]; then\n echo \"ERROR: Failed to download server jar. Aborting.\"\n exit 1\n fi\nelse\n cd /home/container\n : \"${VERSION:=1.20.1}\"\n echo -e \"Building Spigot for Minecraft version ${VERSION}...\"\n curl -L -f -o BuildTools.jar https://hub.spigotmc.org/jenkins/job/BuildTools/lastSuccessfulBuild/artifact/target/BuildTools.jar\n if [ $? -ne 0 ]; then\n echo \"ERROR: Failed to download BuildTools. Aborting.\"\n exit 1\n fi\n BUILD_MEM=1024\n if [ -n \"${SERVER_MEMORY}\" ] && [ \"${SERVER_MEMORY}\" -gt \"${BUILD_MEM}\" ] 2>/dev/null; then\n BUILD_MEM=${SERVER_MEMORY}\n fi\n echo -e \"Running BuildTools with ${BUILD_MEM}M memory...\"\n java -Xms${BUILD_MEM}M -XX:MaxRAMPercentage=80.0 -jar BuildTools.jar --rev ${VERSION}\n if [ $? -ne 0 ]; then\n echo \"ERROR: BuildTools failed. Aborting.\"\n exit 1\n fi\n if [ -f ${SERVER_JARFILE} ]; then\n mv ${SERVER_JARFILE} ${SERVER_JARFILE}.old\n fi\n mv spigot-${VERSION}.jar ${SERVER_JARFILE}\n rm -f BuildTools.jar\n rm -rf build\nfi\n\nif [ ! -f ${SERVER_JARFILE} ]; then\n echo \"ERROR: Server jar not found after download/build. Aborting.\"\n exit 1\nfi\n\n# Write the default server properties file\ncat > server.properties < bukkit.yml < spigot.yml < eula.txt\n\nFIFO=\"server.pipe\"\nmkfifo $FIFO\n\necho \"Starting server for warmup...\"\njava -Xms128M -XX:MaxRAMPercentage=80.0 -Dterminal.jline=false -Dterminal.ansi=true \\\n -jar ${SERVER_JARFILE} -nogui < $FIFO > server-output.log 2>&1 &\nSERVER_PID=$!\n\n# Check server actually started\nsleep 2\nif ! kill -0 $SERVER_PID 2>/dev/null; then\n echo \"ERROR: Server process failed to start. Aborting.\"\n cat server-output.log\n exit 1\nfi\n\n# Keep write end of pipe open so server doesn't block on stdin\nexec 3>$FIFO\n\nSTART_TIME=$(date +%s)\nTIMEOUT=600\nSUCCESS=false\n\necho \"Waiting for server to reach 'Preparing level'...\"\n\ntail -F server-output.log 2>/dev/null | while read -r line; do\n echo \"[SERVER] $line\"\ndone &\nTAIL_PID=$!\n\nwhile true; do\n if [ -f server-output.log ] && grep -q \"Preparing level\" server-output.log; then\n echo \"Server reached 'Preparing level'. Killing process...\"\n kill -9 $SERVER_PID 2>/dev/null || true\n SUCCESS=true\n break\n fi\n\n if ! kill -0 $SERVER_PID 2>/dev/null; then\n echo \"ERROR: Server process died unexpectedly. Aborting.\"\n cat server-output.log\n exit 1\n fi\n\n NOW=$(date +%s)\n ELAPSED=$((NOW - START_TIME))\n if [ \"$ELAPSED\" -ge \"$TIMEOUT\" ]; then\n echo \"ERROR: Timeout reached waiting for server. Aborting.\"\n kill -9 $SERVER_PID 2>/dev/null\n cat server-output.log\n exit 1\n fi\n\n sleep 2\ndone\n\nwait $SERVER_PID 2>/dev/null || true\nexec 3>&-\nkill $TAIL_PID 2>/dev/null || true\nrm -f $FIFO\nrm -f server-output.log\nrm -rf world world_nether world_the_end logs\n\nif [ \"$SUCCESS\" = true ]; then\n echo \"Warmup complete.\"\n exit 0\nelse\n echo \"ERROR: Warmup did not complete successfully.\"\n exit 1\nfi" - - # Default limits - limits: - memory_limit: 4096 - swap: 0 - io_weight: 500 - cpu_limit: 0 - disk_space: 8192 - threads: "" - oom_disabled: true - - # Config patch to set the server ip and port - configs: - server.properties: - parser: properties - find: - server-ip: "0.0.0.0" - server-port: "{{server.build.default.port}}" - query.port: "{{server.build.default.port}}" \ No newline at end of file +# Spigot Software configuration + +software: + id: 'spigot' + name: 'Spigot' + + update: + enabled: true + url: https://github.com/jessefaler/SLS/blob/main/software/spigot.yml + + images: + "java_25": "ghcr.io/protoxon/images:java_25" + "java_21": "ghcr.io/protoxon/images:java_21" + "java_17": "ghcr.io/protoxon/images:java_17" + "java_16": "ghcr.io/protoxon/images:java_16" + "java_11": "ghcr.io/protoxon/images:java_11" + "java_8": "ghcr.io/protoxon/images:java_8" + + mappings: + - java_8: "<=1.16.5" + - java_16: ">=1.17 <=1.17.1" + - java_17: ">=1.18 <=1.20.4" + - java_21: ">=1.20.5 <=1.21.11" + - java_25: ">=1.21.12" + - default: java_21 + + invocation: "java -Xms128M -XX:MaxRAMPercentage=95.0 -Dterminal.jline=false -Dterminal.ansi=true -Ddisable.watchdog=true -jar server.jar" + stop-command: "stop" + online-signal: ")! For help, type" + + install-script: + entrypoint: bash + script: | + #!/bin/bash + set -e + + PROJECT=spigot + : "${SERVER_JARFILE:=server.jar}" + cd /home/container + + if [ -n "${DL_PATH}" ]; then + echo -e "Using supplied download url: ${DL_PATH}" + DOWNLOAD_URL=$(eval echo "$(echo "${DL_PATH}" | sed -e 's/{{/${/g' -e 's/}}/}/g')") + echo -e "Running curl -o ${SERVER_JARFILE} ${DOWNLOAD_URL}" + if [ -f "${SERVER_JARFILE}" ]; then + mv "${SERVER_JARFILE}" "${SERVER_JARFILE}.old" + fi + curl -f -o "${SERVER_JARFILE}" "${DOWNLOAD_URL}" + else + : "${VERSION:=1.20.1}" + echo -e "Building Spigot for Minecraft version ${VERSION}..." + curl -L -f -o BuildTools.jar https://hub.spigotmc.org/jenkins/job/BuildTools/lastSuccessfulBuild/artifact/target/BuildTools.jar + + BUILD_MEM=1024 + if [ -n "${SERVER_MEMORY}" ] && [ "${SERVER_MEMORY}" -gt "${BUILD_MEM}" ] 2>/dev/null; then + BUILD_MEM=${SERVER_MEMORY} + fi + + echo -e "Running BuildTools with ${BUILD_MEM}M memory..." + java -Xms${BUILD_MEM}M -XX:MaxRAMPercentage=80.0 -jar BuildTools.jar --rev "${VERSION}" + + if [ -f "${SERVER_JARFILE}" ]; then + mv "${SERVER_JARFILE}" "${SERVER_JARFILE}.old" + fi + mv "spigot-${VERSION}.jar" "${SERVER_JARFILE}" + rm -f BuildTools.jar + rm -rf build + fi + + if [ ! -f "${SERVER_JARFILE}" ]; then + echo "ERROR: Server jar not found after download/build. Aborting." + exit 1 + fi + + cat > server.properties < bukkit.yml < spigot.yml < eula.txt + warmup: true + warmup-timeout: 600 + warmup-retries: 0 + post-warmup-script: | + rm -rf world world_nether world_the_end logs + post-warmup-timeout: 120 + warmup_failure_policy: fail + + limits: + memory_limit: 4096 + swap: 0 + io_weight: 500 + cpu_limit: 0 + disk_space: 8192 + threads: "" + oom_disabled: true + + configs: + server.properties: + parser: properties + find: + server-ip: "0.0.0.0" + server-port: "{{server.build.default.port}}" + query.port: "{{server.build.default.port}}" diff --git a/vsls/pom.xml b/vsls/pom.xml index 09953a4a..b958b5b5 100644 --- a/vsls/pom.xml +++ b/vsls/pom.xml @@ -7,7 +7,7 @@ net.slimelabs vsls - 1.0.2 + 1.1.0 jar vsls @@ -15,7 +15,7 @@ com.protoxon sls-monorepo - 1.0.1 + 1.1.0 ../pom.xml @@ -70,7 +70,7 @@ com.github.retrooper packetevents-velocity - 2.11.2 + 2.13.0 provided @@ -93,7 +93,7 @@ com.fasterxml.jackson.core jackson-databind - 2.21.3 + 2.22.0 diff --git a/vsls/src/main/java/net/slimelabs/vsls/SLS.java b/vsls/src/main/java/net/slimelabs/vsls/SLS.java index bb8d4dc8..c3978367 100644 --- a/vsls/src/main/java/net/slimelabs/vsls/SLS.java +++ b/vsls/src/main/java/net/slimelabs/vsls/SLS.java @@ -29,7 +29,7 @@ @Plugin( id = "vsls", name = "vSLS", - version = "1.0.2", + version = "1.1.0", description = "Server Management Plugin", authors = {"Protoxon & Contributors"}, dependencies = { diff --git a/vsls/src/main/java/net/slimelabs/vsls/command/SLSCommand.java b/vsls/src/main/java/net/slimelabs/vsls/command/SLSCommand.java index b66215b2..9879d278 100644 --- a/vsls/src/main/java/net/slimelabs/vsls/command/SLSCommand.java +++ b/vsls/src/main/java/net/slimelabs/vsls/command/SLSCommand.java @@ -50,6 +50,7 @@ public static void register() { root.then(NodeCommand.register()); // NODE root.then(ResetCommand.register()); // RESET root.then(InfoCommand.register()); // INFO + root.then(InstallCommand.register()); // INSTALL root.then(ListCommand.register()); // LIST root.then(FindCommand.register()); // FIND root.then(SystemCommand.register()); // SYSTEM @@ -81,6 +82,7 @@ private static int handleRootCommand(CommandContext context) { "join", "create", "start", "pause", "resume", "restart", "debug", "stop", "kill", "reload", "status", "stats", "delete", "console", "dequeue", "blueprint", "version", "logs", "node", "reset", "info", + "install info", "install logs", "list", "find", "system")) .sendMessage(source); return 1; diff --git a/vsls/src/main/java/net/slimelabs/vsls/command/subcommand/DeleteCommand.java b/vsls/src/main/java/net/slimelabs/vsls/command/subcommand/DeleteCommand.java index 4a15ac37..1ca9d44e 100644 --- a/vsls/src/main/java/net/slimelabs/vsls/command/subcommand/DeleteCommand.java +++ b/vsls/src/main/java/net/slimelabs/vsls/command/subcommand/DeleteCommand.java @@ -122,7 +122,14 @@ private static RequiredArgumentBuilder force() { .add("Deleting " + id, NamedTextColor.GRAY) .sendMessage(source); }, - failure -> Log.requestError("Failed to delete server " + id, failure, source) + failure -> { + Log.requestError("Failed to delete server " + id, failure, source); + SLS.servers.unRegister(server.getId()); + ProtoMessage.chat() + .add(MessagePreset.SLS) + .add("Failed to delete server on the remote node but was successfully unregistered from vSLS " + id, NamedTextColor.RED) + .sendMessage(source); + } ); } else { // No such server exists diff --git a/vsls/src/main/java/net/slimelabs/vsls/command/subcommand/InfoCommand.java b/vsls/src/main/java/net/slimelabs/vsls/command/subcommand/InfoCommand.java index b0fa3eba..ca49c47d 100644 --- a/vsls/src/main/java/net/slimelabs/vsls/command/subcommand/InfoCommand.java +++ b/vsls/src/main/java/net/slimelabs/vsls/command/subcommand/InfoCommand.java @@ -68,9 +68,9 @@ public static void showStats(Server server, CommandSource source) { server.getStats(true).executeAsync(stats -> { Blueprint blueprint = SLS.blueprints.getBlueprint(server.getBlueprintId()); String type = blueprint != null ? blueprint.getType() : "Unknown"; - String software = blueprint != null ? blueprint.getServerSoftware() : "Unknown"; - String version = blueprint != null ? blueprint.getServerVersion() : "Unknown"; - String image = blueprint != null ? blueprint.getImage() : "Unknown"; + String software = orUnknown(server.getSoftwareId()); + String version = orUnknown(server.getVersion() != null ? server.getVersion() : server.getSoftwareVersion()); + String image = orUnknown(server.getImage()); String name = blueprint != null ? blueprint.getName() : server.getBlueprintId(); String statusColor = "green"; if(server.getStatus() == ServerStatus.STOPPING || server.getStatus() == ServerStatus.OFFLINE) { @@ -108,4 +108,8 @@ public static void showStats(Server server, CommandSource source) { }); } + private static String orUnknown(String value) { + return value != null && !value.isEmpty() ? value : "Unknown"; + } + } diff --git a/vsls/src/main/java/net/slimelabs/vsls/command/subcommand/InstallCommand.java b/vsls/src/main/java/net/slimelabs/vsls/command/subcommand/InstallCommand.java new file mode 100644 index 00000000..9ddb7f41 --- /dev/null +++ b/vsls/src/main/java/net/slimelabs/vsls/command/subcommand/InstallCommand.java @@ -0,0 +1,392 @@ +package net.slimelabs.vsls.command.subcommand; + +import com.mojang.brigadier.arguments.StringArgumentType; +import com.mojang.brigadier.builder.LiteralArgumentBuilder; +import com.mojang.brigadier.builder.RequiredArgumentBuilder; +import com.protoxon.S4J.InstallPhase; +import com.protoxon.S4J.requests.PaginationAction; +import com.velocitypowered.api.command.CommandSource; +import com.velocitypowered.api.proxy.Player; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.ComponentBuilder; +import net.kyori.adventure.text.TextComponent; +import net.kyori.adventure.text.format.NamedTextColor; +import net.slimelabs.vsls.SLS; +import net.slimelabs.vsls.log.Log; +import net.slimelabs.vsls.server.Server; +import net.slimelabs.vsls.utils.ServerUtils; +import net.slimelabs.vsls.utils.TimeUtils; +import net.slimelabs.vsls.utils.message.CommandMessageParts; +import net.slimelabs.vsls.utils.message.MessageFormatter; +import net.slimelabs.vsls.utils.message.MessagePreset; +import net.slimelabs.vsls.utils.message.ProtoMessage; + +import java.time.Instant; + +public class InstallCommand { + + private static final int DEFAULT_PAGE = 1; + private static final int DEFAULT_LINES_PER_PAGE = 50; + private static final int MAX_LINES_PER_PAGE = 1000; + + public static LiteralArgumentBuilder register() { + return LiteralArgumentBuilder.literal("install") + .requires(source -> source.hasPermission("sls.command.admin")) + .executes(context -> { + CommandSource source = context.getSource(); + ProtoMessage.chat().add(MessagePreset.INCORRECT_COMMAND_USAGE).sendMessage(source); + ProtoMessage.chat() + .add(MessageFormatter.commandUsage("/sls install", "info", "logs", "reinstall")) + .sendMessage(source); + return 1; + }) + .then(info()) + .then(logs()) + .then(reinstall()); + } + + // --- info --- + + private static LiteralArgumentBuilder info() { + return LiteralArgumentBuilder.literal("info") + .executes(context -> { + CommandSource source = context.getSource(); + if (!(source instanceof Player player)) { + Log.warn("Invalid command usage! You must specify a server id when running this command from console."); + return 0; + } + Server server = ServerUtils.getServer(player); + if (server != null) { + showInstallInfo(server, source); + } else { + ProtoMessage.chat() + .add(MessagePreset.SLS) + .add("Server " + ServerUtils.getServerName(player) + " is not an SLS server", NamedTextColor.RED) + .sendMessage(source); + } + return 1; + }) + .then(infoServer()); + } + + private static RequiredArgumentBuilder infoServer() { + return RequiredArgumentBuilder.argument("server", StringArgumentType.string()) + .suggests((context, builder) -> { + builder.suggest("this"); + SLS.servers.getShortIds().forEach(builder::suggest); + return builder.buildFuture(); + }) + .executes(context -> { + CommandSource source = context.getSource(); + String id = StringArgumentType.getString(context, "server"); + Server server = resolveServer(source, id); + if (server != null) { + showInstallInfo(server, source); + } else if (!id.equals("this")) { + ProtoMessage.chat() + .add(MessagePreset.SLS) + .add("No such server " + id, NamedTextColor.RED) + .sendMessage(source); + } + return 0; + }); + } + + private static void showInstallInfo(Server server, CommandSource source) { + server.getInstallInfo().executeAsync(info -> { + String phaseColor = phaseColor(info.getPhase()); + String containerName = orUnknown(info.getContainerName()); + String containerId = orUnknown(info.getContainerId()); + String containerStatus = orUnknown(info.getStatus()); + String exitCode = info.getExitCode() != null ? String.valueOf(info.getExitCode()) : "Unknown"; + String startedAt = formatInstant(info.getStartedAt()); + String finishedAt = formatInstant(info.getFinishedAt()); + String failureReason = info.getFailureReason(); + + StringBuilder message = new StringBuilder(); + message.append("Install (") + .append(server.getCompositeId()) + .append("):\n") + .append("--------------------\n") + .append(" - Phase: <") + .append(phaseColor) + .append(">") + .append(info.getPhase().getPhase()) + .append("\n") + .append(" - Container Status: ") + .append(containerStatus) + .append("\n") + .append(" ") + .append(containerId) + .append("'>- Container: ") + .append(containerName) + .append("\n") + .append(" - Exit Code: ") + .append(exitCode) + .append("\n") + .append(" - Started: ") + .append(startedAt) + .append("\n") + .append(" - Finished: ") + .append(finishedAt) + .append("\n"); + + if (failureReason != null && !failureReason.isBlank()) { + message.append(" - Failure: ") + .append(failureReason) + .append("\n"); + } + + message.append("--------------------"); + + ProtoMessage.chat().addMiniMessage(message.toString()).sendMessage(source); + }, failure -> Log.requestError("Failed to fetch install info for " + server.getCompositeId(), failure, source)); + } + + // --- logs --- + + private static LiteralArgumentBuilder logs() { + return LiteralArgumentBuilder.literal("logs") + .executes(context -> { + CommandSource source = context.getSource(); + ProtoMessage.chat().add(MessagePreset.INCORRECT_COMMAND_USAGE).sendMessage(source); + ProtoMessage.chat() + .add(MessageFormatter.commandUsage("/sls install logs", "server")) + .sendMessage(source); + return 1; + }) + .then(logsServer()); + } + + private static RequiredArgumentBuilder logsServer() { + return RequiredArgumentBuilder.argument("server", StringArgumentType.string()) + .suggests((context, builder) -> { + builder.suggest("this"); + SLS.servers.getShortIds().forEach(builder::suggest); + return builder.buildFuture(); + }) + .executes(context -> executeLogs( + context.getSource(), + StringArgumentType.getString(context, "server"), + DEFAULT_PAGE, + DEFAULT_LINES_PER_PAGE)) + .then(logPage()); + } + + private static RequiredArgumentBuilder logPage() { + return RequiredArgumentBuilder.argument("page", StringArgumentType.string()) + .executes(context -> { + CommandSource source = context.getSource(); + String pageString = StringArgumentType.getString(context, "page"); + Integer page = parsePositiveInt(pageString); + if (page == null) { + invalidNumber(source, pageString); + return 0; + } + return executeLogs(source, + StringArgumentType.getString(context, "server"), + page, + DEFAULT_LINES_PER_PAGE); + }) + .then(logLinesPerPage()); + } + + private static RequiredArgumentBuilder logLinesPerPage() { + return RequiredArgumentBuilder.argument("lines", StringArgumentType.string()) + .suggests((context, builder) -> { + builder.suggest("max"); + return builder.buildFuture(); + }) + .executes(context -> { + CommandSource source = context.getSource(); + String pageString = StringArgumentType.getString(context, "page"); + String linesString = StringArgumentType.getString(context, "lines"); + Integer page = parsePositiveInt(pageString); + if (page == null) { + invalidNumber(source, pageString); + return 0; + } + Integer lines = linesString.equals("max") ? MAX_LINES_PER_PAGE : parsePositiveInt(linesString); + if (lines == null) { + invalidNumber(source, linesString); + return 0; + } + return executeLogs(source, + StringArgumentType.getString(context, "server"), + page, + clampLinesPerPage(lines)); + }); + } + + private static int executeLogs(CommandSource source, String id, int page, int linesPerPage) { + Server server = resolveServer(source, id); + if (server == null) { + if (!id.equals("this")) { + ProtoMessage.chat() + .add(MessagePreset.SLS) + .add("No such server " + id, NamedTextColor.RED) + .sendMessage(source); + } + return 0; + } + + showInstallLogs(source, server, id, page, linesPerPage); + return 0; + } + + private static void showInstallLogs(CommandSource source, Server server, String id, int page, int linesPerPage) { + PaginationAction action = server.getInstallLogs(linesPerPage).skipTo(page); + action.executeAsync(logs -> { + int totalPages = action.getTotalPages(); + int totalLines = action.getTotal(); + int maxPage = Math.max(totalPages, 1); + + if (page > maxPage) { + ProtoMessage.chat() + .add(MessagePreset.SLS) + .add("Page " + page + " does not exist ", NamedTextColor.RED) + .add("(valid range: 1-" + maxPage + ")", NamedTextColor.GRAY) + .sendMessage(source); + return; + } + + ProtoMessage.chat() + .addMiniMessage("-- Install logs for " + server.getCompositeId() + " --\n") + .sendMessage(source); + + ComponentBuilder builder = Component.text(); + for (String line : logs) { + builder.append(Component.text(line + "\n", NamedTextColor.GRAY)); + } + source.sendMessage(builder.build()); + + ProtoMessage.chat() + .addMiniMessage(logsPaginationFooter(id, page, linesPerPage, maxPage, totalLines)) + .sendMessage(source); + }, failure -> Log.requestError("Failed to get install logs for server " + id, failure, source)); + } + + // --- reinstall --- + + private static LiteralArgumentBuilder reinstall() { + return LiteralArgumentBuilder.literal("reinstall") + .executes(context -> { + CommandSource source = context.getSource(); + ProtoMessage.chat().add(MessagePreset.INCORRECT_COMMAND_USAGE).sendMessage(source); + ProtoMessage.chat() + .add(MessageFormatter.commandUsage("/sls install reinstall", "server")) + .sendMessage(source); + return 1; + }) + .then(reinstallServer()); + } + + private static RequiredArgumentBuilder reinstallServer() { + return RequiredArgumentBuilder.argument("server", StringArgumentType.string()) + .suggests((context, builder) -> { + builder.suggest("this"); + SLS.servers.getShortIds().forEach(builder::suggest); + return builder.buildFuture(); + }) + .executes(context -> { + CommandSource source = context.getSource(); + String id = StringArgumentType.getString(context, "server"); + Server server = resolveServer(source, id); + if (server == null) { + if (!id.equals("this")) { + ProtoMessage.chat() + .add(MessagePreset.SLS) + .add("No such server " + id, NamedTextColor.RED) + .sendMessage(source); + } + return 0; + } + + server.reinstall().executeAsync(success -> ProtoMessage.chat() + .add(MessagePreset.SLS) + .add("Reinstall started for ", NamedTextColor.GRAY) + .add(server.getCompositeId(), NamedTextColor.GOLD) + .sendMessage(source), + failure -> Log.requestError("Failed to reinstall server " + server.getCompositeId(), failure, source)); + return 1; + }); + } + + private static String logsPaginationFooter(String serverId, int page, int perPage, int totalPages, int totalLines) { + String hover = CommandMessageParts.text("Total lines: " + totalLines + "\nLines per page: " + perPage); + String pageText = "PAGE " + page + "/" + totalPages + ""; + String previous = page > 1 + ? clickableArrow("«", "/sls install logs " + serverId + " " + (page - 1) + " " + perPage, "View newer logs") + : "«"; + String next = page < totalPages + ? clickableArrow("»", "/sls install logs " + serverId + " " + (page + 1) + " " + perPage, "View older logs") + : "»"; + + return "" + previous + " ------- " + pageText + " ------- " + next + ""; + } + + private static String clickableArrow(String arrow, String command, String hover) { + return "" + + "" + + "" + arrow + ""; + } + + // --- shared --- + + private static Server resolveServer(CommandSource source, String id) { + if (id.equals("this")) { + if (!(source instanceof Player player)) { + Log.warn("Invalid command usage! You must specify a server id when running this command from console."); + return null; + } + Server server = ServerUtils.getServer(player); + if (server == null) { + ProtoMessage.chat() + .add(MessagePreset.SLS) + .add("Server " + ServerUtils.getServerName(player) + " is not an SLS server", NamedTextColor.RED) + .sendMessage(source); + } + return server; + } + return SLS.servers.resolve(id); + } + + private static String phaseColor(InstallPhase phase) { + return switch (phase) { + case COMPLETED, IDLE -> "green"; + case INSTALLING, WARMING, POST_WARMUP -> "yellow"; + case INSTALL_FAILED, WARMUP_FAILED, POST_WARMUP_FAILED -> "red"; + default -> "gray"; + }; + } + + private static String formatInstant(Instant instant) { + return instant == null ? "Unknown" : TimeUtils.formatTimestamp(instant); + } + + private static String orUnknown(String value) { + return value != null && !value.isEmpty() ? value : "Unknown"; + } + + private static Integer parsePositiveInt(String value) { + try { + int parsed = Integer.parseInt(value); + return parsed > 0 ? parsed : null; + } catch (NumberFormatException e) { + return null; + } + } + + private static int clampLinesPerPage(int lines) { + return Math.min(lines, MAX_LINES_PER_PAGE); + } + + private static void invalidNumber(CommandSource source, String value) { + ProtoMessage.chat() + .add(MessagePreset.SLS) + .add("Invalid number " + value, NamedTextColor.RED) + .sendMessage(source); + } +} diff --git a/vsls/src/main/java/net/slimelabs/vsls/command/subcommand/KillCommand.java b/vsls/src/main/java/net/slimelabs/vsls/command/subcommand/KillCommand.java index 83de4833..4b0a0f96 100644 --- a/vsls/src/main/java/net/slimelabs/vsls/command/subcommand/KillCommand.java +++ b/vsls/src/main/java/net/slimelabs/vsls/command/subcommand/KillCommand.java @@ -99,75 +99,6 @@ private static RequiredArgumentBuilder server() { .sendMessage(source); } return 0; - }).then(force()); - } - - /** - * Unregisters the server regardless of whether it was killed successfully. - */ - private static RequiredArgumentBuilder force() { - return RequiredArgumentBuilder.argument("force", StringArgumentType.string()) - .suggests((context, builder) -> { - builder.suggest("force"); - return builder.buildFuture(); - }) - .executes(context -> { - CommandSource source = context.getSource(); - String id = StringArgumentType.getString(context, "server"); - String force = StringArgumentType.getString(context, "force"); - if(!Objects.equals(force, "force")) { - ProtoMessage.chat().add(MessagePreset.INCORRECT_COMMAND_USAGE).sendMessage(context.getSource()); - ProtoMessage.chat() - .add(MessageFormatter.commandUsage("/sls kill " + id, "force")) - .sendMessage(source); - return 0; - } - - if(id.equals("all")) { - if(SLS.servers.getAll().isEmpty()) { - ProtoMessage.chat() - .add(MessagePreset.SLS) - .add("No servers are running.", NamedTextColor.RED) - .sendMessage(source); - return 1; - } - SLS.servers.getAll().forEach(server -> - server.kill().executeAsync( - success -> {}, - failure -> { - Log.requestError("Failed to kill server " + server.getCompositeId(), failure, source); - SLS.servers.unRegister(server.getId()); - } - ) - ); - ProtoMessage.chat() - .add(MessagePreset.SLS) - .add("Killing all servers.", NamedTextColor.GRAY) - .sendMessage(source); - return 1; - } - Server server = SLS.servers.resolve(id); - if (server != null) { - server.kill().executeAsync( - success -> { - ProtoMessage.chat() - .add(MessagePreset.SLS) - .add("Killed " + id, NamedTextColor.GRAY) - .sendMessage(source); - }, - failure -> { - Log.requestError("Failed to kill server " + id, failure, source); - SLS.servers.unRegister(server.getId()); - } - ); - } else { - // No such server exists - ProtoMessage.chat() - .add(MessagePreset.SLS) - .add("No such server " + id, NamedTextColor.RED) - .sendMessage(source); - } - return 0; }); } diff --git a/vsls/src/main/java/net/slimelabs/vsls/command/subcommand/LogsCommand.java b/vsls/src/main/java/net/slimelabs/vsls/command/subcommand/LogsCommand.java index 206b212e..e6d1df70 100644 --- a/vsls/src/main/java/net/slimelabs/vsls/command/subcommand/LogsCommand.java +++ b/vsls/src/main/java/net/slimelabs/vsls/command/subcommand/LogsCommand.java @@ -3,6 +3,7 @@ import com.mojang.brigadier.arguments.StringArgumentType; import com.mojang.brigadier.builder.LiteralArgumentBuilder; import com.mojang.brigadier.builder.RequiredArgumentBuilder; +import com.protoxon.S4J.requests.PaginationAction; import com.velocitypowered.api.command.CommandSource; import com.velocitypowered.api.proxy.Player; import net.kyori.adventure.text.Component; @@ -13,12 +14,17 @@ import net.slimelabs.vsls.log.Log; import net.slimelabs.vsls.server.Server; import net.slimelabs.vsls.utils.ServerUtils; +import net.slimelabs.vsls.utils.message.CommandMessageParts; import net.slimelabs.vsls.utils.message.MessageFormatter; import net.slimelabs.vsls.utils.message.MessagePreset; import net.slimelabs.vsls.utils.message.ProtoMessage; public class LogsCommand { + private static final int DEFAULT_PAGE = 1; + private static final int DEFAULT_LINES_PER_PAGE = 50; + private static final int MAX_LINES_PER_PAGE = 1000; + public static LiteralArgumentBuilder register() { return LiteralArgumentBuilder.literal("logs") .requires(source -> source.hasPermission("sls.command.admin")) @@ -40,100 +46,170 @@ private static RequiredArgumentBuilder server() { SLS.servers.getShortIds().forEach(builder::suggest); return builder.buildFuture(); }) + .executes(context -> execute( + context.getSource(), + StringArgumentType.getString(context, "server"), + DEFAULT_PAGE, + DEFAULT_LINES_PER_PAGE)) + .then(page()); + } + + private static RequiredArgumentBuilder page() { + return RequiredArgumentBuilder.argument("page", StringArgumentType.string()) .executes(context -> { CommandSource source = context.getSource(); - String id = StringArgumentType.getString(context, "server"); - Server server; - if(id.equals("this")) { - if(!(source instanceof Player)) { - Log.warn("Invalid command usage! You must specify a server id when running this command from console."); - return 0; - } - server = ServerUtils.getServer((Player) source); - if(server == null) { - ProtoMessage.chat() - .add(MessagePreset.SLS) - .add("Server " + ServerUtils.getServerName((Player) source) + " is not an SLS server", NamedTextColor.RED) - .sendMessage(source); - return 0; - } - } else { - server = SLS.servers.resolve(id); - } - if (server != null) { - server.getLogs().executeAsync(logs -> { - ComponentBuilder builder = Component.text(); - ProtoMessage.chat().addMiniMessage("---- Logs for " + server.getCompositeId() + " ----\n").sendMessage(source); - for (String log : logs) { - builder.append( - Component.text(log + "\n", NamedTextColor.GRAY) - ); - } - source.sendMessage(builder.build()); - ProtoMessage.chat().addMiniMessage("-------- END --------").sendMessage(source); - }, failure -> Log.requestError("Failed to get logs for server " + id, failure, source)); - } else { - // No such server exists - ProtoMessage.chat() - .add(MessagePreset.SLS) - .add("No such server " + id, NamedTextColor.RED) - .sendMessage(source); + String pageString = StringArgumentType.getString(context, "page"); + Integer page = parsePositiveInt(pageString); + if (page == null) { + invalidNumber(source, pageString); + return 0; } - return 0; - }).then(lines()); + return execute( + source, + StringArgumentType.getString(context, "server"), + page, + DEFAULT_LINES_PER_PAGE); + }) + .then(linesPerPage()); } - private static RequiredArgumentBuilder lines() { + private static RequiredArgumentBuilder linesPerPage() { return RequiredArgumentBuilder.argument("lines", StringArgumentType.string()) + .suggests((context, builder) -> { + builder.suggest("max"); + return builder.buildFuture(); + }) .executes(context -> { CommandSource source = context.getSource(); - String id = StringArgumentType.getString(context, "server"); + String pageString = StringArgumentType.getString(context, "page"); String linesString = StringArgumentType.getString(context, "lines"); - int lines; - try { - lines = Integer.parseInt(linesString); - } catch (NumberFormatException e) { - ProtoMessage.chat().add(MessagePreset.SLS).add("Invalid number " + linesString, NamedTextColor.RED).sendMessage(source); + Integer page = parsePositiveInt(pageString); + if (page == null) { + invalidNumber(source, pageString); return 0; } - Server server; - if(id.equals("this")) { - if(!(source instanceof Player)) { - Log.warn("Invalid command usage! You must specify a server id when running this command from console."); - return 0; - } - server = ServerUtils.getServer((Player) source); - if(server == null) { - ProtoMessage.chat() - .add(MessagePreset.SLS) - .add("Server " + ServerUtils.getServerName((Player) source) + " is not an SLS server", NamedTextColor.RED) - .sendMessage(source); - return 0; - } - } else { - server = SLS.servers.resolve(id); + Integer lines = parsePositiveInt(linesString); + if(linesString.equals("max")) { + lines = MAX_LINES_PER_PAGE; } - if (server != null) { - server.getLogs(lines).executeAsync(logs -> { - ComponentBuilder builder = Component.text(); - ProtoMessage.chat().addMiniMessage("---- Logs for " + server.getCompositeId() + " ----\n").sendMessage(source); - for (String log : logs) { - builder.append( - Component.text(log + "\n", NamedTextColor.GRAY) - ); - } - source.sendMessage(builder.build()); - ProtoMessage.chat().addMiniMessage("-------- END --------").sendMessage(source); - }, failure -> Log.requestError("Failed to get logs for server " + id, failure, source)); - } else { - // No such server exists - ProtoMessage.chat() - .add(MessagePreset.SLS) - .add("No such server " + id, NamedTextColor.RED) - .sendMessage(source); + if (lines == null) { + invalidNumber(source, linesString); + return 0; } - return 0; + return execute( + source, + StringArgumentType.getString(context, "server"), + page, + clampLinesPerPage(lines)); }); } + private static int execute(CommandSource source, String id, int page, int linesPerPage) { + Server server = resolveServer(source, id); + if (server == null) { + if (!id.equals("this")) { + ProtoMessage.chat() + .add(MessagePreset.SLS) + .add("No such server " + id, NamedTextColor.RED) + .sendMessage(source); + } + return 0; + } + + displayLogs(source, server, id, page, linesPerPage); + return 0; + } + + private static void displayLogs(CommandSource source, Server server, String id, int page, int linesPerPage) { + PaginationAction action = server.getLogs(linesPerPage).skipTo(page); + action.executeAsync(logs -> { + int totalPages = action.getTotalPages(); + int totalLines = action.getTotal(); + int maxPage = Math.max(totalPages, 1); + + if (page > maxPage) { + ProtoMessage.chat() + .add(MessagePreset.SLS) + .add("Page " + page + " does not exist ", NamedTextColor.RED) + .add("(valid range: 1-" + maxPage + ")", NamedTextColor.GRAY) + .sendMessage(source); + return; + } + + ProtoMessage.chat() + .addMiniMessage("" + "--" + " Logs for " + server.getCompositeId() + " " + "--" + "\n") + .sendMessage(source); + + ComponentBuilder builder = Component.text(); + for (String log : logs) { + builder.append(Component.text(log + "\n", NamedTextColor.GRAY)); + } + source.sendMessage(builder.build()); + + ProtoMessage.chat() + .addMiniMessage(paginationFooter(id, page, linesPerPage, maxPage, totalLines)) + .sendMessage(source); + }, failure -> Log.requestError("Failed to get logs for server " + id, failure, source)); + } + + private static String paginationFooter(String serverId, int page, int perPage, int totalPages, int totalLines) { + String hover = CommandMessageParts.text("Total lines: " + totalLines + "\nLines per page: " + perPage); + String pageText = "PAGE " + page + "/" + totalPages + ""; + String previous = page > 1 + ? clickableArrow("«", logsCommand(serverId, page - 1, perPage), "View newer logs") + : "«"; + String next = page < totalPages + ? clickableArrow("»", logsCommand(serverId, page + 1, perPage), "View older logs") + : "»"; + + return "" + previous + " " + "-------" + " " + pageText + " " + "-------" + " " + next + ""; + } + + private static String clickableArrow(String arrow, String command, String hover) { + return "" + + "" + + "" + arrow + ""; + } + + private static String logsCommand(String serverId, int page, int perPage) { + return "/sls logs " + serverId + " " + page + " " + perPage; + } + + private static Server resolveServer(CommandSource source, String id) { + if (id.equals("this")) { + if (!(source instanceof Player player)) { + Log.warn("Invalid command usage! You must specify a server id when running this command from console."); + return null; + } + Server server = ServerUtils.getServer(player); + if (server == null) { + ProtoMessage.chat() + .add(MessagePreset.SLS) + .add("Server " + ServerUtils.getServerName(player) + " is not an SLS server", NamedTextColor.RED) + .sendMessage(source); + } + return server; + } + return SLS.servers.resolve(id); + } + + private static Integer parsePositiveInt(String value) { + try { + int parsed = Integer.parseInt(value); + return parsed > 0 ? parsed : null; + } catch (NumberFormatException e) { + return null; + } + } + + private static int clampLinesPerPage(int lines) { + return Math.min(lines, MAX_LINES_PER_PAGE); + } + + private static void invalidNumber(CommandSource source, String value) { + ProtoMessage.chat() + .add(MessagePreset.SLS) + .add("Invalid number " + value, NamedTextColor.RED) + .sendMessage(source); + } } diff --git a/vsls/src/main/java/net/slimelabs/vsls/command/subcommand/StopCommand.java b/vsls/src/main/java/net/slimelabs/vsls/command/subcommand/StopCommand.java index 305727e7..15e658ed 100644 --- a/vsls/src/main/java/net/slimelabs/vsls/command/subcommand/StopCommand.java +++ b/vsls/src/main/java/net/slimelabs/vsls/command/subcommand/StopCommand.java @@ -99,75 +99,6 @@ private static RequiredArgumentBuilder server() { .sendMessage(source); } return 0; - }).then(force()); - } - - /** - * Unregisters the server regardless of whether it shut down successfully. - */ - private static RequiredArgumentBuilder force() { - return RequiredArgumentBuilder.argument("force", StringArgumentType.string()) - .suggests((context, builder) -> { - builder.suggest("force"); - return builder.buildFuture(); - }) - .executes(context -> { - CommandSource source = context.getSource(); - String id = StringArgumentType.getString(context, "server"); - String force = StringArgumentType.getString(context, "force"); - if(!Objects.equals(force, "force")) { - ProtoMessage.chat().add(MessagePreset.INCORRECT_COMMAND_USAGE).sendMessage(context.getSource()); - ProtoMessage.chat() - .add(MessageFormatter.commandUsage("/sls stop " + id, "force")) - .sendMessage(source); - return 0; - } - - if(id.equals("all")) { - if(SLS.servers.getAll().isEmpty()) { - ProtoMessage.chat() - .add(MessagePreset.SLS) - .add("No servers are running.", NamedTextColor.RED) - .sendMessage(source); - return 1; - } - SLS.servers.getAll().forEach(server -> - server.stop().executeAsync( - success -> {}, - failure -> { - Log.requestError("Failed to stop server " + server.getCompositeId(), failure, source); - SLS.servers.unRegister(server.getId()); - } - ) - ); - ProtoMessage.chat() - .add(MessagePreset.SLS) - .add("Stopping all servers.", NamedTextColor.GRAY) - .sendMessage(source); - return 1; - } - Server server = SLS.servers.resolve(id); - if (server != null) { - server.stop().executeAsync( - success -> { - ProtoMessage.chat() - .add(MessagePreset.SLS) - .add("Shutdown " + id, NamedTextColor.GRAY) - .sendMessage(source); - }, - failure -> { - Log.requestError("Failed to stop server " + id, failure, source); - SLS.servers.unRegister(server.getId()); - } - ); - } else { - // No such server exists - ProtoMessage.chat() - .add(MessagePreset.SLS) - .add("No such server " + id, NamedTextColor.RED) - .sendMessage(source); - } - return 0; }); } diff --git a/vsls/src/main/java/net/slimelabs/vsls/server/Server.java b/vsls/src/main/java/net/slimelabs/vsls/server/Server.java index f11eda67..732f7bad 100644 --- a/vsls/src/main/java/net/slimelabs/vsls/server/Server.java +++ b/vsls/src/main/java/net/slimelabs/vsls/server/Server.java @@ -1,10 +1,13 @@ package net.slimelabs.vsls.server; +import com.protoxon.S4J.InstallInfo; import com.protoxon.S4J.SLSAction; import com.protoxon.S4J.ServerStats; import com.protoxon.S4J.ServerStatus; +import com.protoxon.S4J.requests.PaginationAction; import com.protoxon.S4J.client.entities.Allocation; import com.protoxon.S4J.client.entities.ClientServer; +import com.protoxon.S4J.client.entities.ServerLimits; import com.velocitypowered.api.proxy.Player; import com.velocitypowered.api.proxy.server.ServerInfo; import net.kyori.adventure.text.format.NamedTextColor; @@ -34,7 +37,7 @@ public class Server { // Runnable that removes this server from the registry when executed private final Runnable unregister; // The software version the server is using - private volatile String version; + private final String version; // Servers last updated status private volatile ServerStatus status = ServerStatus.UNKNOWN; // Server events @@ -51,6 +54,7 @@ public Server(String name, String idPrefix, ClientServer client, Runnable unregi this.shortId = id.length() >= 6 ? id.substring(0, 6) : id; this.compositeId = idPrefix + "." + shortId; this.unregister = unregister; + this.version = client.getSoftwareVersion(); new JoinActions(this); } @@ -145,14 +149,6 @@ public String getVersion() { return version; } - /** - * Sets the servers software version - * @param version the version to set - */ - protected void setVersion(String version) { - this.version = version; - } - /** * Returns the blueprint id the server is from * @return the blueprint id @@ -161,6 +157,34 @@ public String getBlueprintId() { return client.getBlueprintId(); } + /** + * Returns the software id configured for this server. + */ + public String getSoftwareId() { + return client.getSoftwareId(); + } + + /** + * Returns the software version configured for this server. + */ + public String getSoftwareVersion() { + return client.getSoftwareVersion(); + } + + /** + * Returns the container image configured for this server. + */ + public String getImage() { + return client.getImage(); + } + + /** + * Returns the resource limits configured for this server. + */ + public ServerLimits getLimits() { + return client.getLimits(); + } + public SLSAction stop() { return client.stop(); } @@ -262,24 +286,51 @@ public int getPlayerCount() { } /** - * Returns the most recent 100 log lines from the server. + * Returns server logs with pagination. Page 1 contains the most recent lines. * - * @return an action that resolves to a list of log lines + * @return a paginated action that resolves to a page of log lines */ - public SLSAction> getLogs() { + public PaginationAction getLogs() { return client.getLogs(); } /** - * Returns the specified number of most recent log lines from the server. + * Returns server logs with a custom page size. Page 1 contains the most recent lines. * - * @param lines the number of log lines to retrieve - * @return an action that resolves to a list of log lines + * @param lines the number of log lines per page + * @return a paginated action that resolves to a page of log lines */ - public SLSAction> getLogs(int lines) { + public PaginationAction getLogs(int lines) { return client.getLogs(lines); } + /** + * Returns install logs with pagination. Page 1 contains the most recent lines. + * + * @return a paginated action that resolves to a page of install log lines + */ + public PaginationAction getInstallLogs() { + return client.getInstallLogs(); + } + + public PaginationAction getInstallLogs(int lines) { + return client.getInstallLogs(lines); + } + + /** + * Returns the current installation state for this server. + */ + public SLSAction getInstallInfo() { + return client.getInstallInfo(); + } + + /** + * Reinstalls the server software using its configured installation script. + */ + public SLSAction reinstall() { + return client.reinstall(); + } + /** * Fetches the servers status from the remote node */ diff --git a/vsls/src/main/java/net/slimelabs/vsls/server/ServerManager.java b/vsls/src/main/java/net/slimelabs/vsls/server/ServerManager.java index f777a48d..2e0f9a56 100644 --- a/vsls/src/main/java/net/slimelabs/vsls/server/ServerManager.java +++ b/vsls/src/main/java/net/slimelabs/vsls/server/ServerManager.java @@ -13,7 +13,6 @@ import net.slimelabs.vsls.server.events.ServerEventRouter; import net.slimelabs.vsls.server.events.GlobalEvents; import net.slimelabs.vsls.server.lifecycle.LifecycleManager; -import net.slimelabs.vsls.utils.VersionFetcher; import net.slimelabs.vsls.utils.ViaVersion; import java.net.InetSocketAddress; @@ -98,11 +97,7 @@ public Server loadServer(ClientServer clientServer) { Log.warn("Blueprint not found for server {} with blueprint id: {}", clientServer.getId(), clientServer.getBlueprintId()); } register(server); - VersionFetcher.resolveVersion(clientServer.getOverrides(), blueprint, api.getAllServers().getS4J()) - .executeAsync( - version -> server.setVersion(version != null ? version : "null"), - failure -> Log.warn("Failed to resolve version for server {}: {}", clientServer.getId(), failure.info()) - ); + ViaVersion.register(server); // Fetch the servers status and update it locally clientServer.getStatus().executeAsync(server::setStatus); // Set weather to manage the servers lifecycle @@ -160,13 +155,9 @@ public SLSAction createServer(ServerCreationAction action, String name, return action.map(clientServer -> { Blueprint blueprint = SLS.blueprints.getBlueprint(action.getBlueprintId()); Server server = new Server(name, idPrefix, clientServer, () -> unRegister(clientServer.getId())); - // Set the version from the creation action or from the blueprint if not set - server.setVersion(!Objects.equals(action.getVersion(), "") - ? action.getVersion() - : (blueprint != null ? blueprint.getServerVersion() : "null")); - // Set weather to manage the servers lifecycle server.setLifecycleEnabled(!VslsAnnotations.dontStopWhenEmpty(blueprint)); register(server); + ViaVersion.register(server); return server; }); } @@ -206,8 +197,6 @@ public void register(Server server) { ); ServerInfo serverInfo = new ServerInfo(server.getCompositeId(), address); SLS.proxy.registerServer(serverInfo); - // Register the server with ViaVersion - ViaVersion.register(server); } /** diff --git a/vsls/src/main/java/net/slimelabs/vsls/utils/RetryConfig.java b/vsls/src/main/java/net/slimelabs/vsls/utils/RetryConfig.java deleted file mode 100644 index b06c652a..00000000 --- a/vsls/src/main/java/net/slimelabs/vsls/utils/RetryConfig.java +++ /dev/null @@ -1,44 +0,0 @@ -package net.slimelabs.vsls.utils; - -import java.time.Duration; - -/** - * Configuration for retry behavior with exponential backoff. - */ -public record RetryConfig( - int maxRetries, - Duration initialDelay, - Duration maxDelay -) { - private static final int DEFAULT_MAX_RETRIES = 3; - private static final Duration DEFAULT_INITIAL_DELAY = Duration.ofSeconds(1); - private static final Duration DEFAULT_MAX_DELAY = Duration.ofSeconds(16); - - /** - * Default config: 3 retries, 1s initial delay, 16s max delay (exponential backoff). - */ - public static final RetryConfig DEFAULT = new RetryConfig( - DEFAULT_MAX_RETRIES, - DEFAULT_INITIAL_DELAY, - DEFAULT_MAX_DELAY - ); - - public RetryConfig { - if (maxRetries < 1) throw new IllegalArgumentException("maxRetries must be >= 1"); - if (initialDelay.isNegative() || initialDelay.isZero()) { - throw new IllegalArgumentException("initialDelay must be positive"); - } - if (maxDelay.isNegative() || maxDelay.compareTo(initialDelay) < 0) { - throw new IllegalArgumentException("maxDelay must be >= initialDelay"); - } - } - - /** - * Delay before the (1-based) attempt. Attempt 1 = 0, attempt 2 = initialDelay, attempt 3 = 2*initialDelay, etc., capped at maxDelay. - */ - public long delayMillisBeforeAttempt(int attempt) { - if (attempt <= 1) return 0; - long delay = initialDelay.toMillis() * (1L << (attempt - 2)); - return Math.min(delay, maxDelay.toMillis()); - } -} diff --git a/vsls/src/main/java/net/slimelabs/vsls/utils/SimpleRequester.java b/vsls/src/main/java/net/slimelabs/vsls/utils/SimpleRequester.java deleted file mode 100644 index 4d8c9276..00000000 --- a/vsls/src/main/java/net/slimelabs/vsls/utils/SimpleRequester.java +++ /dev/null @@ -1,112 +0,0 @@ -package net.slimelabs.vsls.utils; - -import com.protoxon.S4J.SLSAction; -import com.protoxon.S4J.entities.S4J; -import com.protoxon.S4J.requests.SLSActionImpl; - -import java.io.IOException; -import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.time.Duration; - -/** - * Simple HTTP GET requester with retries and exponential backoff. - * Use for external APIs (e.g. Paper, Minecraft, Spigot). Returns an executable SLSAction - * so the request runs on S4J's supplier pool when executed async. - */ -public class SimpleRequester { - - private static final Duration DEFAULT_CONNECT_TIMEOUT = Duration.ofSeconds(10); - private static final Duration DEFAULT_READ_TIMEOUT = Duration.ofSeconds(10); - - private final RetryConfig retryConfig; - private final Duration readTimeout; - private final HttpClient client; - - public SimpleRequester() { - this(RetryConfig.DEFAULT); - } - - public SimpleRequester(RetryConfig retryConfig) { - this(retryConfig, DEFAULT_CONNECT_TIMEOUT, DEFAULT_READ_TIMEOUT); - } - - public SimpleRequester(RetryConfig retryConfig, Duration connectTimeout, Duration readTimeout) { - this.retryConfig = retryConfig; - this.readTimeout = readTimeout; - this.client = HttpClient.newBuilder() - .connectTimeout(connectTimeout) - .build(); - } - - /** - * Performs a GET with retries and exponential backoff. Blocks until done or all retries exhausted. - * Only retries on retriable errors: IO/network failures, HTTP 429, and 5xx. - * - * @param url the URL to GET - * @return response body as string, or null on failure or non-200 - */ - public String getBlocking(String url) { - HttpRequest request = HttpRequest.newBuilder() - .uri(URI.create(url)) - .timeout(readTimeout) - .GET() - .build(); - for (int attempt = 1; attempt <= retryConfig.maxRetries(); attempt++) { - if (attempt > 1) { - long delayMs = retryConfig.delayMillisBeforeAttempt(attempt); - if (delayMs > 0) { - try { - Thread.sleep(delayMs); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - return null; - } - } - } - try { - HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); - int status = response.statusCode(); - if (status == 200) { - return response.body(); - } - if (!isRetriableStatus(status)) { - return null; - } - } catch (IOException e) { - if (!isRetriable(e)) { - return null; - } - } catch (Exception e) { - return null; - } - } - return null; - } - - /** - * Returns an SLSAction that performs the GET when executed (on S4J's supplier pool). - * Use executeAsync(success, failure) to run without blocking. - * - * @param api S4J instance (for executor) - * @param url the URL to GET - * @return SLSAction yielding the response body string, or null on failure - */ - public SLSAction get(S4J api, String url) { - return SLSActionImpl.onExecute(api, () -> getBlocking(url)); - } - - private static boolean isRetriableStatus(int status) { - return status == 429 || (status >= 500 && status < 600); - } - - private static boolean isRetriable(Throwable t) { - if (t instanceof IOException) { - return true; - } - Throwable cause = t.getCause(); - return cause != null && isRetriable(cause); - } -} diff --git a/vsls/src/main/java/net/slimelabs/vsls/utils/VersionFetcher.java b/vsls/src/main/java/net/slimelabs/vsls/utils/VersionFetcher.java deleted file mode 100644 index 3700d225..00000000 --- a/vsls/src/main/java/net/slimelabs/vsls/utils/VersionFetcher.java +++ /dev/null @@ -1,91 +0,0 @@ -package net.slimelabs.vsls.utils; - -import com.protoxon.S4J.SLSAction; -import com.protoxon.S4J.client.entities.ServerOverrides; -import com.protoxon.S4J.entities.Blueprint; -import com.protoxon.S4J.entities.S4J; -import com.protoxon.S4J.requests.SLSActionImpl; -import org.json.JSONObject; - -import java.util.regex.Pattern; - -public class VersionFetcher { - - private static final String PAPER_VERSIONS_URL = "https://fill.papermc.io/v3/projects/paper"; - private static final Pattern VERSION_PATTERN = Pattern.compile("\\d+"); - private static final SimpleRequester REQUESTER = new SimpleRequester(); - - /** - * Resolves the version from overrides or blueprint. Returns an SLSAction that yields the version string. - * When the version is "latest", fetches from the Paper API (async); otherwise yields immediately. - */ - public static SLSAction resolveVersion(ServerOverrides overrides, Blueprint blueprint, S4J api) { - String versionOverride = overrides != null ? overrides.getVersion() : null; - String blueprintVersion = blueprint != null ? blueprint.getServerVersion() : null; - String software = blueprint != null ? blueprint.getServerSoftware() : null; - String rawVersion = (versionOverride != null && !versionOverride.isEmpty()) - ? versionOverride - : blueprintVersion; - - if (rawVersion == null || rawVersion.isEmpty()) { - return SLSActionImpl.onExecute(api, () -> "null"); - } - if ("latest".equalsIgnoreCase(rawVersion)) { - if(software == null) return SLSActionImpl.onExecute(api, () -> rawVersion); - if(software.equalsIgnoreCase("paper")) { - return fetchLatestPaperRelease(api); - } - } - return SLSActionImpl.onExecute(api, () -> rawVersion); - } - - /** - * Fetches the latest Paper release version from the Paper API. Returns an SLSAction that yields the version string. - */ - public static SLSAction fetchLatestPaperRelease(S4J api) { - return REQUESTER.get(api, PAPER_VERSIONS_URL) - .map(body -> body != null ? parseLatestVersion(body) : null); - } - - private static String parseLatestVersion(String body) { - try { - JSONObject json = new JSONObject(body); - JSONObject versions = json.getJSONObject("versions"); - if (versions == null || versions.isEmpty()) { - return null; - } - String latestMajor = versions.keySet().stream() - .max(VersionFetcher::compareVersionStrings) - .orElse(null); - if (latestMajor == null) { - return null; - } - return versions.getJSONArray(latestMajor).getString(0); - } catch (Exception e) { - return null; - } - } - - /** - * Compares two version strings (e.g. "1.21" vs "1.20"). Returns negative if a < b, positive if a > b, 0 if equal. - */ - private static int compareVersionStrings(String a, String b) { - int[] partsA = parseVersionParts(a); - int[] partsB = parseVersionParts(b); - int maxLen = Math.max(partsA.length, partsB.length); - for (int i = 0; i < maxLen; i++) { - int numA = i < partsA.length ? partsA[i] : 0; - int numB = i < partsB.length ? partsB[i] : 0; - if (numA != numB) { - return Integer.compare(numA, numB); - } - } - return 0; - } - - private static int[] parseVersionParts(String version) { - return VERSION_PATTERN.matcher(version).results() - .mapToInt(m -> Integer.parseInt(m.group())) - .toArray(); - } -} diff --git a/vsls/src/main/java/net/slimelabs/vsls/utils/ViaVersion.java b/vsls/src/main/java/net/slimelabs/vsls/utils/ViaVersion.java index ddb4b6cc..5e96372c 100644 --- a/vsls/src/main/java/net/slimelabs/vsls/utils/ViaVersion.java +++ b/vsls/src/main/java/net/slimelabs/vsls/utils/ViaVersion.java @@ -30,13 +30,28 @@ public class ViaVersion { * @param server the server to register with ViaVersion */ public static void register(Server server) { - if(!isUsingViaVersion()) return; // ViaVersion is not in use on the proxy so return - ProtocolVersion protocolId = ProtocolVersion.getClosest(server.getVersion()); // Get Mapping - if(protocolId == null) { - Log.error("failed to get protocol version for minecraft version {} while registering server {}", server.getVersion(), server.getCompositeId()); + if (!isUsingViaVersion()) return; + String version = resolveMinecraftVersion(server); + if (version == null) { + return; + } + ProtocolVersion protocolId = ProtocolVersion.getClosest(version); + if (protocolId == null) { + Log.error("failed to get protocol version for minecraft version {} while registering server {}", version, server.getCompositeId()); } else { - getDetector().setProtocolVersion(server.getCompositeId(), protocolId.getVersion()); // Register with ViaVersion + getDetector().setProtocolVersion(server.getCompositeId(), protocolId.getVersion()); + } + } + + private static String resolveMinecraftVersion(Server server) { + String version = server.getVersion(); + if (version == null || version.isEmpty() || "null".equals(version)) { + version = server.getSoftwareVersion(); + } + if (version == null || version.isEmpty() || "null".equals(version)) { + return null; } + return version; } /**