From 75084e3aea7ed3b4710c58deeab302ec02f6a1c7 Mon Sep 17 00:00:00 2001 From: jessefaler Date: Sun, 24 May 2026 20:43:31 -0500 Subject: [PATCH 01/41] update .gitignore --- .gitignore | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 3582f8c..0a4682a 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* From 8f78705912f0135bdddbd388246f77ca64c1ee32 Mon Sep 17 00:00:00 2001 From: Danil Vilmont <94568221+Yeetoxic@users.noreply.github.com> Date: Wed, 13 May 2026 13:00:42 -0700 Subject: [PATCH 02/41] initial commit --- S4J/ATTRIBUTION | 0 daemon/ATTRIBUTION | 0 daemon/LICENSE | 0 daemon/api/router/middleware/middleware.go | 0 daemon/api/router/middleware/request_error.go | 0 daemon/api/router/router.go | 0 daemon/cmd/command.go | 0 daemon/cmd/root.go | 0 daemon/config/config.go | 0 daemon/config/config_docker.go | 0 daemon/config/config_test.go | 0 daemon/docker-compose.yml | 6 ++---- daemon/environment/allocations.go | 0 daemon/environment/config.go | 0 daemon/environment/docker.go | 0 daemon/environment/docker/api.go | 0 daemon/environment/docker/container.go | 0 daemon/environment/docker/environment.go | 0 daemon/environment/docker/power.go | 0 daemon/environment/docker/stats.go | 0 daemon/environment/environment.go | 0 daemon/environment/settings.go | 0 daemon/environment/stats.go | 0 daemon/events/events.go | 0 daemon/events/events_test.go | 0 daemon/internal/message/banner.go | 0 daemon/internal/message/banner_test.go | 0 daemon/internal/progress/progress.go | 0 daemon/internal/progress/progress_test.go | 0 daemon/internal/ufs/LICENSE | 0 daemon/internal/ufs/README.md | 0 daemon/internal/ufs/error.go | 0 daemon/internal/ufs/file.go | 0 daemon/internal/ufs/file_posix.go | 0 daemon/internal/ufs/filesystem.go | 0 daemon/internal/ufs/fs_quota.go | 0 daemon/internal/ufs/fs_unix.go | 0 daemon/internal/ufs/fs_unix_test.go | 0 daemon/internal/ufs/go.LICENSE | 0 daemon/internal/ufs/mkdir_unix.go | 0 daemon/internal/ufs/path_unix.go | 0 daemon/internal/ufs/quota_writer.go | 0 daemon/internal/ufs/removeall_unix.go | 0 daemon/internal/ufs/stat_unix.go | 0 daemon/internal/ufs/walk.go | 0 daemon/internal/ufs/walk_unix.go | 0 daemon/log/cli/cli.go | 0 daemon/log/log.go | 0 daemon/parser/helpers.go | 0 daemon/parser/parser.go | 0 daemon/remote/errors.go | 0 daemon/remote/requests.go | 0 daemon/remote/servers.go | 0 daemon/remote/types.go | 0 daemon/run.sh | 0 daemon/server/config_parser.go | 0 daemon/server/configuration.go | 0 daemon/server/console.go | 0 daemon/server/crash.go | 0 daemon/server/errors.go | 0 daemon/server/events.go | 0 daemon/server/listeners.go | 0 daemon/server/manager.go | 0 daemon/server/mounts.go | 0 daemon/server/power.go | 0 daemon/server/resources.go | 0 daemon/server/server.go | 0 daemon/sls.go | 0 daemon/system/const.go | 0 daemon/system/locker.go | 0 daemon/system/rate.go | 0 daemon/system/sink_pool.go | 0 daemon/system/sink_pool_test.go | 0 daemon/system/utils.go | 0 protocube/ATTRIBUTION | 0 protocube/api/router/middleware/middleware.go | 0 protocube/api/router/middleware/ratelimit_test.sh | 0 protocube/api/router/middleware/request_error.go | 0 protocube/api/router/websocket/websocket.go | 0 protocube/blueprint/blueprint.go | 0 protocube/blueprint/blueprint_test.go | 0 protocube/blueprint/example.yml | 0 protocube/blueprint/parser.go | 0 protocube/blueprint/registry.go | 0 protocube/client/errors.go | 0 protocube/cmd/command.go | 0 protocube/cmd/root.go | 0 protocube/config/config.go | 0 protocube/config/config.yml | 0 protocube/config/config_test.go | 0 protocube/docker-compose.yml | 4 +++- protocube/environment/stats.go | 0 protocube/events/events.go | 0 protocube/events/events_test.go | 0 protocube/internal/message/banner.go | 0 protocube/internal/message/banner_test.go | 0 protocube/log/cli/cli.go | 0 protocube/log/log.go | 0 protocube/parser/parser.go | 0 protocube/protocube.go | 0 protocube/run.sh | 0 protocube/server/crash.go | 0 protocube/software/parser.go | 0 protocube/software/registry.go | 0 protocube/software/software.go | 0 protocube/software/software_test.go | 0 protocube/system/const.go | 0 protocube/system/sink_pool.go | 0 protocube/system/sink_pool_test.go | 0 protocube/system/system.go | 0 protocube/system/utils.go | 0 111 files changed, 5 insertions(+), 5 deletions(-) mode change 100755 => 100644 S4J/ATTRIBUTION mode change 100755 => 100644 daemon/ATTRIBUTION mode change 100755 => 100644 daemon/LICENSE mode change 100755 => 100644 daemon/api/router/middleware/middleware.go mode change 100755 => 100644 daemon/api/router/middleware/request_error.go mode change 100755 => 100644 daemon/api/router/router.go mode change 100755 => 100644 daemon/cmd/command.go mode change 100755 => 100644 daemon/cmd/root.go mode change 100755 => 100644 daemon/config/config.go mode change 100755 => 100644 daemon/config/config_docker.go mode change 100755 => 100644 daemon/config/config_test.go mode change 100755 => 100644 daemon/environment/allocations.go mode change 100755 => 100644 daemon/environment/config.go mode change 100755 => 100644 daemon/environment/docker.go mode change 100755 => 100644 daemon/environment/docker/api.go mode change 100755 => 100644 daemon/environment/docker/container.go mode change 100755 => 100644 daemon/environment/docker/environment.go mode change 100755 => 100644 daemon/environment/docker/power.go mode change 100755 => 100644 daemon/environment/docker/stats.go mode change 100755 => 100644 daemon/environment/environment.go mode change 100755 => 100644 daemon/environment/settings.go mode change 100755 => 100644 daemon/environment/stats.go mode change 100755 => 100644 daemon/events/events.go mode change 100755 => 100644 daemon/events/events_test.go mode change 100755 => 100644 daemon/internal/message/banner.go mode change 100755 => 100644 daemon/internal/message/banner_test.go mode change 100755 => 100644 daemon/internal/progress/progress.go mode change 100755 => 100644 daemon/internal/progress/progress_test.go mode change 100755 => 100644 daemon/internal/ufs/LICENSE mode change 100755 => 100644 daemon/internal/ufs/README.md mode change 100755 => 100644 daemon/internal/ufs/error.go mode change 100755 => 100644 daemon/internal/ufs/file.go mode change 100755 => 100644 daemon/internal/ufs/file_posix.go mode change 100755 => 100644 daemon/internal/ufs/filesystem.go mode change 100755 => 100644 daemon/internal/ufs/fs_quota.go mode change 100755 => 100644 daemon/internal/ufs/fs_unix.go mode change 100755 => 100644 daemon/internal/ufs/fs_unix_test.go mode change 100755 => 100644 daemon/internal/ufs/go.LICENSE mode change 100755 => 100644 daemon/internal/ufs/mkdir_unix.go mode change 100755 => 100644 daemon/internal/ufs/path_unix.go mode change 100755 => 100644 daemon/internal/ufs/quota_writer.go mode change 100755 => 100644 daemon/internal/ufs/removeall_unix.go mode change 100755 => 100644 daemon/internal/ufs/stat_unix.go mode change 100755 => 100644 daemon/internal/ufs/walk.go mode change 100755 => 100644 daemon/internal/ufs/walk_unix.go mode change 100755 => 100644 daemon/log/cli/cli.go mode change 100755 => 100644 daemon/log/log.go mode change 100755 => 100644 daemon/parser/helpers.go mode change 100755 => 100644 daemon/parser/parser.go mode change 100755 => 100644 daemon/remote/errors.go mode change 100755 => 100644 daemon/remote/requests.go mode change 100755 => 100644 daemon/remote/servers.go mode change 100755 => 100644 daemon/remote/types.go mode change 100755 => 100644 daemon/run.sh mode change 100755 => 100644 daemon/server/config_parser.go mode change 100755 => 100644 daemon/server/configuration.go mode change 100755 => 100644 daemon/server/console.go mode change 100755 => 100644 daemon/server/crash.go mode change 100755 => 100644 daemon/server/errors.go mode change 100755 => 100644 daemon/server/events.go mode change 100755 => 100644 daemon/server/listeners.go mode change 100755 => 100644 daemon/server/manager.go mode change 100755 => 100644 daemon/server/mounts.go mode change 100755 => 100644 daemon/server/power.go mode change 100755 => 100644 daemon/server/resources.go mode change 100755 => 100644 daemon/server/server.go mode change 100755 => 100644 daemon/sls.go mode change 100755 => 100644 daemon/system/const.go mode change 100755 => 100644 daemon/system/locker.go mode change 100755 => 100644 daemon/system/rate.go mode change 100755 => 100644 daemon/system/sink_pool.go mode change 100755 => 100644 daemon/system/sink_pool_test.go mode change 100755 => 100644 daemon/system/utils.go mode change 100755 => 100644 protocube/ATTRIBUTION mode change 100755 => 100644 protocube/api/router/middleware/middleware.go mode change 100755 => 100644 protocube/api/router/middleware/ratelimit_test.sh mode change 100755 => 100644 protocube/api/router/middleware/request_error.go mode change 100755 => 100644 protocube/api/router/websocket/websocket.go mode change 100755 => 100644 protocube/blueprint/blueprint.go mode change 100755 => 100644 protocube/blueprint/blueprint_test.go mode change 100755 => 100644 protocube/blueprint/example.yml mode change 100755 => 100644 protocube/blueprint/parser.go mode change 100755 => 100644 protocube/blueprint/registry.go mode change 100755 => 100644 protocube/client/errors.go mode change 100755 => 100644 protocube/cmd/command.go mode change 100755 => 100644 protocube/cmd/root.go mode change 100755 => 100644 protocube/config/config.go mode change 100755 => 100644 protocube/config/config.yml mode change 100755 => 100644 protocube/config/config_test.go mode change 100755 => 100644 protocube/environment/stats.go mode change 100755 => 100644 protocube/events/events.go mode change 100755 => 100644 protocube/events/events_test.go mode change 100755 => 100644 protocube/internal/message/banner.go mode change 100755 => 100644 protocube/internal/message/banner_test.go mode change 100755 => 100644 protocube/log/cli/cli.go mode change 100755 => 100644 protocube/log/log.go mode change 100755 => 100644 protocube/parser/parser.go mode change 100755 => 100644 protocube/protocube.go mode change 100755 => 100644 protocube/run.sh mode change 100755 => 100644 protocube/server/crash.go mode change 100755 => 100644 protocube/software/parser.go mode change 100755 => 100644 protocube/software/registry.go mode change 100755 => 100644 protocube/software/software.go mode change 100755 => 100644 protocube/software/software_test.go mode change 100755 => 100644 protocube/system/const.go mode change 100755 => 100644 protocube/system/sink_pool.go mode change 100755 => 100644 protocube/system/sink_pool_test.go mode change 100755 => 100644 protocube/system/system.go mode change 100755 => 100644 protocube/system/utils.go diff --git a/S4J/ATTRIBUTION b/S4J/ATTRIBUTION old mode 100755 new mode 100644 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 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 ac8c4ea..ed8e4b5 100644 --- a/daemon/docker-compose.yml +++ b/daemon/docker-compose.yml @@ -37,10 +37,8 @@ services: # given a bind mount of that path by the host Docker daemon; without propagation # the host never sees the overlay and server containers get an empty directory - type: bind - source: /var/lib/sls + source: /home/dani/sls-data target: /var/lib/sls - bind: - propagation: rshared networks: sls: @@ -50,4 +48,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 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 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/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/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/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 diff --git a/daemon/remote/types.go b/daemon/remote/types.go old mode 100755 new mode 100644 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 diff --git a/daemon/server/events.go b/daemon/server/events.go old mode 100755 new mode 100644 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 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 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 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 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/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/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/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 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 6947050..f7e1d91 100644 --- a/protocube/docker-compose.yml +++ b/protocube/docker-compose.yml @@ -1,6 +1,8 @@ services: protocube: - image: ghcr.io/jessefaler/sls/protocube:latest + build: + context: . + dockerfile: Dockerfile container_name: SLS restart: always ports: 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/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/parser/parser.go b/protocube/parser/parser.go old mode 100755 new mode 100644 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/crash.go b/protocube/server/crash.go old mode 100755 new mode 100644 diff --git a/protocube/software/parser.go b/protocube/software/parser.go old mode 100755 new mode 100644 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 diff --git a/protocube/software/software_test.go b/protocube/software/software_test.go old mode 100755 new mode 100644 diff --git a/protocube/system/const.go b/protocube/system/const.go old mode 100755 new mode 100644 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 From a04bfe0f06e127f4497b23f6d503dc9beecf665d Mon Sep 17 00:00:00 2001 From: Danil Vilmont <94568221+Yeetoxic@users.noreply.github.com> Date: Wed, 13 May 2026 13:12:37 -0700 Subject: [PATCH 03/41] Implemented the server snapshot approach. Servers now persist an immutable runtime snapshot in the DB: configuration plus install script. New servers resolve blueprint/software only at creation time. Node config list and per-server config fetch now read from the server snapshot, so deleted blueprints no longer break normal daemon sync for newly created servers. Install script endpoints also read from the stored server snapshot. Legacy rows without snapshots get a compatibility fallback: if their blueprint still exists, Protocube rebuilds and persists the snapshot on first access. --- protocube/api/router/router_node.go | 48 +++++++----- protocube/api/router/router_server.go | 20 ++--- protocube/api/router/router_system.go | 16 ++-- protocube/docker-compose.yml | 4 +- protocube/models/server.go | 15 ++-- protocube/server/manager.go | 96 +++++++++++++++++------ protocube/server/repository/repository.go | 14 ++++ protocube/server/server.go | 7 ++ 8 files changed, 149 insertions(+), 71 deletions(-) diff --git a/protocube/api/router/router_node.go b/protocube/api/router/router_node.go index f8b6d95..38a9016 100644 --- a/protocube/api/router/router_node.go +++ b/protocube/api/router/router_node.go @@ -201,15 +201,18 @@ 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) + configuration, err := server.GetServerConfiguration(s) if err != nil { - httperror.JSON(c, http.StatusNotFound, err.Error(), "Could not build server configuration.") - return + bp := r.BlueprintRegistry.Get(s.BlueprintId()) + if bp == nil { + httperror.JSON(c, http.StatusNotFound, "blueprint_id="+s.BlueprintId(), "Server configuration snapshot is missing and the referenced blueprint does not exist.") + return + } + configuration, err = server.EnsureServerSnapshot(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) } @@ -238,20 +241,23 @@ func (r *Router) getAllServerConfigurations(c *gin.Context) { // Build configurations for all servers configurations := make([]*models.ServerConfigurationResponse, 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) + configuration, err := server.GetServerConfiguration(s) 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 + bp := r.BlueprintRegistry.Get(s.BlueprintId()) + if bp == nil { + // Skip legacy servers with no snapshot and missing blueprints. + log.WithField("server", s.Id()).WithField("blueprint_id", s.BlueprintId()). + Warn("skipping server configuration: snapshot is missing and referenced blueprint does not exist") + continue + } + + configuration, err = server.EnsureServerSnapshot(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) diff --git a/protocube/api/router/router_server.go b/protocube/api/router/router_server.go index 3c1026b..6ba8bb3 100644 --- a/protocube/api/router/router_server.go +++ b/protocube/api/router/router_server.go @@ -9,6 +9,7 @@ import ( "protoxon.com/sls/protocube/api/router/middleware" "protoxon.com/sls/protocube/client" "protoxon.com/sls/protocube/models" + "protoxon.com/sls/protocube/server" ) func postServerPower(c *gin.Context) { @@ -132,21 +133,22 @@ func getServerLogs(c *gin.Context) { } func (r *Router) getInstallInfo(c *gin.Context) { - server := middleware.ExtractServer(c) + s := middleware.ExtractServer(c) + if s.InstallScript != nil { + c.JSON(http.StatusOK, s.InstallScript) + return + } // Get the servers blueprint - bp := r.BlueprintRegistry.Get(server.BlueprintId()) + bp := r.BlueprintRegistry.Get(s.BlueprintId()) if bp == nil { - httperror.JSON(c, http.StatusNotFound, "blueprint not found for install info", "Blueprint not found") + httperror.JSON(c, http.StatusNotFound, "blueprint not found for install info", "Install script snapshot is missing and the referenced blueprint does not exist.") 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") + if _, err := server.EnsureServerSnapshot(s, bp, r.SoftwareRegistry); err != nil { + httperror.JSON(c, http.StatusNotFound, err.Error(), "Could not build server installation data.") return } - // Return the installation script from the software configuration - c.JSON(http.StatusOK, sw.InstallScript) + c.JSON(http.StatusOK, s.InstallScript) } diff --git a/protocube/api/router/router_system.go b/protocube/api/router/router_system.go index 026d1e3..73071b9 100644 --- a/protocube/api/router/router_system.go +++ b/protocube/api/router/router_system.go @@ -14,6 +14,7 @@ import ( "protoxon.com/sls/protocube/events" "protoxon.com/sls/protocube/models" "protoxon.com/sls/protocube/node" + "protoxon.com/sls/protocube/server" "protoxon.com/sls/protocube/software" "protoxon.com/sls/protocube/system" ) @@ -74,18 +75,21 @@ func getBlueprint(c *gin.Context) { func (r *Router) getInstallationScript(c *gin.Context) { s := middleware.ExtractServer(c) - s.BlueprintId() + if s.InstallScript != nil { + c.JSON(http.StatusOK, s.InstallScript) + return + } + bp := r.BlueprintRegistry.Get(s.BlueprintId()) if bp == nil { - httperror.AbortWithJSON(c, http.StatusNotFound, "unknown blueprint "+s.BlueprintId(), "Blueprint not found for this server.") + httperror.AbortWithJSON(c, http.StatusNotFound, "unknown blueprint "+s.BlueprintId(), "Install script snapshot is missing and the referenced blueprint does not exist.") 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 _, err := server.EnsureServerSnapshot(s, bp, r.SoftwareRegistry); err != nil { + httperror.AbortWithJSON(c, http.StatusNotFound, err.Error(), "Could not build server installation data.") return } - c.JSON(http.StatusOK, sw.InstallScript) + c.JSON(http.StatusOK, s.InstallScript) } // Returns all servers diff --git a/protocube/docker-compose.yml b/protocube/docker-compose.yml index f7e1d91..6947050 100644 --- a/protocube/docker-compose.yml +++ b/protocube/docker-compose.yml @@ -1,8 +1,6 @@ services: protocube: - build: - context: . - dockerfile: Dockerfile + image: ghcr.io/jessefaler/sls/protocube:latest container_name: SLS restart: always ports: diff --git a/protocube/models/server.go b/protocube/models/server.go index 59d3cc7..eca8ba6 100644 --- a/protocube/models/server.go +++ b/protocube/models/server.go @@ -3,6 +3,7 @@ package models import ( "protoxon.com/sls/protocube/blueprint" "protoxon.com/sls/protocube/environment" + "protoxon.com/sls/protocube/software" "protoxon.com/sls/protocube/system" ) @@ -22,12 +23,14 @@ type ServerData struct { } 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"` + 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"` + Configuration *ServerConfigurationResponse `gorm:"serializer:json"` + InstallScript *software.InstallationScript `gorm:"serializer:json"` } type StatusResponse struct { diff --git a/protocube/server/manager.go b/protocube/server/manager.go index 0b1368e..f8c1775 100644 --- a/protocube/server/manager.go +++ b/protocube/server/manager.go @@ -168,20 +168,24 @@ func (m *Manager) CreateServer(ctx context.Context, node *node.Node, bp *bluepri // 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, - }); err != nil { + cfg, installScript, err := BuildServerSnapshot(s, bp, swr) + if err != nil { return nil, err } + s.Configuration = cfg + s.InstallScript = installScript - cfg, err := GetServerConfiguration(s, bp, swr) - if err != nil { + // 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, + Configuration: cfg, + InstallScript: installScript, + }); err != nil { return nil, err } @@ -196,7 +200,18 @@ 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) { +func GetServerConfiguration(s *Server) (*models.ServerConfigurationResponse, error) { + if s.Configuration == nil { + return nil, errors.New("server configuration snapshot is missing") + } + + cfg := *s.Configuration + cfg.Id = s.Id() + cfg.Allocations = s.Allocations + return &cfg, nil +} + +func BuildServerSnapshot(s *Server, bp *blueprint.Blueprint, swr *software.Registry) (*models.ServerConfigurationResponse, *software.InstallationScript, error) { effectiveSoftware := bp.Server.Software effectiveVersion := bp.Server.Version if s.Overrides != nil { @@ -210,12 +225,12 @@ func GetServerConfiguration(s *Server, bp *blueprint.Blueprint, swr *software.Re sw := swr.Get(effectiveSoftware) if sw == nil { - return nil, errors.Errorf("software not found: %s", effectiveSoftware) + return nil, 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) + 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 @@ -271,7 +286,7 @@ func GetServerConfiguration(s *Server, bp *blueprint.Blueprint, swr *software.Re if image == "" { selectedImage, err := sw.ImageForVersion(effectiveVersion) if err != nil { - return nil, errors.Wrapf(err, "failed to select image for version %s", effectiveVersion) + return nil, nil, errors.Wrapf(err, "failed to select image for version %s", effectiveVersion) } image = selectedImage } @@ -294,11 +309,39 @@ func GetServerConfiguration(s *Server, bp *blueprint.Blueprint, swr *software.Re Allocations: s.Allocations, Save: save, SoftwareId: sw.Id, - SoftwareVersion: bp.Server.Version, + SoftwareVersion: effectiveVersion, HasInstallScript: sw.InstallScript.Script != "", SkipInstallScript: sw.InstallScript.SkipScripts, } - return &nodeReq, nil + installScript := sw.InstallScript + return &nodeReq, &installScript, nil +} + +func EnsureServerSnapshot(s *Server, bp *blueprint.Blueprint, swr *software.Registry) (*models.ServerConfigurationResponse, error) { + cfg, err := GetServerConfiguration(s) + if err == nil && s.InstallScript != nil { + return cfg, nil + } + + if bp == nil { + if err == nil { + return cfg, nil + } + return nil, err + } + + cfg, installScript, err := BuildServerSnapshot(s, bp, swr) + if err != nil { + return nil, err + } + + s.Configuration = cfg + s.InstallScript = installScript + if err := repository.StoreServerSnapshot(s.Id(), cfg, installScript); err != nil { + log.WithError(err).WithField("server", s.Id()).Warn("failed to persist server snapshot") + } + + return GetServerConfiguration(s) } // mergeBlueprintState returns a new State with blueprint env merged with envOverride (override wins on key collision). @@ -375,14 +418,16 @@ func (m *Manager) InitServer(ctx context.Context, data *models.ServerStore, n *n // 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: data.Id, + nodeName: data.NodeName, + nodeId: data.NodeId, + blueprintId: data.BlueprintId, + Overrides: data.Overrides, + Configuration: data.Configuration, + InstallScript: data.InstallScript, + sc: serverClient, + GlobalEvents: m.Events, + Allocations: data.Allocation, Remove: func() { m.Remove(data.Id) }, @@ -397,4 +442,3 @@ func (m *Manager) InitServer(ctx context.Context, data *models.ServerStore, n *n m.Add(server) return server, nil } - diff --git a/protocube/server/repository/repository.go b/protocube/server/repository/repository.go index ab1305e..a915a84 100644 --- a/protocube/server/repository/repository.go +++ b/protocube/server/repository/repository.go @@ -4,6 +4,7 @@ import ( "emperror.dev/errors" "protoxon.com/sls/protocube/internal/database" "protoxon.com/sls/protocube/models" + "protoxon.com/sls/protocube/software" ) func StoreServer(server *models.ServerStore) error { @@ -13,6 +14,19 @@ func StoreServer(server *models.ServerStore) error { return nil } +func StoreServerSnapshot(id string, configuration *models.ServerConfigurationResponse, installScript *software.InstallationScript) error { + if err := database.Instance(). + Model(&models.ServerStore{}). + Where("id = ?", id). + Updates(&models.ServerStore{ + Configuration: configuration, + InstallScript: installScript, + }).Error; err != nil { + return errors.Wrap(err, "failed to save server snapshot to database") + } + return nil +} + func RemoveServer(id string) error { if err := database.Instance().Where("id = ?", id).Delete(&models.ServerStore{}).Error; err != nil { return errors.Wrap(err, "failed to remove server from database") diff --git a/protocube/server/server.go b/protocube/server/server.go index cc2d4cb..771a95e 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,6 +36,12 @@ type Server struct { // The crash handler for this server instance. crasher CrashHandler + // Configuration is the immutable runtime snapshot sent to the node. + Configuration *models.ServerConfigurationResponse + + // 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 From 67bf5a44bd981e166563312442bc27a5ba41f6c7 Mon Sep 17 00:00:00 2001 From: jessefaler Date: Sun, 24 May 2026 00:30:09 -0500 Subject: [PATCH 04/41] fix(protocube/parser): quote ReplaceValue strings during JSON marshaling Fix an issue where ReplaceValue strings loaded from stored config patches were marshaled as unquoted JSON, causing config patch unmarshaling to fail after restart. This prevented protocube from loading servers from the database and completing startup. --- protocube/parser/parser.go | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/protocube/parser/parser.go b/protocube/parser/parser.go index 67166e7..99049c1 100644 --- 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 } From ad35fe21d30ff73d006db8703536008f9cc447ec Mon Sep 17 00:00:00 2001 From: jessefaler Date: Mon, 25 May 2026 02:36:12 -0500 Subject: [PATCH 05/41] Refactor server configuration handling and runtime state management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update ServerData to include software ID, version, image, and limits - Update S4J to handle new values returned in the server data response - Update vSLS to use software ID, version, image, and limits from ServerData instead of the blueprint - Remove version fetcher from vSLS - Rename BuildServerSnapshot to BuildServerConfiguration - Move BuildServerConfiguration into configuration.go - Move mergeBlueprintState to blueprint/helpers.go and rename to MergeState - Move MergeLimits, ValidateLimits, and CopyLimits to environment/helpers.go - Move GetServerConfiguration into server.go and convert it to a Server method - Rename ServerConfigurationResponse to ServerConfiguration on both Protocube and the daemon Don’t attempt to reconstruct a server’s runtime configuration (ServerConfiguration) if it cannot be loaded. Instead, validate it, and if it is invalid, log an error and let the user handle it manually. This situation should only occur if database rows were modified directly. The EnsureServerSnapshot logic added unnecessary boilerplate to HTTP handlers, and it’s preferable to keep the code simple rather than account for an unlikely edge case. --- .../S4J/client/entities/ClientServer.java | 28 ++ .../entities/impl/ClientServerImpl.java | 23 ++ daemon/api/router/router_system.go | 2 +- daemon/models/server.go | 10 +- daemon/remote/client.go | 4 +- daemon/remote/servers.go | 14 +- daemon/server/manager.go | 2 +- daemon/server/server.go | 2 +- protocube/api/router/router_node.go | 42 +-- protocube/api/router/router_server.go | 50 ++-- protocube/api/router/router_system.go | 15 +- protocube/blueprint/helpers.go | 27 ++ protocube/client/node_client.go | 4 +- protocube/cmd/root.go | 4 +- protocube/environment/helpers.go | 92 +++++++ protocube/environment/settings.go | 91 ------- protocube/internal/database/database.go | 2 +- protocube/models/server.go | 92 ++++--- protocube/node/allocator/allocator.go | 2 +- protocube/node/node.go | 2 +- protocube/server/configuration.go | 119 +++++++++ protocube/server/manager.go | 242 +++--------------- protocube/server/repository/repository.go | 22 +- protocube/server/server.go | 39 ++- .../vsls/command/subcommand/InfoCommand.java | 10 +- .../net/slimelabs/vsls/server/Server.java | 40 ++- .../slimelabs/vsls/server/ServerManager.java | 15 +- .../net/slimelabs/vsls/utils/RetryConfig.java | 44 ---- .../slimelabs/vsls/utils/SimpleRequester.java | 112 -------- .../slimelabs/vsls/utils/VersionFetcher.java | 91 ------- .../net/slimelabs/vsls/utils/ViaVersion.java | 25 +- 31 files changed, 518 insertions(+), 749 deletions(-) create mode 100644 protocube/blueprint/helpers.go create mode 100644 protocube/environment/helpers.go create mode 100644 protocube/server/configuration.go delete mode 100644 vsls/src/main/java/net/slimelabs/vsls/utils/RetryConfig.java delete mode 100644 vsls/src/main/java/net/slimelabs/vsls/utils/SimpleRequester.java delete mode 100644 vsls/src/main/java/net/slimelabs/vsls/utils/VersionFetcher.java 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 b37ff0c..a334290 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 @@ -56,6 +56,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); /** 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 341da49..cc9fea4 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 @@ -6,6 +6,7 @@ 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.Route; import com.protoxon.S4J.requests.SLSActionImpl; @@ -21,6 +22,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 +30,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 +63,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()); diff --git a/daemon/api/router/router_system.go b/daemon/api/router/router_system.go index bfd0259..448f44a 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/models/server.go b/daemon/models/server.go index 5b312ad..ec533ca 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/remote/client.go b/daemon/remote/client.go index 5807857..349b3e9 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/servers.go b/daemon/remote/servers.go index 92c3495..b2300bf 100644 --- 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/server/manager.go b/daemon/server/manager.go index 878a224..0a93789 100644 --- a/daemon/server/manager.go +++ b/daemon/server/manager.go @@ -92,7 +92,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/server.go b/daemon/server/server.go index 4c98226..9bd0586 100644 --- a/daemon/server/server.go +++ b/daemon/server/server.go @@ -347,7 +347,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/protocube/api/router/router_node.go b/protocube/api/router/router_node.go index 38a9016..eaa5283 100644 --- a/protocube/api/router/router_node.go +++ b/protocube/api/router/router_node.go @@ -201,20 +201,7 @@ func toggleNodeDrained(c *gin.Context) { func (r *Router) getServerConfiguration(c *gin.Context) { s := middleware.ExtractServer(c) - configuration, err := server.GetServerConfiguration(s) - if err != nil { - bp := r.BlueprintRegistry.Get(s.BlueprintId()) - if bp == nil { - httperror.JSON(c, http.StatusNotFound, "blueprint_id="+s.BlueprintId(), "Server configuration snapshot is missing and the referenced blueprint does not exist.") - return - } - configuration, err = server.EnsureServerSnapshot(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) { @@ -239,28 +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 { - configuration, err := server.GetServerConfiguration(s) - if err != nil { - bp := r.BlueprintRegistry.Get(s.BlueprintId()) - if bp == nil { - // Skip legacy servers with no snapshot and missing blueprints. - log.WithField("server", s.Id()).WithField("blueprint_id", s.BlueprintId()). - Warn("skipping server configuration: snapshot is missing and referenced blueprint does not exist") - continue - } - - configuration, err = server.EnsureServerSnapshot(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 @@ -276,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 6ba8bb3..f2bf7a8 100644 --- a/protocube/api/router/router_server.go +++ b/protocube/api/router/router_server.go @@ -9,18 +9,17 @@ import ( "protoxon.com/sls/protocube/api/router/middleware" "protoxon.com/sls/protocube/client" "protoxon.com/sls/protocube/models" - "protoxon.com/sls/protocube/server" ) 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) @@ -31,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 } @@ -77,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"` @@ -88,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) @@ -99,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 @@ -111,9 +109,9 @@ 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 + // Parse the size query parameter size, _ := strconv.Atoi(c.DefaultQuery("size", "100")) if size <= 0 { size = 100 @@ -122,33 +120,21 @@ func getServerLogs(c *gin.Context) { } // Make the request to the daemon - logs, err := server.GetLogs(c.Request.Context(), size) + logs, err := s.GetLogs(c.Request.Context(), size) if err != nil { client.HandleError(c, err) return } - // Return the logs response + // Return the logs in the response c.JSON(http.StatusOK, logs) } func (r *Router) getInstallInfo(c *gin.Context) { s := middleware.ExtractServer(c) - if s.InstallScript != nil { - c.JSON(http.StatusOK, s.InstallScript) - return - } - - // Get the servers blueprint - bp := r.BlueprintRegistry.Get(s.BlueprintId()) - if bp == nil { - httperror.JSON(c, http.StatusNotFound, "blueprint not found for install info", "Install script snapshot is missing and the referenced blueprint does not exist.") + if s.InstallScript == nil { + httperror.JSON(c, http.StatusInternalServerError, "install script is missing", "Server install script is missing.") return } - if _, err := server.EnsureServerSnapshot(s, bp, r.SoftwareRegistry); err != nil { - httperror.JSON(c, http.StatusNotFound, err.Error(), "Could not build server installation data.") - return - } - c.JSON(http.StatusOK, s.InstallScript) } diff --git a/protocube/api/router/router_system.go b/protocube/api/router/router_system.go index 73071b9..4f4dd9b 100644 --- a/protocube/api/router/router_system.go +++ b/protocube/api/router/router_system.go @@ -14,7 +14,6 @@ import ( "protoxon.com/sls/protocube/events" "protoxon.com/sls/protocube/models" "protoxon.com/sls/protocube/node" - "protoxon.com/sls/protocube/server" "protoxon.com/sls/protocube/software" "protoxon.com/sls/protocube/system" ) @@ -75,18 +74,8 @@ func getBlueprint(c *gin.Context) { func (r *Router) getInstallationScript(c *gin.Context) { s := middleware.ExtractServer(c) - if s.InstallScript != nil { - c.JSON(http.StatusOK, s.InstallScript) - return - } - - bp := r.BlueprintRegistry.Get(s.BlueprintId()) - if bp == nil { - httperror.AbortWithJSON(c, http.StatusNotFound, "unknown blueprint "+s.BlueprintId(), "Install script snapshot is missing and the referenced blueprint does not exist.") - return - } - if _, err := server.EnsureServerSnapshot(s, bp, r.SoftwareRegistry); err != nil { - httperror.AbortWithJSON(c, http.StatusNotFound, err.Error(), "Could not build server installation data.") + if s.InstallScript == nil { + httperror.AbortWithJSON(c, http.StatusInternalServerError, "install script is missing", "Server install script is missing.") return } c.JSON(http.StatusOK, s.InstallScript) diff --git a/protocube/blueprint/helpers.go b/protocube/blueprint/helpers.go new file mode 100644 index 0000000..d9121e8 --- /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/client/node_client.go b/protocube/client/node_client.go index e5b5c74..59b63e8 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/cmd/root.go b/protocube/cmd/root.go index 78fd5eb..60543ab 100644 --- 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,7 +48,7 @@ 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()) } diff --git a/protocube/environment/helpers.go b/protocube/environment/helpers.go new file mode 100644 index 0000000..3370238 --- /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 < 10 || *limit.IoWeight > 1000) { + return errors.New("io_weight must be between 10 and 1000") + } + + return nil +} diff --git a/protocube/environment/settings.go b/protocube/environment/settings.go index eb99166..92e2eab 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/internal/database/database.go b/protocube/internal/database/database.go index 1d08998..5b294d2 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/models/server.go b/protocube/models/server.go index eca8ba6..703c003 100644 --- a/protocube/models/server.go +++ b/protocube/models/server.go @@ -1,6 +1,8 @@ package models import ( + "errors" + "protoxon.com/sls/protocube/blueprint" "protoxon.com/sls/protocube/environment" "protoxon.com/sls/protocube/software" @@ -12,27 +14,62 @@ 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"` } -type ServerStore struct { +// 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"` - Allocation environment.Allocations `gorm:"serializer:json"` Overrides *ServerOverrides `gorm:"serializer:json"` - Configuration *ServerConfigurationResponse `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 +} + +// 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"` } @@ -59,35 +96,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"` @@ -95,10 +103,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 d12cffa..36ead70 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/node.go b/protocube/node/node.go index 50fc921..c098252 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/server/configuration.go b/protocube/server/configuration.go new file mode 100644 index 0000000..9b85e6d --- /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) + + nodeReq := 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 &nodeReq, &installScript, nil +} diff --git a/protocube/server/manager.go b/protocube/server/manager.go index f8c1775..3167fa7 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,13 @@ 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) + } } } } @@ -149,7 +148,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,23 +163,22 @@ func (m *Manager) CreateServer(ctx context.Context, node *node.Node, bp *bluepri } }() - // Add the server to the manager - m.Add(s) - - cfg, installScript, err := BuildServerSnapshot(s, bp, swr) + 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{ + if err := repository.StoreServer(&models.ServerRecord{ Id: serverId, NodeName: node.Name(), NodeId: node.Id(), BlueprintId: bp.Meta.ID, - Allocation: alloc, Overrides: overrides, Configuration: cfg, InstallScript: installScript, @@ -200,172 +197,8 @@ func (m *Manager) CreateServer(ctx context.Context, node *node.Node, bp *bluepri return s, nil } -func GetServerConfiguration(s *Server) (*models.ServerConfigurationResponse, error) { - if s.Configuration == nil { - return nil, errors.New("server configuration snapshot is missing") - } - - cfg := *s.Configuration - cfg.Id = s.Id() - cfg.Allocations = s.Allocations - return &cfg, nil -} - -func BuildServerSnapshot(s *Server, bp *blueprint.Blueprint, swr *software.Registry) (*models.ServerConfigurationResponse, *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 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: effectiveVersion, - HasInstallScript: sw.InstallScript.Script != "", - SkipInstallScript: sw.InstallScript.SkipScripts, - } - installScript := sw.InstallScript - return &nodeReq, &installScript, nil -} - -func EnsureServerSnapshot(s *Server, bp *blueprint.Blueprint, swr *software.Registry) (*models.ServerConfigurationResponse, error) { - cfg, err := GetServerConfiguration(s) - if err == nil && s.InstallScript != nil { - return cfg, nil - } - - if bp == nil { - if err == nil { - return cfg, nil - } - return nil, err - } - - cfg, installScript, err := BuildServerSnapshot(s, bp, swr) - if err != nil { - return nil, err - } - - s.Configuration = cfg - s.InstallScript = installScript - if err := repository.StoreServerSnapshot(s.Id(), cfg, installScript); err != nil { - log.WithError(err).WithField("server", s.Id()).Warn("failed to persist server snapshot") - } - - return GetServerConfiguration(s) -} - -// 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") @@ -380,9 +213,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) @@ -399,46 +232,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, - Configuration: data.Configuration, - InstallScript: data.InstallScript, + 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, - Allocations: data.Allocation, 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 a915a84..d21698d 100644 --- a/protocube/server/repository/repository.go +++ b/protocube/server/repository/repository.go @@ -4,39 +4,25 @@ import ( "emperror.dev/errors" "protoxon.com/sls/protocube/internal/database" "protoxon.com/sls/protocube/models" - "protoxon.com/sls/protocube/software" ) -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") } return nil } -func StoreServerSnapshot(id string, configuration *models.ServerConfigurationResponse, installScript *software.InstallationScript) error { - if err := database.Instance(). - Model(&models.ServerStore{}). - Where("id = ?", id). - Updates(&models.ServerStore{ - Configuration: configuration, - InstallScript: installScript, - }).Error; err != nil { - return errors.Wrap(err, "failed to save server snapshot to database") - } - return nil -} - 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 771a95e..82b01ba 100644 --- a/protocube/server/server.go +++ b/protocube/server/server.go @@ -36,8 +36,8 @@ type Server struct { // The crash handler for this server instance. crasher CrashHandler - // Configuration is the immutable runtime snapshot sent to the node. - Configuration *models.ServerConfigurationResponse + // Configuration is the servers runtime configuration information + Configuration *models.ServerConfiguration // InstallScript is copied from the selected software at creation time. InstallScript *software.InstallationScript @@ -45,8 +45,17 @@ type Server struct { // 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 +} + +// GetServerConfiguration returns the runtime configuration info for the server. +func (s *Server) GetServerConfiguration() *models.ServerConfiguration { + cfg := *s.Configuration + cfg.Id = s.Id() + return &cfg +} - Allocations environment.Allocations +func (s *Server) allocations() *environment.Allocations { + return &s.Configuration.Allocations } func (s *Server) Id() string { @@ -137,15 +146,21 @@ func (s *Server) GetLogs(ctx context.Context, size int) (gin.H, error) { } // 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 { @@ -158,8 +173,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/vsls/src/main/java/net/slimelabs/vsls/command/subcommand/InfoCommand.java b/vsls/src/main/java/net/slimelabs/vsls/command/subcommand/InfoCommand.java index b0fa3eb..ca49c47 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/server/Server.java b/vsls/src/main/java/net/slimelabs/vsls/server/Server.java index f11eda6..21618fb 100644 --- a/vsls/src/main/java/net/slimelabs/vsls/server/Server.java +++ b/vsls/src/main/java/net/slimelabs/vsls/server/Server.java @@ -5,6 +5,7 @@ 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.velocitypowered.api.proxy.Player; import com.velocitypowered.api.proxy.server.ServerInfo; import net.kyori.adventure.text.format.NamedTextColor; @@ -34,7 +35,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 +52,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 +147,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 +155,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(); } 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 f777a48..2e0f9a5 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 b06c652..0000000 --- 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 4d8c927..0000000 --- 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 3700d22..0000000 --- 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 ddb4b6c..5e96372 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; } /** From e88a2357334e91d97b51f4f584dbc92d091a827d Mon Sep 17 00:00:00 2001 From: jessefaler Date: Mon, 25 May 2026 17:11:04 -0500 Subject: [PATCH 06/41] remove force from kill and stop commands and unregister the server in the delete command if force is set force on the kill and stop commands was left over from the old way vSLS handled server registration where vSLS only tracked servers that it created and removed them if they stopped running --- protocube/api/router/router_server.go | 4 -- protocube/server/configuration.go | 4 +- .../command/subcommand/DeleteCommand.java | 9 ++- .../vsls/command/subcommand/KillCommand.java | 69 ------------------- .../vsls/command/subcommand/StopCommand.java | 69 ------------------- 5 files changed, 10 insertions(+), 145 deletions(-) diff --git a/protocube/api/router/router_server.go b/protocube/api/router/router_server.go index f2bf7a8..83d1d8b 100644 --- a/protocube/api/router/router_server.go +++ b/protocube/api/router/router_server.go @@ -132,9 +132,5 @@ func getServerLogs(c *gin.Context) { func (r *Router) getInstallInfo(c *gin.Context) { s := middleware.ExtractServer(c) - if s.InstallScript == nil { - httperror.JSON(c, http.StatusInternalServerError, "install script is missing", "Server install script is missing.") - return - } c.JSON(http.StatusOK, s.InstallScript) } diff --git a/protocube/server/configuration.go b/protocube/server/configuration.go index 9b85e6d..27f0890 100644 --- a/protocube/server/configuration.go +++ b/protocube/server/configuration.go @@ -98,7 +98,7 @@ func BuildServerConfiguration(s *Server, bp *blueprint.Blueprint, swr *software. } effectiveState := blueprint.MergeState(bp.State, env) - nodeReq := models.ServerConfiguration{ + cfg := models.ServerConfiguration{ Id: s.Id(), ProcessConfiguration: pc, Image: image, @@ -115,5 +115,5 @@ func BuildServerConfiguration(s *Server, bp *blueprint.Blueprint, swr *software. } installScript := sw.InstallScript - return &nodeReq, &installScript, nil + return &cfg, &installScript, nil } 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 4a15ac3..1ca9d44 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/KillCommand.java b/vsls/src/main/java/net/slimelabs/vsls/command/subcommand/KillCommand.java index 83de483..4b0a0f9 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/StopCommand.java b/vsls/src/main/java/net/slimelabs/vsls/command/subcommand/StopCommand.java index 305727e..15e658e 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; }); } From 0f85573c6bc2688cde449f8a8ef3719609c135f2 Mon Sep 17 00:00:00 2001 From: Danil Vilmont <94568221+Yeetoxic@users.noreply.github.com> Date: Mon, 25 May 2026 19:44:31 -0700 Subject: [PATCH 07/41] Added daemon install lifecycle visibility Track install phase, installer container metadata, exit state, failure reason, timestamps, and recent logs from the daemon. Expose install state through daemon and Protocube APIs so clients can query install progress without talking to the daemon directly. Allow io_weight 0 to omit Docker blkio weight for local Docker/WSL testing environments that do not support io.weight. --- daemon/api/router/router.go | 1 + daemon/api/router/router_server.go | 6 + daemon/environment/settings.go | 5 +- daemon/server/install.go | 14 ++ daemon/server/install_state.go | 242 ++++++++++++++++++++++++++ daemon/server/server.go | 3 + protocube/api/router/router.go | 2 +- protocube/api/router/router_server.go | 11 ++ protocube/client/server_client.go | 6 + protocube/environment/helpers.go | 4 +- protocube/models/server.go | 13 ++ protocube/server/server.go | 4 + 12 files changed, 307 insertions(+), 4 deletions(-) create mode 100644 daemon/server/install_state.go diff --git a/daemon/api/router/router.go b/daemon/api/router/router.go index 9b3a3f1..5fe9d42 100644 --- a/daemon/api/router/router.go +++ b/daemon/api/router/router.go @@ -33,6 +33,7 @@ func (r *Router) Configure() *gin.Engine { server.DELETE("", r.deleteServer) server.GET("/logs", getServerLogs) + server.GET("/install", getServerInstallInfo) server.POST("/power", postServerPower) server.GET("/status", getServerStatus) server.GET("/stats", getServerStats) diff --git a/daemon/api/router/router_server.go b/daemon/api/router/router_server.go index b527842..20b49f9 100644 --- a/daemon/api/router/router_server.go +++ b/daemon/api/router/router_server.go @@ -94,6 +94,12 @@ func getServerStats(c *gin.Context) { c.JSON(http.StatusOK, s.Proc()) } +func getServerInstallInfo(c *gin.Context) { + s := middleware.ExtractServer(c) + l, _ := strconv.Atoi(c.DefaultQuery("size", "100")) + c.JSON(http.StatusOK, s.InstallInfo(c.Request.Context(), l)) +} + // Sends an array of commands to a running server instance. func postServerCommands(c *gin.Context) { s := middleware.ExtractServer(c) diff --git a/daemon/environment/settings.go b/daemon/environment/settings.go index f650e49..cdc2dae 100644 --- 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/server/install.go b/daemon/server/install.go index 9044bc2..1a79b89 100644 --- a/daemon/server/install.go +++ b/daemon/server/install.go @@ -39,17 +39,26 @@ func (s *Server) Install(ctx context.Context) error { func (s *Server) install(ctx context.Context, reinstall bool) error { var err error if !s.Config().SkipInstallScripts { + installerName := s.ID() + "_installer" + s.StartInstallPhase(InstallPhaseInstalling, installerName, filepath.Join(config.Get().System.LogDirectory, "/install", s.ID()+".log")) + // 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(InstallPhaseReady, "") } // 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(InstallPhaseReady, "") + } else { + 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 { @@ -528,11 +537,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 +562,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_state.go b/daemon/server/install_state.go new file mode 100644 index 0000000..efbb4b8 --- /dev/null +++ b/daemon/server/install_state.go @@ -0,0 +1,242 @@ +package server + +import ( + "bufio" + "context" + "os" + "path/filepath" + "strconv" + "sync" + "time" + + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/client" + "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" + InstallPhaseReady InstallPhase = "ready" +) + +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"` + Logs []string `json:"logs"` +} + +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 == InstallPhaseReady && s.installState.exitCode == nil { + var code int64 + s.installState.exitCode = &code + } +} + +func (s *Server) InstallInfo(ctx context.Context, lines int) InstallInfo { + if lines <= 0 { + lines = 100 + } else if lines > 500 { + lines = 500 + } + + info := s.installInfoSnapshot() + s.enrichInstallInfoFromDocker(ctx, &info, lines) + if len(info.Logs) == 0 { + info.Logs = readTailLines(s.installLogPath(), lines) + } + if len(info.Logs) == 0 { + info.Logs = []string{} + } + 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) enrichInstallInfoFromDocker(ctx context.Context, info *InstallInfo, lines int) { + 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 + } + } + } + + reader, err := cli.ContainerLogs(ctx, containerRef, container.LogsOptions{ + ShowStdout: true, + ShowStderr: true, + Tail: strconv.Itoa(lines), + }) + if err != nil { + return + } + defer reader.Close() + + scanner := bufio.NewScanner(reader) + for scanner.Scan() { + info.Logs = append(info.Logs, scanner.Text()) + } +} + +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 len(lines) > max { + lines = lines[len(lines)-max:] + } + } + return lines +} + +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/server.go b/daemon/server/server.go index 9bd0586..68ca23c 100644 --- a/daemon/server/server.go +++ b/daemon/server/server.go @@ -65,6 +65,8 @@ type Server struct { // Tracks if we've already emitted the very first status update. initialStateBroadcast *system.AtomicBool + + installState *installState } func New(client remote.Client) (*Server, error) { @@ -83,6 +85,7 @@ func New(client remote.Client) (*Server, error) { State: system.NewAtomicString("offline"), }, initialStateBroadcast: system.NewAtomicBool(false), + installState: newInstallState(), } return server, nil diff --git a/protocube/api/router/router.go b/protocube/api/router/router.go index 496eb90..36194d4 100644 --- a/protocube/api/router/router.go +++ b/protocube/api/router/router.go @@ -58,7 +58,7 @@ func (r *Router) Configure() *gin.Engine { server.GET("/stats", getServerStats) server.POST("/commands", postServerCommands) server.POST("/reset", postServerReset) - server.GET("/install", r.getInstallationScript) + server.GET("/install", getServerInstallInfo) //server.POST("/reinstall", postServerReinstall) //server.POST("/sync", postServerSync) //server.POST("/ws/deny", postServerDenyWSTokens) diff --git a/protocube/api/router/router_server.go b/protocube/api/router/router_server.go index 83d1d8b..ef666b2 100644 --- a/protocube/api/router/router_server.go +++ b/protocube/api/router/router_server.go @@ -130,6 +130,17 @@ func getServerLogs(c *gin.Context) { c.JSON(http.StatusOK, logs) } +func getServerInstallInfo(c *gin.Context) { + s := middleware.ExtractServer(c) + size, _ := strconv.Atoi(c.DefaultQuery("size", "100")) + info, err := s.InstallInfo(c.Request.Context(), size) + if err != nil { + client.HandleError(c, err) + return + } + c.JSON(http.StatusOK, info) +} + func (r *Router) getInstallInfo(c *gin.Context) { s := middleware.ExtractServer(c) c.JSON(http.StatusOK, s.InstallScript) diff --git a/protocube/client/server_client.go b/protocube/client/server_client.go index 0152c87..4521293 100644 --- a/protocube/client/server_client.go +++ b/protocube/client/server_client.go @@ -23,6 +23,7 @@ type ServerClient interface { Power(ctx context.Context, action models.PowerAction) error Reset(ctx context.Context) error Logs(ctx context.Context, size int) (gin.H, error) + InstallInfo(ctx context.Context, size int) (models.InstallInfo, error) Stats(ctx context.Context, update bool) (models.ResourceUsage, error) Status(ctx context.Context) (string, error) GetServer(ctx context.Context) (models.ServerData, error) @@ -114,3 +115,8 @@ 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) InstallInfo(ctx context.Context, size int) (models.InstallInfo, error) { + query := q{"size": strconv.Itoa(size)} + return Get[models.InstallInfo](sc, ctx, "/install", query) +} diff --git a/protocube/environment/helpers.go b/protocube/environment/helpers.go index 3370238..9013ffa 100644 --- a/protocube/environment/helpers.go +++ b/protocube/environment/helpers.go @@ -84,8 +84,8 @@ func ValidateLimits(limit *Limits) error { return err } - if limit.IoWeight != nil && (*limit.IoWeight < 10 || *limit.IoWeight > 1000) { - return errors.New("io_weight must be between 10 and 1000") + 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/models/server.go b/protocube/models/server.go index 703c003..ad46586 100644 --- a/protocube/models/server.go +++ b/protocube/models/server.go @@ -2,6 +2,7 @@ package models import ( "errors" + "time" "protoxon.com/sls/protocube/blueprint" "protoxon.com/sls/protocube/environment" @@ -74,6 +75,18 @@ 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"` + Logs []string `json:"logs"` +} + // 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. diff --git a/protocube/server/server.go b/protocube/server/server.go index 82b01ba..5777732 100644 --- a/protocube/server/server.go +++ b/protocube/server/server.go @@ -145,6 +145,10 @@ func (s *Server) GetLogs(ctx context.Context, size int) (gin.H, error) { return s.Client().Logs(ctx, size) } +func (s *Server) InstallInfo(ctx context.Context, size int) (models.InstallInfo, error) { + return s.Client().InstallInfo(ctx, size) +} + // ServerData returns the ServerData model for this server. // ServerData is the public API representation of a managed server. func (s *Server) ServerData() models.ServerData { From ed4930e33cb09002a12d1f09b34498c9bc7819bc Mon Sep 17 00:00:00 2001 From: Jesse Faler Date: Tue, 26 May 2026 19:46:33 -0500 Subject: [PATCH 08/41] Revert daemon docker compose file remove changes by Dani --- daemon/docker-compose.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/daemon/docker-compose.yml b/daemon/docker-compose.yml index ed8e4b5..88c4a8e 100644 --- a/daemon/docker-compose.yml +++ b/daemon/docker-compose.yml @@ -37,8 +37,10 @@ services: # given a bind mount of that path by the host Docker daemon; without propagation # the host never sees the overlay and server containers get an empty directory - type: bind - source: /home/dani/sls-data + source: /var/lib/sls target: /var/lib/sls + bind: + propagation: rshared networks: sls: From e90edded05c4c2b25b93538fed97b793dbf8f540 Mon Sep 17 00:00:00 2001 From: Danil Vilmont <94568221+Yeetoxic@users.noreply.github.com> Date: Tue, 26 May 2026 20:27:44 -0700 Subject: [PATCH 09/41] Move install logs out of install info Keep install info responses focused on lifecycle metadata only. Route installer log visibility through the existing server logs endpoint, and record the install lock owner so servers waiting on shared base installation can surface the active installer logs. --- daemon/api/router/router_server.go | 5 +- daemon/server/install_lock.go | 12 +++- daemon/server/install_state.go | 87 +++++++++++++++++++++------ daemon/server/power.go | 2 +- daemon/server/server.go | 20 +++++- protocube/api/router/router_server.go | 3 +- protocube/client/server_client.go | 7 +-- protocube/models/server.go | 1 - protocube/server/server.go | 4 +- 9 files changed, 106 insertions(+), 35 deletions(-) diff --git a/daemon/api/router/router_server.go b/daemon/api/router/router_server.go index 20b49f9..2fce92a 100644 --- a/daemon/api/router/router_server.go +++ b/daemon/api/router/router_server.go @@ -96,8 +96,7 @@ func getServerStats(c *gin.Context) { func getServerInstallInfo(c *gin.Context) { s := middleware.ExtractServer(c) - l, _ := strconv.Atoi(c.DefaultQuery("size", "100")) - c.JSON(http.StatusOK, s.InstallInfo(c.Request.Context(), l)) + c.JSON(http.StatusOK, s.InstallInfo(c.Request.Context())) } // Sends an array of commands to a running server instance. @@ -172,7 +171,7 @@ func getServerLogs(c *gin.Context) { l = 100 } - out, err := s.ReadLogfile(l) + out, err := s.ReadLogfile(c.Request.Context(), l) if err != nil { middleware.CaptureAndAbort(c, err) return diff --git a/daemon/server/install_lock.go b/daemon/server/install_lock.go index 5ea5f7c..0e38030 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_state.go b/daemon/server/install_state.go index efbb4b8..0359132 100644 --- a/daemon/server/install_state.go +++ b/daemon/server/install_state.go @@ -11,6 +11,7 @@ import ( "github.com/docker/docker/api/types/container" "github.com/docker/docker/client" + "protoxon.com/sls/daemon/config" "protoxon.com/sls/daemon/environment" ) @@ -36,7 +37,6 @@ type InstallInfo struct { StartedAt *time.Time `json:"started_at,omitempty"` FinishedAt *time.Time `json:"finished_at,omitempty"` FailureReason string `json:"failure_reason,omitempty"` - Logs []string `json:"logs"` } type installState struct { @@ -104,21 +104,9 @@ func (s *Server) FinishInstallPhase(phase InstallPhase, failureReason string) { } } -func (s *Server) InstallInfo(ctx context.Context, lines int) InstallInfo { - if lines <= 0 { - lines = 100 - } else if lines > 500 { - lines = 500 - } - +func (s *Server) InstallInfo(ctx context.Context) InstallInfo { info := s.installInfoSnapshot() - s.enrichInstallInfoFromDocker(ctx, &info, lines) - if len(info.Logs) == 0 { - info.Logs = readTailLines(s.installLogPath(), lines) - } - if len(info.Logs) == 0 { - info.Logs = []string{} - } + s.enrichInstallInfoFromDocker(ctx, &info) return info } @@ -144,7 +132,34 @@ func (s *Server) installLogPath() string { return path } -func (s *Server) enrichInstallInfoFromDocker(ctx context.Context, info *InstallInfo, lines int) { +func (s *Server) installLogsForServer(ctx context.Context, lines int) ([]string, error) { + phase := s.installPhase() + if phase != InstallPhaseIdle && phase != InstallPhaseReady { + return s.ReadInstallLogfile(ctx, lines) + } + + ownerID := InstallLockOwner(s.Filesystem().Overlay().ServerPath) + if ownerID == "" || ownerID == s.ID() { + return nil, nil + } + + logs, err := readInstallLogsFromDocker(ctx, ownerID+"_installer", lines) + if err != nil { + return nil, err + } + if len(logs) > 0 { + return logs, nil + } + return readTailLines(filepath.Join(config.Get().System.LogDirectory, "install", ownerID+".log"), lines), nil +} + +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 @@ -187,6 +202,37 @@ func (s *Server) enrichInstallInfoFromDocker(ctx context.Context, info *InstallI } } } +} + +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 + } + return readTailLines(s.installLogPath(), lines), nil +} + +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 + } reader, err := cli.ContainerLogs(ctx, containerRef, container.LogsOptions{ ShowStdout: true, @@ -194,14 +240,19 @@ func (s *Server) enrichInstallInfoFromDocker(ctx context.Context, info *InstallI Tail: strconv.Itoa(lines), }) if err != nil { - return + if client.IsErrNotFound(err) { + return nil, nil + } + return nil, err } defer reader.Close() + var logs []string scanner := bufio.NewScanner(reader) for scanner.Scan() { - info.Logs = append(info.Logs, scanner.Text()) + logs = append(logs, scanner.Text()) } + return logs, scanner.Err() } func readTailLines(path string, max int) []string { diff --git a/daemon/server/power.go b/daemon/server/power.go index 6abafb3..8c3a05e 100644 --- a/daemon/server/power.go +++ b/daemon/server/power.go @@ -300,7 +300,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/server.go b/daemon/server/server.go index 68ca23c..a3e7e62 100644 --- a/daemon/server/server.go +++ b/daemon/server/server.go @@ -142,9 +142,23 @@ 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) { + out, err := s.Environment.Readlog(lines) + if err != nil { + out = nil + } + + installLogs, installErr := s.installLogsForServer(ctx, lines) + if installErr != nil && err != nil { + return nil, err + } + + out = append(out, installLogs...) + if 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. diff --git a/protocube/api/router/router_server.go b/protocube/api/router/router_server.go index ef666b2..56e54c3 100644 --- a/protocube/api/router/router_server.go +++ b/protocube/api/router/router_server.go @@ -132,8 +132,7 @@ func getServerLogs(c *gin.Context) { func getServerInstallInfo(c *gin.Context) { s := middleware.ExtractServer(c) - size, _ := strconv.Atoi(c.DefaultQuery("size", "100")) - info, err := s.InstallInfo(c.Request.Context(), size) + info, err := s.InstallInfo(c.Request.Context()) if err != nil { client.HandleError(c, err) return diff --git a/protocube/client/server_client.go b/protocube/client/server_client.go index 4521293..013b361 100644 --- a/protocube/client/server_client.go +++ b/protocube/client/server_client.go @@ -23,7 +23,7 @@ type ServerClient interface { Power(ctx context.Context, action models.PowerAction) error Reset(ctx context.Context) error Logs(ctx context.Context, size int) (gin.H, error) - InstallInfo(ctx context.Context, size int) (models.InstallInfo, error) + InstallInfo(ctx context.Context) (models.InstallInfo, error) Stats(ctx context.Context, update bool) (models.ResourceUsage, error) Status(ctx context.Context) (string, error) GetServer(ctx context.Context) (models.ServerData, error) @@ -116,7 +116,6 @@ func (sc *serverClient) Logs(ctx context.Context, size int) (gin.H, error) { return Get[gin.H](sc, ctx, "/logs", query) } -func (sc *serverClient) InstallInfo(ctx context.Context, size int) (models.InstallInfo, error) { - query := q{"size": strconv.Itoa(size)} - return Get[models.InstallInfo](sc, ctx, "/install", query) +func (sc *serverClient) InstallInfo(ctx context.Context) (models.InstallInfo, error) { + return Get[models.InstallInfo](sc, ctx, "/install", nil) } diff --git a/protocube/models/server.go b/protocube/models/server.go index ad46586..3a6c700 100644 --- a/protocube/models/server.go +++ b/protocube/models/server.go @@ -84,7 +84,6 @@ type InstallInfo struct { StartedAt *time.Time `json:"started_at,omitempty"` FinishedAt *time.Time `json:"finished_at,omitempty"` FailureReason string `json:"failure_reason,omitempty"` - Logs []string `json:"logs"` } // ResourceUsage defines the current resource usage for a given server instance. If a server is offline you diff --git a/protocube/server/server.go b/protocube/server/server.go index 5777732..b4e9494 100644 --- a/protocube/server/server.go +++ b/protocube/server/server.go @@ -145,8 +145,8 @@ func (s *Server) GetLogs(ctx context.Context, size int) (gin.H, error) { return s.Client().Logs(ctx, size) } -func (s *Server) InstallInfo(ctx context.Context, size int) (models.InstallInfo, error) { - return s.Client().InstallInfo(ctx, size) +func (s *Server) InstallInfo(ctx context.Context) (models.InstallInfo, error) { + return s.Client().InstallInfo(ctx) } // ServerData returns the ServerData model for this server. From 299202b7f155834ca7aabc4f181dbfb31c1fe8c7 Mon Sep 17 00:00:00 2001 From: Danil Vilmont <94568221+Yeetoxic@users.noreply.github.com> Date: Thu, 28 May 2026 16:43:59 -0700 Subject: [PATCH 10/41] Add daemon-owned warmup lifecycle Add warmup and post-warmup install-script fields with defaults and validation. Run daemon-owned warmup after install using lifecycle phases, online signal matching, timeout/retry policy, graceful shutdown, and post-warmup execution. --- daemon/remote/types.go | 16 +- daemon/server/install.go | 12 +- daemon/server/warmup.go | 453 +++++++++++++++++++++++++++++++++ protocube/software/parser.go | 27 ++ protocube/software/software.go | 12 +- 5 files changed, 510 insertions(+), 10 deletions(-) create mode 100644 daemon/server/warmup.go diff --git a/daemon/remote/types.go b/daemon/remote/types.go index 809d843..0d77875 100644 --- 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/server/install.go b/daemon/server/install.go index 1a79b89..e5cb9b3 100644 --- a/daemon/server/install.go +++ b/daemon/server/install.go @@ -57,7 +57,11 @@ func (s *Server) install(ctx context.Context, reinstall bool) error { if successful { s.FinishInstallPhase(InstallPhaseReady, "") } else { - s.FinishInstallPhase(InstallPhaseInstallFailed, err.Error()) + 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() { @@ -117,6 +121,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 } @@ -473,7 +481,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") } diff --git a/daemon/server/warmup.go b/daemon/server/warmup.go new file mode 100644 index 0000000..74a6879 --- /dev/null +++ b/daemon/server/warmup.go @@ -0,0 +1,453 @@ +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 + } + + attempts := 1 + if ip.Script.WarmupFailurePolicy == warmupPolicyRetry { + attempts += ip.Script.WarmupRetries + } + + var lastErr error + for attempt := 1; attempt <= attempts; 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) 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, filepath.Join(config.Get().System.LogDirectory, "install", ip.Server.ID()+"-warmup.log")) + + 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, filepath.Join(config.Get().System.LogDirectory, "install", ip.Server.ID()+"-post-warmup.log")) + + 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/protocube/software/parser.go b/protocube/software/parser.go index 06509fb..66e4706 100644 --- 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/software.go b/protocube/software/software.go index 98e7de4..0f178f0 100644 --- 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"` } From 6cc426f86fc93448ab1bb8e108305695f54d5cef Mon Sep 17 00:00:00 2001 From: Danil Vilmont <94568221+Yeetoxic@users.noreply.github.com> Date: Thu, 28 May 2026 17:04:34 -0700 Subject: [PATCH 11/41] Add warmup lifecycle test coverage Add parser coverage for warmup defaults, explicit warmup fields, and invalid failure policies. Add daemon warmup tests for defaulting, retry attempt semantics, and startup signal matching. --- daemon/server/warmup.go | 15 ++-- daemon/server/warmup_test.go | 73 +++++++++++++++++++ protocube/software/warmup_test.go | 115 ++++++++++++++++++++++++++++++ 3 files changed, 197 insertions(+), 6 deletions(-) create mode 100644 daemon/server/warmup_test.go create mode 100644 protocube/software/warmup_test.go diff --git a/daemon/server/warmup.go b/daemon/server/warmup.go index 74a6879..18dd92f 100644 --- a/daemon/server/warmup.go +++ b/daemon/server/warmup.go @@ -33,13 +33,8 @@ func (ip *InstallationProcess) RunWarmup(ctx context.Context) error { return nil } - attempts := 1 - if ip.Script.WarmupFailurePolicy == warmupPolicyRetry { - attempts += ip.Script.WarmupRetries - } - var lastErr error - for attempt := 1; attempt <= attempts; attempt++ { + for attempt := 1; attempt <= ip.warmupAttempts(); attempt++ { if attempt > 1 { ip.Server.Log().WithField("attempt", attempt).Info("retrying server warmup") } @@ -76,6 +71,14 @@ func (ip *InstallationProcess) RunWarmup(ctx context.Context) error { 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 diff --git a/daemon/server/warmup_test.go b/daemon/server/warmup_test.go new file mode 100644 index 0000000..3ee1a34 --- /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/protocube/software/warmup_test.go b/protocube/software/warmup_test.go new file mode 100644 index 0000000..173226e --- /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 +} From 6f9cb31920d363c6ee63d650f86b39f51c3de207 Mon Sep 17 00:00:00 2001 From: Danil Vilmont <94568221+Yeetoxic@users.noreply.github.com> Date: Fri, 29 May 2026 00:54:16 -0700 Subject: [PATCH 12/41] Add daemon-owned reinstall and software warmup cleanup --- daemon/api/router/router.go | 3 +- daemon/api/router/router_server.go | 12 ++ daemon/docker-compose.yml | 5 + daemon/server/install.go | 13 +- daemon/server/server.go | 17 ++- protocube/api/router/router.go | 2 +- protocube/api/router/router_server.go | 9 ++ protocube/client/server_client.go | 6 + protocube/docker-compose.yml | 7 +- protocube/server/server.go | 4 + protocube/software/bundled_test.go | 39 ++++++ software/paper.yml | 102 +++++++++++--- software/spigot.yml | 189 +++++++++++++++++--------- 13 files changed, 312 insertions(+), 96 deletions(-) create mode 100644 protocube/software/bundled_test.go diff --git a/daemon/api/router/router.go b/daemon/api/router/router.go index 5fe9d42..cde995d 100644 --- a/daemon/api/router/router.go +++ b/daemon/api/router/router.go @@ -39,8 +39,7 @@ 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.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 2fce92a..4229a68 100644 --- a/daemon/api/router/router_server.go +++ b/daemon/api/router/router_server.go @@ -99,6 +99,18 @@ func getServerInstallInfo(c *gin.Context) { c.JSON(http.StatusOK, s.InstallInfo(c.Request.Context())) } +func postServerReinstall(c *gin.Context) { + s := middleware.ExtractServer(c) + + 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) diff --git a/daemon/docker-compose.yml b/daemon/docker-compose.yml index ed8e4b5..770e26e 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) diff --git a/daemon/server/install.go b/daemon/server/install.go index e5cb9b3..e84da19 100644 --- a/daemon/server/install.go +++ b/daemon/server/install.go @@ -37,6 +37,14 @@ 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" @@ -102,7 +110,10 @@ 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) } // Internal installation function used to simplify reporting back to Protocube. diff --git a/daemon/server/server.go b/daemon/server/server.go index a3e7e62..7c8111a 100644 --- 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 @@ -73,10 +74,11 @@ 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(), @@ -251,6 +253,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() diff --git a/protocube/api/router/router.go b/protocube/api/router/router.go index 36194d4..a88580b 100644 --- a/protocube/api/router/router.go +++ b/protocube/api/router/router.go @@ -59,7 +59,7 @@ func (r *Router) Configure() *gin.Engine { server.POST("/commands", postServerCommands) server.POST("/reset", postServerReset) server.GET("/install", getServerInstallInfo) - //server.POST("/reinstall", postServerReinstall) + server.POST("/reinstall", postServerReinstall) //server.POST("/sync", postServerSync) //server.POST("/ws/deny", postServerDenyWSTokens) } diff --git a/protocube/api/router/router_server.go b/protocube/api/router/router_server.go index 56e54c3..9608708 100644 --- a/protocube/api/router/router_server.go +++ b/protocube/api/router/router_server.go @@ -140,6 +140,15 @@ func getServerInstallInfo(c *gin.Context) { c.JSON(http.StatusOK, info) } +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) +} + func (r *Router) getInstallInfo(c *gin.Context) { s := middleware.ExtractServer(c) c.JSON(http.StatusOK, s.InstallScript) diff --git a/protocube/client/server_client.go b/protocube/client/server_client.go index 013b361..21f9f50 100644 --- a/protocube/client/server_client.go +++ b/protocube/client/server_client.go @@ -22,6 +22,7 @@ type ServerClient interface { // Requests Power(ctx context.Context, action models.PowerAction) error Reset(ctx context.Context) error + Reinstall(ctx context.Context) error Logs(ctx context.Context, size int) (gin.H, error) InstallInfo(ctx context.Context) (models.InstallInfo, error) Stats(ctx context.Context, update bool) (models.ResourceUsage, error) @@ -72,6 +73,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 { diff --git a/protocube/docker-compose.yml b/protocube/docker-compose.yml index 6947050..2403e17 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/server/server.go b/protocube/server/server.go index b4e9494..72cfd06 100644 --- a/protocube/server/server.go +++ b/protocube/server/server.go @@ -120,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) diff --git a/protocube/software/bundled_test.go b/protocube/software/bundled_test.go new file mode 100644 index 0000000..ab86667 --- /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/software/paper.yml b/software/paper.yml index 98da9ff..6c94567 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,7 +16,6 @@ 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" @@ -31,22 +24,94 @@ software: - 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 -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/bash + set -e + + PROJECT=paper + : "${SERVER_JARFILE:=server.jar}" + + 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 -s "https://api.papermc.io/v2/projects/${PROJECT}" | jq -r --arg VERSION "${VERSION}" '.versions[] | contains($VERSION)' | grep -m1 true || true) + LATEST_VERSION=$(curl -s "https://api.papermc.io/v2/projects/${PROJECT}" | jq -r '.versions' | jq -r '.[-1]') + 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 -s "https://api.papermc.io/v2/projects/${PROJECT}/versions/${VERSION}" | jq -r --arg BUILD "${BUILD_NUMBER}" '.builds[] | tostring | contains($BUILD)' | grep -m1 true || true) + LATEST_BUILD=$(curl -s "https://api.papermc.io/v2/projects/${PROJECT}/versions/${VERSION}" | jq -r '.builds' | jq -r '.[-1]') + 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 + + JAR_NAME=${PROJECT}-${VERSION}-${BUILD_NUMBER}.jar + echo -e "MC Version: ${VERSION}" + echo -e "Build: ${BUILD_NUMBER}" + echo -e "JAR Name of Build: ${JAR_NAME}" + DOWNLOAD_URL=https://api.papermc.io/v2/projects/${PROJECT}/versions/${VERSION}/builds/${BUILD_NUMBER}/downloads/${JAR_NAME} + fi + + cd /home/container + 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}" + + if [ ! -f "${SERVER_JARFILE}" ]; then + echo "ERROR: Server jar not found after download. Aborting." + exit 1 + fi + + cat > 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 +121,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/spigot.yml b/software/spigot.yml index b7aae86..4546d30 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}}" From 37d10848a143a492a0291e9a470810d42727b786 Mon Sep 17 00:00:00 2001 From: Danil Vilmont <94568221+Yeetoxic@users.noreply.github.com> Date: Fri, 29 May 2026 01:33:31 -0700 Subject: [PATCH 13/41] Fix Paper smoke install and daemon mount propagation --- daemon/docker-compose.yml | 4 +++- software/paper.yml | 5 ++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/daemon/docker-compose.yml b/daemon/docker-compose.yml index 770e26e..69c8792 100644 --- a/daemon/docker-compose.yml +++ b/daemon/docker-compose.yml @@ -42,8 +42,10 @@ services: # given a bind mount of that path by the host Docker daemon; without propagation # the host never sees the overlay and server containers get an empty directory - type: bind - source: /home/dani/sls-data + source: /var/lib/sls target: /var/lib/sls + bind: + propagation: rshared networks: sls: diff --git a/software/paper.yml b/software/paper.yml index 6c94567..5f82fa4 100644 --- a/software/paper.yml +++ b/software/paper.yml @@ -50,7 +50,10 @@ software: VERSION=${LATEST_VERSION} fi - 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 || true) + BUILD_EXISTS=false + if [ -n "${BUILD_NUMBER}" ]; then + BUILD_EXISTS=$(curl -s "https://api.papermc.io/v2/projects/${PROJECT}/versions/${VERSION}" | jq -r --arg BUILD "${BUILD_NUMBER}" '.builds[] | tostring | . == $BUILD' | grep -m1 true || true) + fi LATEST_BUILD=$(curl -s "https://api.papermc.io/v2/projects/${PROJECT}/versions/${VERSION}" | jq -r '.builds' | jq -r '.[-1]') if [ "${BUILD_EXISTS}" = "true" ]; then echo -e "Build is valid for version ${VERSION}. Using build ${BUILD_NUMBER}" From 5340b9052e4c8b30de5ff4753865e128abcdba1a Mon Sep 17 00:00:00 2001 From: Danil Vilmont <94568221+Yeetoxic@users.noreply.github.com> Date: Fri, 29 May 2026 02:07:47 -0700 Subject: [PATCH 14/41] fix: return daemon install logs through lifecycle APIs --- daemon/api/router/router_server.go | 30 +++++++++++---- daemon/docker-compose.yml | 2 +- daemon/server/install.go | 8 +++- daemon/server/install_state.go | 53 ++++++++++++++++++++++++++- protocube/api/router/router_server.go | 23 ++++++++---- protocube/client/server_client.go | 14 ++++--- protocube/models/server.go | 1 + protocube/server/server.go | 8 ++-- 8 files changed, 110 insertions(+), 29 deletions(-) diff --git a/daemon/api/router/router_server.go b/daemon/api/router/router_server.go index 4229a68..32461d3 100644 --- a/daemon/api/router/router_server.go +++ b/daemon/api/router/router_server.go @@ -96,7 +96,8 @@ func getServerStats(c *gin.Context) { func getServerInstallInfo(c *gin.Context) { s := middleware.ExtractServer(c) - c.JSON(http.StatusOK, s.InstallInfo(c.Request.Context())) + l := logLineCount(c) + c.JSON(http.StatusOK, s.InstallInfoWithLogs(c.Request.Context(), l)) } func postServerReinstall(c *gin.Context) { @@ -176,14 +177,15 @@ 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 - } + l := logLineCount(c) - out, err := s.ReadLogfile(c.Request.Context(), l) + var out []string + var err error + if c.Query("type") == "install" { + out, err = s.InstallLogs(c.Request.Context(), l) + } else { + out, err = s.ReadLogfile(c.Request.Context(), l) + } if err != nil { middleware.CaptureAndAbort(c, err) return @@ -226,3 +228,15 @@ func getServerLogs(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"data": stripped}) } + +func logLineCount(c *gin.Context) int { + raw := c.DefaultQuery("size", c.DefaultQuery("lines", "100")) + l, _ := strconv.Atoi(raw) + if l <= 0 { + return 100 + } + if l > 100 { + return 100 + } + return l +} diff --git a/daemon/docker-compose.yml b/daemon/docker-compose.yml index 69c8792..acbdad5 100644 --- a/daemon/docker-compose.yml +++ b/daemon/docker-compose.yml @@ -33,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" diff --git a/daemon/server/install.go b/daemon/server/install.go index e84da19..3f9a301 100644 --- a/daemon/server/install.go +++ b/daemon/server/install.go @@ -429,7 +429,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 } @@ -438,7 +442,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 diff --git a/daemon/server/install_state.go b/daemon/server/install_state.go index 0359132..c3072d2 100644 --- a/daemon/server/install_state.go +++ b/daemon/server/install_state.go @@ -37,6 +37,7 @@ type InstallInfo struct { StartedAt *time.Time `json:"started_at,omitempty"` FinishedAt *time.Time `json:"finished_at,omitempty"` FailureReason string `json:"failure_reason,omitempty"` + Content []string `json:"content,omitempty"` } type installState struct { @@ -110,6 +111,15 @@ func (s *Server) InstallInfo(ctx context.Context) InstallInfo { return info } +func (s *Server) InstallInfoWithLogs(ctx context.Context, lines int) InstallInfo { + info := s.InstallInfo(ctx) + logs, err := s.InstallLogs(ctx, lines) + if err == nil { + info.Content = logs + } + return info +} + func (s *Server) installInfoSnapshot() InstallInfo { s.installState.mu.RLock() defer s.installState.mu.RUnlock() @@ -138,6 +148,11 @@ func (s *Server) installLogsForServer(ctx context.Context, lines int) ([]string, return s.ReadInstallLogfile(ctx, lines) } + logs := s.readStoredInstallLogs(lines) + if len(logs) > 0 { + return logs, nil + } + ownerID := InstallLockOwner(s.Filesystem().Overlay().ServerPath) if ownerID == "" || ownerID == s.ID() { return nil, nil @@ -153,6 +168,13 @@ func (s *Server) installLogsForServer(ctx context.Context, lines int) ([]string, return readTailLines(filepath.Join(config.Get().System.LogDirectory, "install", ownerID+".log"), lines), nil } +func (s *Server) InstallLogs(ctx context.Context, lines int) ([]string, error) { + if lines <= 0 { + lines = 100 + } + return s.installLogsForServer(ctx, lines) +} + func (s *Server) installPhase() InstallPhase { s.installState.mu.RLock() defer s.installState.mu.RUnlock() @@ -212,7 +234,36 @@ func (s *Server) ReadInstallLogfile(ctx context.Context, lines int) ([]string, e if len(logs) > 0 { return logs, nil } - return readTailLines(s.installLogPath(), lines), 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 { + paths := []string{ + filepath.Join(config.Get().System.LogDirectory, "install", s.ID()+".log"), + filepath.Join(config.Get().System.LogDirectory, "install", s.ID()+"-warmup.log"), + filepath.Join(config.Get().System.LogDirectory, "install", s.ID()+"-post-warmup.log"), + } + + 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 len(out) > lines { + out = out[len(out)-lines:] + } + return out } func (s *Server) installContainerRef() string { diff --git a/protocube/api/router/router_server.go b/protocube/api/router/router_server.go index 9608708..9c6b60c 100644 --- a/protocube/api/router/router_server.go +++ b/protocube/api/router/router_server.go @@ -112,15 +112,10 @@ func getServerLogs(c *gin.Context) { s := middleware.ExtractServer(c) // Parse the size query parameter - size, _ := strconv.Atoi(c.DefaultQuery("size", "100")) - if size <= 0 { - size = 100 - } else if size > 100 { - size = 100 - } + size := logLineCount(c) // Make the request to the daemon - logs, err := s.GetLogs(c.Request.Context(), size) + logs, err := s.GetLogs(c.Request.Context(), size, c.Query("type")) if err != nil { client.HandleError(c, err) return @@ -132,7 +127,7 @@ func getServerLogs(c *gin.Context) { func getServerInstallInfo(c *gin.Context) { s := middleware.ExtractServer(c) - info, err := s.InstallInfo(c.Request.Context()) + info, err := s.InstallInfo(c.Request.Context(), logLineCount(c)) if err != nil { client.HandleError(c, err) return @@ -153,3 +148,15 @@ func (r *Router) getInstallInfo(c *gin.Context) { s := middleware.ExtractServer(c) c.JSON(http.StatusOK, s.InstallScript) } + +func logLineCount(c *gin.Context) int { + raw := c.DefaultQuery("size", c.DefaultQuery("lines", "100")) + size, _ := strconv.Atoi(raw) + if size <= 0 { + return 100 + } + if size > 100 { + return 100 + } + return size +} diff --git a/protocube/client/server_client.go b/protocube/client/server_client.go index 21f9f50..d0df77c 100644 --- a/protocube/client/server_client.go +++ b/protocube/client/server_client.go @@ -23,8 +23,8 @@ type ServerClient interface { Power(ctx context.Context, action models.PowerAction) error Reset(ctx context.Context) error Reinstall(ctx context.Context) error - Logs(ctx context.Context, size int) (gin.H, error) - InstallInfo(ctx context.Context) (models.InstallInfo, error) + Logs(ctx context.Context, size int, logType string) (gin.H, error) + InstallInfo(ctx context.Context, size int) (models.InstallInfo, error) Stats(ctx context.Context, update bool) (models.ResourceUsage, error) Status(ctx context.Context) (string, error) GetServer(ctx context.Context) (models.ServerData, error) @@ -117,11 +117,15 @@ 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) { +func (sc *serverClient) Logs(ctx context.Context, size int, logType string) (gin.H, error) { query := q{"size": strconv.Itoa(size)} + if logType != "" { + query["type"] = logType + } return Get[gin.H](sc, ctx, "/logs", query) } -func (sc *serverClient) InstallInfo(ctx context.Context) (models.InstallInfo, error) { - return Get[models.InstallInfo](sc, ctx, "/install", nil) +func (sc *serverClient) InstallInfo(ctx context.Context, size int) (models.InstallInfo, error) { + query := q{"size": strconv.Itoa(size)} + return Get[models.InstallInfo](sc, ctx, "/install", query) } diff --git a/protocube/models/server.go b/protocube/models/server.go index 3a6c700..c1e4c39 100644 --- a/protocube/models/server.go +++ b/protocube/models/server.go @@ -84,6 +84,7 @@ type InstallInfo struct { StartedAt *time.Time `json:"started_at,omitempty"` FinishedAt *time.Time `json:"finished_at,omitempty"` FailureReason string `json:"failure_reason,omitempty"` + Content []string `json:"content,omitempty"` } // ResourceUsage defines the current resource usage for a given server instance. If a server is offline you diff --git a/protocube/server/server.go b/protocube/server/server.go index 72cfd06..e794295 100644 --- a/protocube/server/server.go +++ b/protocube/server/server.go @@ -145,12 +145,12 @@ 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, size int, logType string) (gin.H, error) { + return s.Client().Logs(ctx, size, logType) } -func (s *Server) InstallInfo(ctx context.Context) (models.InstallInfo, error) { - return s.Client().InstallInfo(ctx) +func (s *Server) InstallInfo(ctx context.Context, size int) (models.InstallInfo, error) { + return s.Client().InstallInfo(ctx, size) } // ServerData returns the ServerData model for this server. From 0d7954297ba2de47f1bd776f772594be8060025a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 31 May 2026 10:42:39 +0000 Subject: [PATCH 15/41] chore(deps): bump ch.qos.logback:logback-classic in /S4J (#100) Bumps [ch.qos.logback:logback-classic](https://github.com/qos-ch/logback) from 1.5.32 to 1.5.33. - [Release notes](https://github.com/qos-ch/logback/releases) - [Commits](https://github.com/qos-ch/logback/compare/v_1.5.32...v_1.5.33) --- updated-dependencies: - dependency-name: ch.qos.logback:logback-classic dependency-version: 1.5.33 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- S4J/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/S4J/pom.xml b/S4J/pom.xml index 6a7e13c..97ea7e2 100644 --- a/S4J/pom.xml +++ b/S4J/pom.xml @@ -133,7 +133,7 @@ ch.qos.logback logback-classic - 1.5.32 + 1.5.33 From 4d7c84dcb233d7822093f54d9fa17b06c7cf8e9a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 31 May 2026 10:42:56 +0000 Subject: [PATCH 16/41] chore(deps): bump ch.qos.logback:logback-classic from 1.5.32 to 1.5.33 (#103) Bumps [ch.qos.logback:logback-classic](https://github.com/qos-ch/logback) from 1.5.32 to 1.5.33. - [Release notes](https://github.com/qos-ch/logback/releases) - [Commits](https://github.com/qos-ch/logback/compare/v_1.5.32...v_1.5.33) --- updated-dependencies: - dependency-name: ch.qos.logback:logback-classic dependency-version: 1.5.33 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> From 087fdc6fb8bb84d4eb100f641dc22fdad8525fd6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 31 May 2026 10:43:21 +0000 Subject: [PATCH 17/41] chore(deps): bump com.github.retrooper:packetevents-velocity (#106) Bumps [com.github.retrooper:packetevents-velocity](https://github.com/retrooper/packetevents) from 2.11.2 to 2.12.1. - [Release notes](https://github.com/retrooper/packetevents/releases) - [Changelog](https://github.com/retrooper/packetevents/blob/2.0/CHANGELOG.md) - [Commits](https://github.com/retrooper/packetevents/compare/v2.11.2...v2.12.1) --- updated-dependencies: - dependency-name: com.github.retrooper:packetevents-velocity dependency-version: 2.12.1 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- vsls/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vsls/pom.xml b/vsls/pom.xml index 09953a4..cd1f200 100644 --- a/vsls/pom.xml +++ b/vsls/pom.xml @@ -70,7 +70,7 @@ com.github.retrooper packetevents-velocity - 2.11.2 + 2.12.1 provided From 73e32e4b5347d69dad4e7baa0760387b200ea65a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 31 May 2026 10:43:31 +0000 Subject: [PATCH 18/41] chore(deps): bump com.fasterxml.jackson.core:jackson-databind (#107) Bumps [com.fasterxml.jackson.core:jackson-databind](https://github.com/FasterXML/jackson) from 2.21.3 to 2.21.4. - [Commits](https://github.com/FasterXML/jackson/commits) --- updated-dependencies: - dependency-name: com.fasterxml.jackson.core:jackson-databind dependency-version: 2.21.4 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- vsls/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vsls/pom.xml b/vsls/pom.xml index cd1f200..17bb22e 100644 --- a/vsls/pom.xml +++ b/vsls/pom.xml @@ -93,7 +93,7 @@ com.fasterxml.jackson.core jackson-databind - 2.21.3 + 2.21.4 From e0047373090dbf7f412011cc692d2326b9b29017 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 7 Jun 2026 10:42:31 +0000 Subject: [PATCH 19/41] chore(deps): bump ch.qos.logback:logback-classic in /S4J (#108) Bumps [ch.qos.logback:logback-classic](https://github.com/qos-ch/logback) from 1.5.33 to 1.5.34. - [Release notes](https://github.com/qos-ch/logback/releases) - [Commits](https://github.com/qos-ch/logback/compare/v_1.5.33...v_1.5.34) --- updated-dependencies: - dependency-name: ch.qos.logback:logback-classic dependency-version: 1.5.34 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- S4J/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/S4J/pom.xml b/S4J/pom.xml index 97ea7e2..1dbcd86 100644 --- a/S4J/pom.xml +++ b/S4J/pom.xml @@ -133,7 +133,7 @@ ch.qos.logback logback-classic - 1.5.33 + 1.5.34 From 3bbb2b33d26c0f0b1082a82aeed45d2c165d4884 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 7 Jun 2026 10:42:39 +0000 Subject: [PATCH 20/41] chore(deps): bump com.github.retrooper:packetevents-velocity in /vsls (#109) Bumps [com.github.retrooper:packetevents-velocity](https://github.com/retrooper/packetevents) from 2.12.1 to 2.12.2. - [Release notes](https://github.com/retrooper/packetevents/releases) - [Changelog](https://github.com/retrooper/packetevents/blob/2.0/CHANGELOG.md) - [Commits](https://github.com/retrooper/packetevents/compare/v2.12.1...v2.12.2) --- updated-dependencies: - dependency-name: com.github.retrooper:packetevents-velocity dependency-version: 2.12.2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- vsls/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vsls/pom.xml b/vsls/pom.xml index 17bb22e..a78d93b 100644 --- a/vsls/pom.xml +++ b/vsls/pom.xml @@ -70,7 +70,7 @@ com.github.retrooper packetevents-velocity - 2.12.1 + 2.12.2 provided From d4b498616ce4c35500295bff4187c42d73fb0afc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 7 Jun 2026 10:42:52 +0000 Subject: [PATCH 21/41] chore(deps): bump com.github.retrooper:packetevents-velocity (#110) Bumps [com.github.retrooper:packetevents-velocity](https://github.com/retrooper/packetevents) from 2.12.1 to 2.12.2. - [Release notes](https://github.com/retrooper/packetevents/releases) - [Changelog](https://github.com/retrooper/packetevents/blob/2.0/CHANGELOG.md) - [Commits](https://github.com/retrooper/packetevents/compare/v2.12.1...v2.12.2) --- updated-dependencies: - dependency-name: com.github.retrooper:packetevents-velocity dependency-version: 2.12.2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> From 329c4de466ec14dcd3ff3288759f0d3b2f883c1b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 7 Jun 2026 10:43:03 +0000 Subject: [PATCH 22/41] chore(deps): bump com.fasterxml.jackson.core:jackson-databind (#111) Bumps [com.fasterxml.jackson.core:jackson-databind](https://github.com/FasterXML/jackson) from 2.21.4 to 2.22.0. - [Commits](https://github.com/FasterXML/jackson/commits) --- updated-dependencies: - dependency-name: com.fasterxml.jackson.core:jackson-databind dependency-version: 2.22.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- vsls/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vsls/pom.xml b/vsls/pom.xml index a78d93b..eb50169 100644 --- a/vsls/pom.xml +++ b/vsls/pom.xml @@ -93,7 +93,7 @@ com.fasterxml.jackson.core jackson-databind - 2.21.4 + 2.22.0 From 746fc40a426887bf71e5d36508d583f9ddcf5cf0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 7 Jun 2026 10:43:16 +0000 Subject: [PATCH 23/41] chore(deps): bump ch.qos.logback:logback-classic from 1.5.33 to 1.5.34 (#113) Bumps [ch.qos.logback:logback-classic](https://github.com/qos-ch/logback) from 1.5.33 to 1.5.34. - [Release notes](https://github.com/qos-ch/logback/releases) - [Commits](https://github.com/qos-ch/logback/compare/v_1.5.33...v_1.5.34) --- updated-dependencies: - dependency-name: ch.qos.logback:logback-classic dependency-version: 1.5.34 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> From beb57d9e4cab87fa5943fefbb5dd577910022077 Mon Sep 17 00:00:00 2001 From: jessefaler Date: Sun, 7 Jun 2026 22:43:39 -0500 Subject: [PATCH 24/41] append server logs last so that appear at the bottom of the log output --- daemon/server/server.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/daemon/server/server.go b/daemon/server/server.go index 7c8111a..9faa497 100644 --- a/daemon/server/server.go +++ b/daemon/server/server.go @@ -146,17 +146,18 @@ func (s *Server) IsRunning() bool { // 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 } - installLogs, installErr := s.installLogsForServer(ctx, lines) if installErr != nil && err != nil { return nil, err } - out = append(out, installLogs...) + out = append(installLogs, out...) if len(out) > lines { out = out[len(out)-lines:] } From 6b0e3c94c27528a8e7b881d3cb00d36597c5db22 Mon Sep 17 00:00:00 2001 From: jessefaler Date: Mon, 8 Jun 2026 00:23:41 -0500 Subject: [PATCH 25/41] add pagination to server logs --- .../S4J/client/entites/ClientServer.java | 27 +- .../client/entites/impl/ClientServerImpl.java | 36 +-- .../S4J/client/entities/ClientServer.java | 27 +- .../entities/impl/ClientServerImpl.java | 36 +-- .../S4J/requests/PaginationAction.java | 8 + .../operator/impl/PaginationActionImpl.java | 6 + .../operator/impl/PaginationResponseImpl.java | 8 +- .../impl/StringPaginationResponseImpl.java | 55 ++++ daemon/api/router/router_server.go | 74 +++++- daemon/environment/docker/container.go | 9 +- daemon/server/install_state.go | 16 +- daemon/server/server.go | 2 +- protocube/api/router/router_server.go | 48 ++-- protocube/client/server_client.go | 9 +- protocube/server/server.go | 4 +- .../vsls/command/subcommand/LogsCommand.java | 236 ++++++++++++------ .../net/slimelabs/vsls/server/Server.java | 24 +- 17 files changed, 422 insertions(+), 203 deletions(-) create mode 100644 S4J/src/main/java/com/protoxon/S4J/requests/action/operator/impl/StringPaginationResponseImpl.java 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 6abf816..47849b2 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 @@ -5,6 +5,7 @@ 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 +139,30 @@ 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 install logs with pagination. Page 1 contains the most recent lines. + * + * @return PaginationAction that returns a page of install log lines + */ + PaginationAction getInstallLogs(); + /** * 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 394e9ff..6520340 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 @@ -7,8 +7,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 +132,16 @@ 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())); + } - return SLSActionImpl.onRequestExecute( + @Override + public PaginationAction getInstallLogs() { + return StringPaginationResponseImpl.onPagination( 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.LOGS.compile(getId()).withQueryParams("type", "install")); } @Override 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 a334290..8fab48b 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 @@ -5,6 +5,7 @@ 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; @@ -174,20 +175,30 @@ 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 install logs with pagination. Page 1 contains the most recent lines. + * + * @return PaginationAction that returns a page of install log lines + */ + PaginationAction getInstallLogs(); + /** * 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 cc9fea4..2f511e9 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 @@ -8,8 +8,10 @@ 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; @@ -160,34 +162,16 @@ 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())); + } - return SLSActionImpl.onRequestExecute( + @Override + public PaginationAction getInstallLogs() { + return StringPaginationResponseImpl.onPagination( 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.LOGS.compile(getId()).withQueryParams("type", "install")); } @Override 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 4a90ab8..4f7d168 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/action/operator/impl/PaginationActionImpl.java b/S4J/src/main/java/com/protoxon/S4J/requests/action/operator/impl/PaginationActionImpl.java index 29d4556..6cbd777 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 c3a8d03..bc802cc 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 0000000..83a1c13 --- /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/api/router/router_server.go b/daemon/api/router/router_server.go index 32461d3..f453e1f 100644 --- a/daemon/api/router/router_server.go +++ b/daemon/api/router/router_server.go @@ -177,21 +177,37 @@ var stripAnsiRegex = regexp.MustCompile("[\u001B\u009B][[\\]()#;?]*(?:(?:(?:[a-z func getServerLogs(c *gin.Context) { s := middleware.ExtractServer(c) - l := logLineCount(c) + page, perPage := logPaginationParams(c) var out []string var err error if c.Query("type") == "install" { - out, err = s.InstallLogs(c.Request.Context(), l) + out, err = s.InstallLogs(c.Request.Context(), 0) } else { - out, err = s.ReadLogfile(c.Request.Context(), l) + 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 @@ -225,18 +241,50 @@ 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 + } + + start := end - perPage + if start < 0 { + start = 0 + } - c.JSON(http.StatusOK, gin.H{"data": stripped}) + return lines[start:end], totalPages } -func logLineCount(c *gin.Context) int { - raw := c.DefaultQuery("size", c.DefaultQuery("lines", "100")) - l, _ := strconv.Atoi(raw) - if l <= 0 { - return 100 +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 l > 100 { - return 100 + if perPage > 100 { + perPage = 100 } - return l + return page, perPage +} + +func logLineCount(c *gin.Context) int { + _, perPage := logPaginationParams(c) + return perPage } diff --git a/daemon/environment/docker/container.go b/daemon/environment/docker/container.go index 0c2b427..40cb7b1 100644 --- 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/server/install_state.go b/daemon/server/install_state.go index c3072d2..cc2f9d5 100644 --- a/daemon/server/install_state.go +++ b/daemon/server/install_state.go @@ -169,9 +169,6 @@ func (s *Server) installLogsForServer(ctx context.Context, lines int) ([]string, } func (s *Server) InstallLogs(ctx context.Context, lines int) ([]string, error) { - if lines <= 0 { - lines = 100 - } return s.installLogsForServer(ctx, lines) } @@ -260,7 +257,7 @@ func (s *Server) readStoredInstallLogs(lines int) []string { } out = append(out, logs...) } - if len(out) > lines { + if lines > 0 && len(out) > lines { out = out[len(out)-lines:] } return out @@ -285,11 +282,14 @@ func readInstallLogsFromDocker(ctx context.Context, containerRef string, lines i return nil, err } - reader, err := cli.ContainerLogs(ctx, containerRef, container.LogsOptions{ + opts := container.LogsOptions{ ShowStdout: true, ShowStderr: true, - Tail: strconv.Itoa(lines), - }) + } + 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 @@ -320,7 +320,7 @@ func readTailLines(path string, max int) []string { scanner := bufio.NewScanner(f) for scanner.Scan() { lines = append(lines, scanner.Text()) - if len(lines) > max { + if max > 0 && len(lines) > max { lines = lines[len(lines)-max:] } } diff --git a/daemon/server/server.go b/daemon/server/server.go index 9faa497..6266a18 100644 --- a/daemon/server/server.go +++ b/daemon/server/server.go @@ -158,7 +158,7 @@ func (s *Server) ReadLogfile(ctx context.Context, lines int) ([]string, error) { } out = append(installLogs, out...) - if len(out) > lines { + if lines > 0 && len(out) > lines { out = out[len(out)-lines:] } return out, nil diff --git a/protocube/api/router/router_server.go b/protocube/api/router/router_server.go index 9c6b60c..0e9c6b8 100644 --- a/protocube/api/router/router_server.go +++ b/protocube/api/router/router_server.go @@ -111,20 +111,42 @@ func postServerReset(c *gin.Context) { func getServerLogs(c *gin.Context) { s := middleware.ExtractServer(c) - // Parse the size query parameter - size := logLineCount(c) + page, perPage := logPaginationParams(c) - // Make the request to the daemon - logs, err := s.GetLogs(c.Request.Context(), size, c.Query("type")) + logs, err := s.GetLogs(c.Request.Context(), page, perPage, c.Query("type")) if err != nil { client.HandleError(c, err) return } - // Return the logs in the response c.JSON(http.StatusOK, logs) } +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 logLineCount(c *gin.Context) int { + _, perPage := logPaginationParams(c) + return perPage +} + func getServerInstallInfo(c *gin.Context) { s := middleware.ExtractServer(c) info, err := s.InstallInfo(c.Request.Context(), logLineCount(c)) @@ -146,17 +168,9 @@ func postServerReinstall(c *gin.Context) { func (r *Router) getInstallInfo(c *gin.Context) { s := middleware.ExtractServer(c) - c.JSON(http.StatusOK, s.InstallScript) -} - -func logLineCount(c *gin.Context) int { - raw := c.DefaultQuery("size", c.DefaultQuery("lines", "100")) - size, _ := strconv.Atoi(raw) - if size <= 0 { - return 100 - } - if size > 100 { - return 100 + if s.InstallScript == nil { + httperror.JSON(c, http.StatusInternalServerError, "install script is missing", "Server install script is missing.") + return } - return size + c.JSON(http.StatusOK, s.InstallScript) } diff --git a/protocube/client/server_client.go b/protocube/client/server_client.go index d0df77c..e1460ed 100644 --- a/protocube/client/server_client.go +++ b/protocube/client/server_client.go @@ -23,7 +23,7 @@ type ServerClient interface { Power(ctx context.Context, action models.PowerAction) error Reset(ctx context.Context) error Reinstall(ctx context.Context) error - Logs(ctx context.Context, size int, logType string) (gin.H, error) + Logs(ctx context.Context, page, perPage int, logType string) (gin.H, error) InstallInfo(ctx context.Context, size int) (models.InstallInfo, error) Stats(ctx context.Context, update bool) (models.ResourceUsage, error) Status(ctx context.Context) (string, error) @@ -117,8 +117,11 @@ 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, logType string) (gin.H, error) { - query := q{"size": strconv.Itoa(size)} +func (sc *serverClient) Logs(ctx context.Context, page, perPage int, logType string) (gin.H, error) { + query := q{ + "page": strconv.Itoa(page), + "per_page": strconv.Itoa(perPage), + } if logType != "" { query["type"] = logType } diff --git a/protocube/server/server.go b/protocube/server/server.go index e794295..ab59097 100644 --- a/protocube/server/server.go +++ b/protocube/server/server.go @@ -145,8 +145,8 @@ 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, logType string) (gin.H, error) { - return s.Client().Logs(ctx, size, logType) +func (s *Server) GetLogs(ctx context.Context, page, perPage int, logType string) (gin.H, error) { + return s.Client().Logs(ctx, page, perPage, logType) } func (s *Server) InstallInfo(ctx context.Context, size int) (models.InstallInfo, error) { 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 206b212..e6d1df7 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/server/Server.java b/vsls/src/main/java/net/slimelabs/vsls/server/Server.java index 21618fb..8e8745d 100644 --- a/vsls/src/main/java/net/slimelabs/vsls/server/Server.java +++ b/vsls/src/main/java/net/slimelabs/vsls/server/Server.java @@ -3,6 +3,7 @@ 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; @@ -284,24 +285,33 @@ 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(); + } + /** * Fetches the servers status from the remote node */ From abbfcb42ac6a75861f413a878717c3b9097a8581 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 10 Jun 2026 00:33:31 +0000 Subject: [PATCH 26/41] chore(deps): bump github.com/mattn/go-colorable in /protocube (#101) Bumps [github.com/mattn/go-colorable](https://github.com/mattn/go-colorable) from 0.1.14 to 0.1.15. - [Commits](https://github.com/mattn/go-colorable/compare/v0.1.14...v0.1.15) --- updated-dependencies: - dependency-name: github.com/mattn/go-colorable dependency-version: 0.1.15 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- protocube/go.mod | 2 +- protocube/go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/protocube/go.mod b/protocube/go.mod index 69e1a1c..e68a482 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 1436111..896c04e 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= From 1fa356c08dcc0f3f6f9d5076db13cd123f905d10 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 10 Jun 2026 00:34:56 +0000 Subject: [PATCH 27/41] chore(deps): bump github.com/mattn/go-colorable in /daemon (#104) Bumps [github.com/mattn/go-colorable](https://github.com/mattn/go-colorable) from 0.1.14 to 0.1.15. - [Commits](https://github.com/mattn/go-colorable/compare/v0.1.14...v0.1.15) --- updated-dependencies: - dependency-name: github.com/mattn/go-colorable dependency-version: 0.1.15 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- daemon/go.mod | 2 +- daemon/go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/daemon/go.mod b/daemon/go.mod index 4aa7c17..26b7eb8 100644 --- a/daemon/go.mod +++ b/daemon/go.mod @@ -28,7 +28,7 @@ require ( github.com/klauspost/compress v1.18.6 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 diff --git a/daemon/go.sum b/daemon/go.sum index 97d7f24..48ae21f 100644 --- a/daemon/go.sum +++ b/daemon/go.sum @@ -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= From fff1ae8af85cbe331d08eb9e852d39cd69cd8e02 Mon Sep 17 00:00:00 2001 From: jessefaler Date: Sat, 13 Jun 2026 19:09:45 -0500 Subject: [PATCH 28/41] Detach node clients from servers when nodes disconnect in Protocube This should allow for cleaner errors when a server's node is not reachable and allow us to avoid unnecessary requests to a disconnected node. Before, the node client would be kept and Protocube would still try to make requests to the node even after it disconnected, resulting in downstream connection errors. Now the client is removed when the node disconnects and Protocube will return a clear node unavailable error instead. Before: 502 Bad Gateway: request creation failed: connection refused After: 503 Service Unavailable: node for server is not connected: node unavailable --- protocube/cmd/root.go | 5 ++++- protocube/node/manager.go | 18 ++++++++++++++++++ protocube/server/manager.go | 13 +++++++++++++ 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/protocube/cmd/root.go b/protocube/cmd/root.go index 60543ab..850fe11 100644 --- a/protocube/cmd/root.go +++ b/protocube/cmd/root.go @@ -53,10 +53,13 @@ func run(cmd *cobra.Command, _ []string) { 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/node/manager.go b/protocube/node/manager.go index f33ffee..2a90301 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/server/manager.go b/protocube/server/manager.go index 3167fa7..1b52dac 100644 --- a/protocube/server/manager.go +++ b/protocube/server/manager.go @@ -123,6 +123,19 @@ func (m *Manager) AttachNodeClientToServers(nodeId string, node *node.Node) { } } +// 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 + } + } +} + // CreateServer creates a server on the specified node and adds it to the manager func (m *Manager) CreateServer(ctx context.Context, node *node.Node, bp *blueprint.Blueprint, swr *software.Registry, overrides *models.ServerOverrides) (*Server, error) { serverId := id.New() From 7cfb679e421a69b6b0a8faa387db958d8806ec7d Mon Sep 17 00:00:00 2001 From: jessefaler Date: Sat, 13 Jun 2026 19:52:08 -0500 Subject: [PATCH 29/41] Remove duplicate SyncWithEnvironment call during server start Sync() already syncs environment settings, so onBeforeStart was running InSituUpdate twice and logging the same limit modification message. --- daemon/server/power.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/daemon/server/power.go b/daemon/server/power.go index 6abafb3..8b30192 100644 --- 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 From d2fb24e8c897f05a1d5c206757dc6bddaaa4e862 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 14 Jun 2026 10:42:42 +0000 Subject: [PATCH 30/41] chore(deps): bump golang.org/x/sync from 0.20.0 to 0.21.0 in /daemon (#114) Bumps [golang.org/x/sync](https://github.com/golang/sync) from 0.20.0 to 0.21.0. - [Commits](https://github.com/golang/sync/compare/v0.20.0...v0.21.0) --- updated-dependencies: - dependency-name: golang.org/x/sync dependency-version: 0.21.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- daemon/go.mod | 2 +- daemon/go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/daemon/go.mod b/daemon/go.mod index 26b7eb8..052e69d 100644 --- a/daemon/go.mod +++ b/daemon/go.mod @@ -34,7 +34,7 @@ require ( 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/sync v0.21.0 golang.org/x/sys v0.45.0 gopkg.in/ini.v1 v1.67.2 gopkg.in/yaml.v3 v3.0.1 diff --git a/daemon/go.sum b/daemon/go.sum index 48ae21f..5b17152 100644 --- a/daemon/go.sum +++ b/daemon/go.sum @@ -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= From aeb185c40420533e98a94153d652447cd08fb0d2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 14 Jun 2026 10:42:59 +0000 Subject: [PATCH 31/41] chore(deps): bump gopkg.in/ini.v1 from 1.67.2 to 1.67.3 in /daemon (#117) Bumps gopkg.in/ini.v1 from 1.67.2 to 1.67.3. --- updated-dependencies: - dependency-name: gopkg.in/ini.v1 dependency-version: 1.67.3 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- daemon/go.mod | 2 +- daemon/go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/daemon/go.mod b/daemon/go.mod index 052e69d..aae3eca 100644 --- a/daemon/go.mod +++ b/daemon/go.mod @@ -36,7 +36,7 @@ require ( github.com/spf13/cobra v1.10.2 golang.org/x/sync v0.21.0 golang.org/x/sys v0.45.0 - gopkg.in/ini.v1 v1.67.2 + 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 5b17152..232862e 100644 --- a/daemon/go.sum +++ b/daemon/go.sum @@ -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= From 31ca2d0427e6f52f24b3938006e1f4c3b84b3774 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 14 Jun 2026 10:44:46 +0000 Subject: [PATCH 32/41] chore(deps): bump golang.org/x/sys from 0.45.0 to 0.46.0 in /daemon (#116) Bumps [golang.org/x/sys](https://github.com/golang/sys) from 0.45.0 to 0.46.0. - [Commits](https://github.com/golang/sys/compare/v0.45.0...v0.46.0) --- updated-dependencies: - dependency-name: golang.org/x/sys dependency-version: 0.46.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- daemon/go.mod | 2 +- daemon/go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/daemon/go.mod b/daemon/go.mod index aae3eca..ac44405 100644 --- a/daemon/go.mod +++ b/daemon/go.mod @@ -35,7 +35,7 @@ require ( github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 github.com/spf13/cobra v1.10.2 golang.org/x/sync v0.21.0 - golang.org/x/sys v0.45.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 232862e..cfff345 100644 --- a/daemon/go.sum +++ b/daemon/go.sum @@ -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= From b9c4a80b75e812129a11d04767278ea5c71bf8e3 Mon Sep 17 00:00:00 2001 From: jessefaler Date: Sat, 20 Jun 2026 18:38:31 -0500 Subject: [PATCH 33/41] Integrate vSLS with Daemon Install Lifecycle and Warmup Status --- .../java/com/protoxon/S4J/InstallInfo.java | 100 +++++ .../java/com/protoxon/S4J/InstallPhase.java | 47 +++ .../S4J/client/entites/ClientServer.java | 30 +- .../client/entites/impl/ClientServerImpl.java | 18 +- .../S4J/client/entities/ClientServer.java | 30 +- .../entities/impl/ClientServerImpl.java | 18 +- .../java/com/protoxon/S4J/requests/Route.java | 3 + daemon/api/router/router.go | 3 +- daemon/api/router/router_server.go | 50 ++- daemon/server/errors.go | 9 +- daemon/server/install.go | 31 +- daemon/server/install_log_path_test.go | 22 + daemon/server/install_state.go | 157 +++++-- daemon/server/manager.go | 23 + daemon/server/server.go | 5 + daemon/server/warmup.go | 4 +- protocube/api/router/router.go | 1 + protocube/api/router/router_server.go | 27 +- protocube/client/server_client.go | 27 +- protocube/models/server.go | 1 - protocube/server/server.go | 12 +- .../slimelabs/vsls/command/SLSCommand.java | 2 + .../command/subcommand/InstallCommand.java | 392 ++++++++++++++++++ .../net/slimelabs/vsls/server/Server.java | 19 + 24 files changed, 945 insertions(+), 86 deletions(-) create mode 100644 S4J/src/main/java/com/protoxon/S4J/InstallInfo.java create mode 100644 S4J/src/main/java/com/protoxon/S4J/InstallPhase.java create mode 100644 daemon/server/install_log_path_test.go create mode 100644 vsls/src/main/java/net/slimelabs/vsls/command/subcommand/InstallCommand.java 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 0000000..8e0687e --- /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 0000000..7664eea --- /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 47849b2..d00ca1c 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,6 +1,7 @@ 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; @@ -157,12 +158,39 @@ default PaginationAction getLogs(int perPage) { } /** - * Retrieves install logs with pagination. Page 1 contains the most recent lines. + * 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 6520340..6b74543 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; @@ -137,11 +138,18 @@ public PaginationAction getLogs() { impl.getS4J(), Route.Server.LOGS.compile(getId())); } + @Override + public SLSAction getInstallInfo() { + return SLSActionImpl.onRequestExecute( + impl.getS4J(), + Route.Server.INSTALL.compile(getId()), + (response, request) -> InstallInfo.fromJSON(response.getObject())); + } + @Override public PaginationAction getInstallLogs() { return StringPaginationResponseImpl.onPagination( - impl.getS4J(), - Route.Server.LOGS.compile(getId()).withQueryParams("type", "install")); + impl.getS4J(), Route.Server.INSTALL_LOGS.compile(getId())); } @Override @@ -159,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 8fab48b..7b3c8ac 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,6 +1,7 @@ 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; @@ -193,12 +194,39 @@ default PaginationAction getLogs(int perPage) { } /** - * Retrieves install logs with pagination. Page 1 contains the most recent lines. + * 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 2f511e9..5765841 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,5 +1,6 @@ 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; @@ -167,11 +168,18 @@ public PaginationAction getLogs() { impl.getS4J(), Route.Server.LOGS.compile(getId())); } + @Override + public SLSAction getInstallInfo() { + return SLSActionImpl.onRequestExecute( + impl.getS4J(), + Route.Server.INSTALL.compile(getId()), + (response, request) -> InstallInfo.fromJSON(response.getObject())); + } + @Override public PaginationAction getInstallLogs() { return StringPaginationResponseImpl.onPagination( - impl.getS4J(), - Route.Server.LOGS.compile(getId()).withQueryParams("type", "install")); + impl.getS4J(), Route.Server.INSTALL_LOGS.compile(getId())); } @Override @@ -189,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/Route.java b/S4J/src/main/java/com/protoxon/S4J/requests/Route.java index 968dffb..b87f077 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/daemon/api/router/router.go b/daemon/api/router/router.go index cde995d..9e60b34 100644 --- a/daemon/api/router/router.go +++ b/daemon/api/router/router.go @@ -33,12 +33,13 @@ func (r *Router) Configure() *gin.Engine { server.DELETE("", r.deleteServer) server.GET("/logs", getServerLogs) - server.GET("/install", getServerInstallInfo) server.POST("/power", postServerPower) server.GET("/status", getServerStatus) server.GET("/stats", getServerStats) server.POST("/commands", postServerCommands) server.POST("/reset", postServerReset) + 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 f453e1f..dcb53f7 100644 --- a/daemon/api/router/router_server.go +++ b/daemon/api/router/router_server.go @@ -96,13 +96,49 @@ func getServerStats(c *gin.Context) { func getServerInstallInfo(c *gin.Context) { s := middleware.ExtractServer(c) - l := logLineCount(c) - c.JSON(http.StatusOK, s.InstallInfoWithLogs(c.Request.Context(), l)) + 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") @@ -181,11 +217,7 @@ func getServerLogs(c *gin.Context) { var out []string var err error - if c.Query("type") == "install" { - out, err = s.InstallLogs(c.Request.Context(), 0) - } else { - out, err = s.ReadLogfile(c.Request.Context(), 0) - } + out, err = s.ReadLogfile(c.Request.Context(), 0) if err != nil { middleware.CaptureAndAbort(c, err) return @@ -284,7 +316,3 @@ func logPaginationParams(c *gin.Context) (page, perPage int) { return page, perPage } -func logLineCount(c *gin.Context) int { - _, perPage := logPaginationParams(c) - return perPage -} diff --git a/daemon/server/errors.go b/daemon/server/errors.go index 2ab3a75..ffe8e51 100644 --- 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/install.go b/daemon/server/install.go index 3f9a301..d7fef15 100644 --- a/daemon/server/install.go +++ b/daemon/server/install.go @@ -48,7 +48,7 @@ func (s *Server) install(ctx context.Context, reinstall bool) error { var err error if !s.Config().SkipInstallScripts { installerName := s.ID() + "_installer" - s.StartInstallPhase(InstallPhaseInstalling, installerName, filepath.Join(config.Get().System.LogDirectory, "/install", s.ID()+".log")) + s.StartInstallPhase(InstallPhaseInstalling, installerName, installLogDiskPath(s.installLogBasePath(), "")) // Send the start event so protocube can automatically update. s.Events().Publish(InstallStartedEvent, "") @@ -56,14 +56,14 @@ func (s *Server) install(ctx context.Context, reinstall bool) error { err = s.internalInstall(ctx) } else { s.Log().Info("server configured to skip running installation scripts for this software, not executing process") - s.FinishInstallPhase(InstallPhaseReady, "") + 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(InstallPhaseReady, "") + s.FinishInstallPhase(InstallPhaseCompleted, "") } else { switch s.installPhase() { case InstallPhaseWarmupFailed, InstallPhasePostWarmupFailed: @@ -94,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") @@ -116,6 +122,13 @@ func (s *Server) Reinstall() error { 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. func (s *Server) internalInstall(ctx context.Context) error { script, err := s.client.GetInstallationScript(ctx, s.ID()) @@ -381,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. diff --git a/daemon/server/install_log_path_test.go b/daemon/server/install_log_path_test.go new file mode 100644 index 0000000..70fab5c --- /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 index cc2f9d5..b6d7336 100644 --- a/daemon/server/install_state.go +++ b/daemon/server/install_state.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "strconv" + "strings" "sync" "time" @@ -25,7 +26,7 @@ const ( InstallPhaseInstallFailed InstallPhase = "install_failed" InstallPhaseWarmupFailed InstallPhase = "warmup_failed" InstallPhasePostWarmupFailed InstallPhase = "post_warmup_failed" - InstallPhaseReady InstallPhase = "ready" + InstallPhaseCompleted InstallPhase = "completed" ) type InstallInfo struct { @@ -37,7 +38,6 @@ type InstallInfo struct { StartedAt *time.Time `json:"started_at,omitempty"` FinishedAt *time.Time `json:"finished_at,omitempty"` FailureReason string `json:"failure_reason,omitempty"` - Content []string `json:"content,omitempty"` } type installState struct { @@ -99,7 +99,7 @@ func (s *Server) FinishInstallPhase(phase InstallPhase, failureReason string) { s.installState.phase = phase s.installState.finishedAt = &now s.installState.failureReason = failureReason - if phase == InstallPhaseReady && s.installState.exitCode == nil { + if phase == InstallPhaseCompleted && s.installState.exitCode == nil { var code int64 s.installState.exitCode = &code } @@ -111,15 +111,6 @@ func (s *Server) InstallInfo(ctx context.Context) InstallInfo { return info } -func (s *Server) InstallInfoWithLogs(ctx context.Context, lines int) InstallInfo { - info := s.InstallInfo(ctx) - logs, err := s.InstallLogs(ctx, lines) - if err == nil { - info.Content = logs - } - return info -} - func (s *Server) installInfoSnapshot() InstallInfo { s.installState.mu.RLock() defer s.installState.mu.RUnlock() @@ -144,7 +135,7 @@ func (s *Server) installLogPath() string { func (s *Server) installLogsForServer(ctx context.Context, lines int) ([]string, error) { phase := s.installPhase() - if phase != InstallPhaseIdle && phase != InstallPhaseReady { + if phase != InstallPhaseIdle && phase != InstallPhaseCompleted { return s.ReadInstallLogfile(ctx, lines) } @@ -153,23 +144,93 @@ func (s *Server) installLogsForServer(ctx context.Context, lines int) ([]string, return logs, nil } - ownerID := InstallLockOwner(s.Filesystem().Overlay().ServerPath) + ownerID := InstallLockOwner(s.installLogBasePath()) if ownerID == "" || ownerID == s.ID() { return nil, nil } - logs, err := readInstallLogsFromDocker(ctx, ownerID+"_installer", lines) - if err != nil { - return nil, err + 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", + }, } - if len(logs) > 0 { - return logs, nil +} + +// 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 readTailLines(filepath.Join(config.Get().System.LogDirectory, "install", ownerID+".log"), lines), nil + return out, nil } -func (s *Server) InstallLogs(ctx context.Context, lines int) ([]string, error) { - return s.installLogsForServer(ctx, lines) +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 { @@ -240,10 +301,11 @@ func (s *Server) ReadInstallLogfile(ctx context.Context, lines int) ([]string, e } func (s *Server) readStoredInstallLogs(lines int) []string { + basePath := s.installLogBasePath() paths := []string{ - filepath.Join(config.Get().System.LogDirectory, "install", s.ID()+".log"), - filepath.Join(config.Get().System.LogDirectory, "install", s.ID()+"-warmup.log"), - filepath.Join(config.Get().System.LogDirectory, "install", s.ID()+"-post-warmup.log"), + installLogDiskPath(basePath, ""), + installLogDiskPath(basePath, "-warmup"), + installLogDiskPath(basePath, "-post-warmup"), } var out []string @@ -327,6 +389,51 @@ func readTailLines(path string, max int) []string { 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 diff --git a/daemon/server/manager.go b/daemon/server/manager.go index 0a93789..274c584 100644 --- 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) { diff --git a/daemon/server/server.go b/daemon/server/server.go index 6266a18..d500c62 100644 --- a/daemon/server/server.go +++ b/daemon/server/server.go @@ -68,6 +68,11 @@ type Server struct { 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) { diff --git a/daemon/server/warmup.go b/daemon/server/warmup.go index 18dd92f..f3f3441 100644 --- a/daemon/server/warmup.go +++ b/daemon/server/warmup.go @@ -93,7 +93,7 @@ func (ip *InstallationProcess) applyWarmupDefaults() { func (ip *InstallationProcess) runWarmupAttempt(ctx context.Context, attempt int) error { name := ip.Server.ID() + "_warmup" - ip.Server.StartInstallPhase(InstallPhaseWarming, name, filepath.Join(config.Get().System.LogDirectory, "install", ip.Server.ID()+"-warmup.log")) + ip.Server.StartInstallPhase(InstallPhaseWarming, name, installLogDiskPath(ip.Server.installLogBasePath(), "-warmup")) if err := ip.removeNamedContainer(ctx, name); err != nil { return err @@ -132,7 +132,7 @@ func (ip *InstallationProcess) runWarmupAttempt(ctx context.Context, attempt int func (ip *InstallationProcess) runPostWarmup(ctx context.Context) error { name := ip.Server.ID() + "_post_warmup" - ip.Server.StartInstallPhase(InstallPhasePostWarmup, name, filepath.Join(config.Get().System.LogDirectory, "install", ip.Server.ID()+"-post-warmup.log")) + ip.Server.StartInstallPhase(InstallPhasePostWarmup, name, installLogDiskPath(ip.Server.installLogBasePath(), "-post-warmup")) if err := ip.removeNamedContainer(ctx, name); err != nil { return err diff --git a/protocube/api/router/router.go b/protocube/api/router/router.go index a88580b..bd4590f 100644 --- a/protocube/api/router/router.go +++ b/protocube/api/router/router.go @@ -58,6 +58,7 @@ func (r *Router) Configure() *gin.Engine { server.GET("/stats", getServerStats) server.POST("/commands", postServerCommands) server.POST("/reset", postServerReset) + server.GET("/install/logs", getServerInstallLogs) server.GET("/install", getServerInstallInfo) server.POST("/reinstall", postServerReinstall) //server.POST("/sync", postServerSync) diff --git a/protocube/api/router/router_server.go b/protocube/api/router/router_server.go index 0e9c6b8..7cb8b7b 100644 --- a/protocube/api/router/router_server.go +++ b/protocube/api/router/router_server.go @@ -113,7 +113,7 @@ func getServerLogs(c *gin.Context) { page, perPage := logPaginationParams(c) - logs, err := s.GetLogs(c.Request.Context(), page, perPage, c.Query("type")) + logs, err := s.GetLogs(c.Request.Context(), page, perPage) if err != nil { client.HandleError(c, err) return @@ -142,14 +142,9 @@ func logPaginationParams(c *gin.Context) (page, perPage int) { return page, perPage } -func logLineCount(c *gin.Context) int { - _, perPage := logPaginationParams(c) - return perPage -} - func getServerInstallInfo(c *gin.Context) { s := middleware.ExtractServer(c) - info, err := s.InstallInfo(c.Request.Context(), logLineCount(c)) + info, err := s.InstallInfo(c.Request.Context()) if err != nil { client.HandleError(c, err) return @@ -157,6 +152,20 @@ func getServerInstallInfo(c *gin.Context) { c.JSON(http.StatusOK, info) } +func getServerInstallLogs(c *gin.Context) { + s := middleware.ExtractServer(c) + + page, perPage := logPaginationParams(c) + + logs, err := s.InstallLogs(c.Request.Context(), page, perPage) + if err != nil { + client.HandleError(c, err) + return + } + + c.JSON(http.StatusOK, logs) +} + func postServerReinstall(c *gin.Context) { s := middleware.ExtractServer(c) if err := s.Reinstall(c.Request.Context()); err != nil { @@ -168,9 +177,5 @@ func postServerReinstall(c *gin.Context) { func (r *Router) getInstallInfo(c *gin.Context) { s := middleware.ExtractServer(c) - if s.InstallScript == nil { - httperror.JSON(c, http.StatusInternalServerError, "install script is missing", "Server install script is missing.") - return - } c.JSON(http.StatusOK, s.InstallScript) } diff --git a/protocube/client/server_client.go b/protocube/client/server_client.go index e1460ed..19483ff 100644 --- a/protocube/client/server_client.go +++ b/protocube/client/server_client.go @@ -23,8 +23,9 @@ type ServerClient interface { Power(ctx context.Context, action models.PowerAction) error Reset(ctx context.Context) error Reinstall(ctx context.Context) error - Logs(ctx context.Context, page, perPage int, logType string) (gin.H, error) - InstallInfo(ctx context.Context, size int) (models.InstallInfo, 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) @@ -117,18 +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, page, perPage int, logType string) (gin.H, error) { - query := q{ +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), - } - if logType != "" { - query["type"] = logType - } - return Get[gin.H](sc, ctx, "/logs", query) + }) +} + +func (sc *serverClient) InstallInfo(ctx context.Context) (models.InstallInfo, error) { + return Get[models.InstallInfo](sc, ctx, "/install", nil) } -func (sc *serverClient) InstallInfo(ctx context.Context, size int) (models.InstallInfo, error) { - query := q{"size": strconv.Itoa(size)} - return Get[models.InstallInfo](sc, ctx, "/install", query) +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/models/server.go b/protocube/models/server.go index c1e4c39..3a6c700 100644 --- a/protocube/models/server.go +++ b/protocube/models/server.go @@ -84,7 +84,6 @@ type InstallInfo struct { StartedAt *time.Time `json:"started_at,omitempty"` FinishedAt *time.Time `json:"finished_at,omitempty"` FailureReason string `json:"failure_reason,omitempty"` - Content []string `json:"content,omitempty"` } // ResourceUsage defines the current resource usage for a given server instance. If a server is offline you diff --git a/protocube/server/server.go b/protocube/server/server.go index ab59097..a93a131 100644 --- a/protocube/server/server.go +++ b/protocube/server/server.go @@ -145,12 +145,16 @@ 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, page, perPage int, logType string) (gin.H, error) { - return s.Client().Logs(ctx, page, perPage, logType) +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, size int) (models.InstallInfo, error) { - return s.Client().InstallInfo(ctx, size) +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. 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 b66215b..9879d27 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/InstallCommand.java b/vsls/src/main/java/net/slimelabs/vsls/command/subcommand/InstallCommand.java new file mode 100644 index 0000000..9ddb7f4 --- /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/server/Server.java b/vsls/src/main/java/net/slimelabs/vsls/server/Server.java index 8e8745d..732f7ba 100644 --- a/vsls/src/main/java/net/slimelabs/vsls/server/Server.java +++ b/vsls/src/main/java/net/slimelabs/vsls/server/Server.java @@ -1,5 +1,6 @@ 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; @@ -312,6 +313,24 @@ 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 */ From 1b50d699fe8537eeb6ea4eda46f49086c0abc6e8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 21 Jun 2026 10:42:22 +0000 Subject: [PATCH 34/41] chore(deps-dev): bump org.sonatype.central:central-publishing-maven-plugin (#118) Bumps [org.sonatype.central:central-publishing-maven-plugin](https://github.com/sonatype/central-publishing-maven-plugin) from 0.10.0 to 0.11.0. - [Commits](https://github.com/sonatype/central-publishing-maven-plugin/commits) --- updated-dependencies: - dependency-name: org.sonatype.central:central-publishing-maven-plugin dependency-version: 0.11.0 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- S4J/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/S4J/pom.xml b/S4J/pom.xml index 1dbcd86..6c2504c 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 From 98ffd79f40561fac37b949d80756b186d85a7d7f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 21 Jun 2026 10:42:56 +0000 Subject: [PATCH 35/41] chore(deps-dev): bump org.sonatype.central:central-publishing-maven-plugin (#119) Bumps [org.sonatype.central:central-publishing-maven-plugin](https://github.com/sonatype/central-publishing-maven-plugin) from 0.10.0 to 0.11.0. - [Commits](https://github.com/sonatype/central-publishing-maven-plugin/commits) --- updated-dependencies: - dependency-name: org.sonatype.central:central-publishing-maven-plugin dependency-version: 0.11.0 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> From 465eb64438d4a929b336e0dc3cf59099046388eb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 28 Jun 2026 10:42:27 +0000 Subject: [PATCH 36/41] chore(deps): bump ch.qos.logback:logback-classic in /S4J (#120) Bumps [ch.qos.logback:logback-classic](https://github.com/qos-ch/logback) from 1.5.34 to 1.5.37. - [Release notes](https://github.com/qos-ch/logback/releases) - [Commits](https://github.com/qos-ch/logback/compare/v_1.5.34...v_1.5.37) --- updated-dependencies: - dependency-name: ch.qos.logback:logback-classic dependency-version: 1.5.37 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- S4J/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/S4J/pom.xml b/S4J/pom.xml index 6c2504c..5aa1af6 100644 --- a/S4J/pom.xml +++ b/S4J/pom.xml @@ -133,7 +133,7 @@ ch.qos.logback logback-classic - 1.5.34 + 1.5.37 From 257b943488f6f155f7cfd9356b2ac7d06944a959 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 28 Jun 2026 10:42:43 +0000 Subject: [PATCH 37/41] chore(deps): bump com.github.retrooper:packetevents-velocity (#123) Bumps [com.github.retrooper:packetevents-velocity](https://github.com/retrooper/packetevents) from 2.12.2 to 2.13.0. - [Release notes](https://github.com/retrooper/packetevents/releases) - [Changelog](https://github.com/retrooper/packetevents/blob/2.0/CHANGELOG.md) - [Commits](https://github.com/retrooper/packetevents/compare/v2.12.2...v2.13.0) --- updated-dependencies: - dependency-name: com.github.retrooper:packetevents-velocity dependency-version: 2.13.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- vsls/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vsls/pom.xml b/vsls/pom.xml index eb50169..87c610c 100644 --- a/vsls/pom.xml +++ b/vsls/pom.xml @@ -70,7 +70,7 @@ com.github.retrooper packetevents-velocity - 2.12.2 + 2.13.0 provided From f8d84a753cdf30e6c18b842deba16ec0fa340222 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 28 Jun 2026 10:42:55 +0000 Subject: [PATCH 38/41] chore(deps): bump com.github.retrooper:packetevents-velocity in /vsls (#124) Bumps [com.github.retrooper:packetevents-velocity](https://github.com/retrooper/packetevents) from 2.12.2 to 2.13.0. - [Release notes](https://github.com/retrooper/packetevents/releases) - [Changelog](https://github.com/retrooper/packetevents/blob/2.0/CHANGELOG.md) - [Commits](https://github.com/retrooper/packetevents/compare/v2.12.2...v2.13.0) --- updated-dependencies: - dependency-name: com.github.retrooper:packetevents-velocity dependency-version: 2.13.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> From 703ef9ea8be6709106a2ad9a0827412e656cd2f8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 5 Jul 2026 10:42:49 +0000 Subject: [PATCH 39/41] chore(deps): bump github.com/klauspost/compress in /daemon (#126) Bumps [github.com/klauspost/compress](https://github.com/klauspost/compress) from 1.18.6 to 1.19.0. - [Release notes](https://github.com/klauspost/compress/releases) - [Commits](https://github.com/klauspost/compress/compare/v1.18.6...v1.19.0) --- updated-dependencies: - dependency-name: github.com/klauspost/compress dependency-version: 1.19.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- daemon/go.mod | 2 +- daemon/go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/daemon/go.mod b/daemon/go.mod index ac44405..e5d54c2 100644 --- a/daemon/go.mod +++ b/daemon/go.mod @@ -25,7 +25,7 @@ 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.15 diff --git a/daemon/go.sum b/daemon/go.sum index cfff345..17e62b1 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= From be82750dd3a85efa2d9383c3031c3da280eb6ed1 Mon Sep 17 00:00:00 2001 From: jessefaler Date: Tue, 7 Jul 2026 18:02:17 -0500 Subject: [PATCH 40/41] update paper installation script to use papers v3 api --- slimepacks/go.mod | 2 +- slimepacks/go.sum | 1 + software/paper.yml | 79 +----------------- .../scripts/paper_installation_script.yml | 82 +++++++++++++++++++ 4 files changed, 86 insertions(+), 78 deletions(-) create mode 100644 software/scripts/paper_installation_script.yml diff --git a/slimepacks/go.mod b/slimepacks/go.mod index b76ee90..fd1039d 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 bf6de19..ddf9401 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 5f82fa4..5e11b54 100644 --- a/software/paper.yml +++ b/software/paper.yml @@ -22,7 +22,7 @@ software: - 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 invocation: "java -Xms128M -XX:MaxRAMPercentage=95.0 -Dterminal.jline=false -Dterminal.ansi=true -XX:+PerfDisableSharedMem -XX:+ParallelRefProcEnabled -jar server.jar" stop-command: "stop" @@ -30,82 +30,7 @@ software: install-script: entrypoint: bash - script: | - #!/bin/bash - set -e - - PROJECT=paper - : "${SERVER_JARFILE:=server.jar}" - - 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 -s "https://api.papermc.io/v2/projects/${PROJECT}" | jq -r --arg VERSION "${VERSION}" '.versions[] | contains($VERSION)' | grep -m1 true || true) - LATEST_VERSION=$(curl -s "https://api.papermc.io/v2/projects/${PROJECT}" | jq -r '.versions' | jq -r '.[-1]') - 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=false - if [ -n "${BUILD_NUMBER}" ]; then - BUILD_EXISTS=$(curl -s "https://api.papermc.io/v2/projects/${PROJECT}/versions/${VERSION}" | jq -r --arg BUILD "${BUILD_NUMBER}" '.builds[] | tostring | . == $BUILD' | grep -m1 true || true) - fi - LATEST_BUILD=$(curl -s "https://api.papermc.io/v2/projects/${PROJECT}/versions/${VERSION}" | jq -r '.builds' | jq -r '.[-1]') - 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 - - JAR_NAME=${PROJECT}-${VERSION}-${BUILD_NUMBER}.jar - echo -e "MC Version: ${VERSION}" - echo -e "Build: ${BUILD_NUMBER}" - echo -e "JAR Name of Build: ${JAR_NAME}" - DOWNLOAD_URL=https://api.papermc.io/v2/projects/${PROJECT}/versions/${VERSION}/builds/${BUILD_NUMBER}/downloads/${JAR_NAME} - fi - - cd /home/container - 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}" - - if [ ! -f "${SERVER_JARFILE}" ]; then - echo "ERROR: Server jar not found after download. Aborting." - exit 1 - fi - - cat > server.properties < bukkit.yml < spigot.yml < eula.txt + 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 diff --git a/software/scripts/paper_installation_script.yml b/software/scripts/paper_installation_script.yml new file mode 100644 index 0000000..6f19dab --- /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 From b56fbbbc00f40b10890edb2bf97bb58c842b3eb0 Mon Sep 17 00:00:00 2001 From: jessefaler Date: Tue, 7 Jul 2026 18:06:38 -0500 Subject: [PATCH 41/41] chore: bump versions --- daemon/system/const.go | 2 +- protocube/system/const.go | 2 +- vsls/pom.xml | 4 ++-- vsls/src/main/java/net/slimelabs/vsls/SLS.java | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/daemon/system/const.go b/daemon/system/const.go index 225b2a0..c25556f 100644 --- 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/protocube/system/const.go b/protocube/system/const.go index b934963..aaf0fd3 100644 --- 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/vsls/pom.xml b/vsls/pom.xml index 87c610c..b958b5b 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 diff --git a/vsls/src/main/java/net/slimelabs/vsls/SLS.java b/vsls/src/main/java/net/slimelabs/vsls/SLS.java index bb8d4dc..c397836 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 = {