From cabc8a5cd8640e6cbfa9ed4961c552f5dba30bd0 Mon Sep 17 00:00:00 2001 From: et-nik Date: Sat, 26 Sep 2026 18:41:35 +0200 Subject: [PATCH 1/2] suspend --- internal/app/domain/server.go | 21 ++- internal/app/domain/server_autostart_test.go | 23 ++- internal/app/game_server_commands/commands.go | 8 + .../game_server_commands/install_server.go | 9 + .../install_server_test.go | 79 +++++++- .../game_server_commands/restart_server.go | 31 ++-- .../app/game_server_commands/start_server.go | 8 + .../game_server_commands/start_server_test.go | 16 +- .../suspended_server_test.go | 171 ++++++++++++++++++ internal/app/servers_loop/loop.go | 125 ++++++++++++- internal/app/servers_loop/loop_test.go | 163 ++++++++++++++++- internal/app/servers_scheduler/execution.go | 7 + .../app/servers_scheduler/helpers_test.go | 6 +- internal/app/servers_scheduler/policy.go | 13 ++ .../scheduler_suspended_test.go | 97 ++++++++++ internal/processmanager/README.md | 8 + test/functional/game_server_suite.go | 18 +- .../serverscommand/restart/restart_test.go | 21 +++ .../serverscommand/start/start_test.go | 18 ++ 19 files changed, 814 insertions(+), 28 deletions(-) create mode 100644 internal/app/game_server_commands/suspended_server_test.go create mode 100644 internal/app/servers_scheduler/scheduler_suspended_test.go diff --git a/internal/app/domain/server.go b/internal/app/domain/server.go index 59274e4..c0dfcaa 100644 --- a/internal/app/domain/server.go +++ b/internal/app/domain/server.go @@ -43,6 +43,10 @@ const autostartSettingKey = "autostart" const autostartCurrentSettingKey = "autostart_current" const updateBeforeStartSettingKey = "update_before_start" +// ErrServerBlocked is returned for an attempt to start a server suspended in +// the panel. The text matches the panel's own refusal. +var ErrServerBlocked = errors.New("server is blocked") + type workDirReader interface { WorkDir() string } @@ -272,6 +276,21 @@ func (s *Server) Blocked() bool { return s.blocked } +// IsSuspended reports whether the panel suspended the server: it must not be +// started by any path, and a copy still running is to be stopped. Every such +// check goes through here, so a future suspension reason (an expiry date) only +// has to be added in one place. +func (s *Server) IsSuspended() bool { + s.mu.RLock() + defer s.mu.RUnlock() + + return s.isSuspended() +} + +func (s *Server) isSuspended() bool { + return s.blocked +} + func (s *Server) UUID() string { s.mu.RLock() defer s.mu.RUnlock() @@ -660,7 +679,7 @@ func (s *Server) CanAutoStart() bool { s.mu.RLock() defer s.mu.RUnlock() - if s.blocked { + if s.isSuspended() { return false } diff --git a/internal/app/domain/server_autostart_test.go b/internal/app/domain/server_autostart_test.go index 7b7bd6f..8836420 100644 --- a/internal/app/domain/server_autostart_test.go +++ b/internal/app/domain/server_autostart_test.go @@ -296,11 +296,32 @@ func TestServer_Set_ConvergesAfterTheDaemonActsAgain(t *testing.T) { assert.True(t, server.AutoStart()) } +// A suspension arrives the same way as any other change: the panel pushes the +// whole server, and lifting it is just another push. +func TestServer_IsSuspended_FollowsThePanel(t *testing.T) { + server := newTestServerForAutostart(true, false, Settings{autostartSettingKey: "1"}) + require.False(t, server.IsSuspended()) + + pushServer(server, true, Settings{autostartSettingKey: "1"}) + + assert.True(t, server.IsSuspended()) + assert.False(t, server.CanAutoStart(), "a suspended server is never started by the loop") + + pushServer(server, false, Settings{autostartSettingKey: "1"}) + + assert.False(t, server.IsSuspended()) + assert.True(t, server.CanAutoStart()) +} + func pushServerSettings(server *Server, settings Settings) { + pushServer(server, false, settings) +} + +func pushServer(server *Server, blocked bool, settings Settings) { server.Set( true, ServerInstalled, - false, + blocked, "test", "759b875e-d910-11eb-aff7-d796d7fcf7ef", "759b875e", diff --git a/internal/app/game_server_commands/commands.go b/internal/app/game_server_commands/commands.go index 5e0f899..cda16d4 100644 --- a/internal/app/game_server_commands/commands.go +++ b/internal/app/game_server_commands/commands.go @@ -16,6 +16,14 @@ const ( ErrorResult = int(domain.ErrorResult) ) +// Written to the command output of a suspended server, so the task log in the +// panel says in plain words why the server stayed down. +const ( + suspendedServerStartRefused = "The server is suspended in the panel " + + "and cannot be started until the suspension is lifted.\n" + suspendedServerNotStartedAgain = "The server is suspended in the panel and is not started again.\n" +) + type LoadServerCommandFunc func(cmd domain.ServerCommand, server *domain.Server) contracts.GameServerCommand var nilLoadServerCommandFunc = func(_ domain.ServerCommand, _ *domain.Server) contracts.GameServerCommand { diff --git a/internal/app/game_server_commands/install_server.go b/internal/app/game_server_commands/install_server.go index 28e6c03..9c2525c 100644 --- a/internal/app/game_server_commands/install_server.go +++ b/internal/app/game_server_commands/install_server.go @@ -215,6 +215,15 @@ func (cmd *installServer) startServerIfNeeded(ctx context.Context, server *domai return nil } + // The start command would refuse anyway, but as an error, and a scheduled + // update that did everything it was asked to would be reported as failed. + if server.IsSuspended() { + logger.Info(ctx, "Game server is suspended, it is not started again after installation/updating") + _, _ = cmd.installOutput.Write([]byte(suspendedServerNotStartedAgain)) + + return nil + } + err := cmd.startCommand.Execute(ctx, server) if err != nil { return errors.WithMessage( diff --git a/internal/app/game_server_commands/install_server_test.go b/internal/app/game_server_commands/install_server_test.go index 60253d6..8165f3a 100644 --- a/internal/app/game_server_commands/install_server_test.go +++ b/internal/app/game_server_commands/install_server_test.go @@ -352,6 +352,59 @@ func TestInstallation_ServerInstalledFromLocalRepository(t *testing.T) { assert.FileExists(t, workPath+"/test-server/directory_file.txt") } +// An update of a running server stops it and starts it again afterwards. A +// suspended server stays stopped, and the update itself still succeeds: it is a +// scheduled update that did what it was asked, not a failed one. +func TestInstallation_ServerRunningBeforeInstallation(t *testing.T) { + tests := []struct { + name string + server func(t *testing.T) *domain.Server + wantActive bool + wantOutput bool + }{ + { + name: "is started again", + server: givenLocalInstallationServer, + wantActive: true, + wantOutput: false, + }, + { + name: "is not started again while suspended", + server: givenSuspendedLocalInstallationServer, + wantActive: false, + wantOutput: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := &config.Config{ + WorkPath: t.TempDir(), + } + install := newInstallServer( + cfg, + components.NewExecutor(), + processmanager.NewSimple(cfg, components.NewExecutor(), components.NewExecutor()), + mocks.NewServerRepository(), + commandmocks.LoadServerCommand(domain.Status), + commandmocks.LoadServerCommand(domain.Stop), + commandmocks.LoadServerCommand(domain.Start), + ) + server := tt.server(t) + + err := install.Execute(context.Background(), server) + + require.NoError(t, err) + assert.Equal(t, tt.wantActive, server.IsActive()) + assert.Equal( + t, + tt.wantOutput, + bytes.Contains(install.ReadOutput(), []byte(suspendedServerNotStartedAgain)), + ) + }) + } +} + func TestInstallation_RunAfterInstallScript(t *testing.T) { workPath, err := os.MkdirTemp(os.TempDir(), "gameap-daemon-test") defer func(path string) { @@ -487,6 +540,22 @@ func givenRemoteInstallationServer(t *testing.T) *domain.Server { func givenLocalInstallationServer(t *testing.T) *domain.Server { t.Helper() + game, gameMod := givenLocalInstallationGame(t) + + return givenServer(t, game, gameMod) +} + +func givenSuspendedLocalInstallationServer(t *testing.T) *domain.Server { + t.Helper() + + game, gameMod := givenLocalInstallationGame(t) + + return givenServerWithState(t, game, gameMod, true) +} + +func givenLocalInstallationGame(t *testing.T) (domain.Game, domain.GameMod) { + t.Helper() + pathToFileZip, err := filepath.Abs("../../../test/files/file.zip") if err != nil { t.Fatal(err) @@ -509,7 +578,7 @@ func givenLocalInstallationServer(t *testing.T) *domain.Server { RemoteRepository: "https://files.gameap.ru/mod-game.tar.gz", } - return givenServer(t, game, gameMod) + return game, gameMod } func givenLocalInstallationServerWithAfterInstallScript(t *testing.T) *domain.Server { @@ -538,11 +607,17 @@ func givenLocalInstallationServerWithAfterInstallScript(t *testing.T) *domain.Se func givenServer(t *testing.T, game domain.Game, gameMod domain.GameMod) *domain.Server { t.Helper() + return givenServerWithState(t, game, gameMod, false) +} + +func givenServerWithState(t *testing.T, game domain.Game, gameMod domain.GameMod, blocked bool) *domain.Server { + t.Helper() + return domain.NewServer( 1, true, domain.ServerInstalled, - false, + blocked, "name", "759b875e-d910-11eb-aff7-d796d7fcf7ef", "759b875e", diff --git a/internal/app/game_server_commands/restart_server.go b/internal/app/game_server_commands/restart_server.go index 87713a7..9017928 100644 --- a/internal/app/game_server_commands/restart_server.go +++ b/internal/app/game_server_commands/restart_server.go @@ -39,7 +39,16 @@ func newDefaultRestartServer( } func (cmd *defaultRestartServer) Execute(ctx context.Context, server *domain.Server) error { - cmd.output = components.NewSafeBuffer() + // Refused as a whole rather than left to the start command: the default + // restart script restarts through the process manager and never runs it, and + // the stop/start path would stop the server before the start is refused. + if server.IsSuspended() { + _, _ = cmd.output.Write([]byte(suspendedServerStartRefused)) + cmd.SetResult(ErrorResult) + cmd.SetComplete() + + return errors.WithMessage(domain.ErrServerBlocked, "[game_server_commands.defaultRestartServer] restart refused") + } // Checked before either restart path runs. The stop/start path would otherwise stop a healthy // server first and only then fail in the start command, leaving a server that was running @@ -106,21 +115,15 @@ func (cmd *defaultRestartServer) restartViaStopStart(ctx context.Context, server } func (cmd *defaultRestartServer) ReadOutput() []byte { - var err error - var out []byte + out, err := io.ReadAll(cmd.output) + if err != nil { + return []byte(err.Error()) + } if cmd.cfg.Scripts.Restart == "" { - statusOut := cmd.statusServer.ReadOutput() - stopOut := cmd.stopServer.ReadOutput() - startOut := cmd.startServer.ReadOutput() - out = append(out, statusOut...) - out = append(out, stopOut...) - out = append(out, startOut...) - } else { - out, err = io.ReadAll(cmd.output) - if err != nil { - return []byte(err.Error()) - } + out = append(out, cmd.statusServer.ReadOutput()...) + out = append(out, cmd.stopServer.ReadOutput()...) + out = append(out, cmd.startServer.ReadOutput()...) } return out diff --git a/internal/app/game_server_commands/start_server.go b/internal/app/game_server_commands/start_server.go index 72bfcd9..32997f6 100644 --- a/internal/app/game_server_commands/start_server.go +++ b/internal/app/game_server_commands/start_server.go @@ -33,6 +33,14 @@ func newDefaultStartServer( } func (cmd *defaultStartServer) Execute(ctx context.Context, server *domain.Server) error { + if server.IsSuspended() { + _, _ = cmd.startOutput.Write([]byte(suspendedServerStartRefused)) + cmd.SetResult(ErrorResult) + cmd.SetComplete() + + return errors.WithMessage(domain.ErrServerBlocked, "[game_server_commands.defaultStartServer] start refused") + } + if cmd.enableUpdatingBefore && server.UpdateBeforeStart() { updateCmd := cmd.loadServerCommand(domain.Update, server) diff --git a/internal/app/game_server_commands/start_server_test.go b/internal/app/game_server_commands/start_server_test.go index d1a5fb1..ca73e9b 100644 --- a/internal/app/game_server_commands/start_server_test.go +++ b/internal/app/game_server_commands/start_server_test.go @@ -113,11 +113,25 @@ func givenServerWithStartCommandAndVars( ) *domain.Server { t.Helper() + return givenServerWithState(t, startCommand, vars, false) +} + +func givenSuspendedServerWithStartCommand(t *testing.T, startCommand string) *domain.Server { + t.Helper() + + return givenServerWithState(t, startCommand, map[string]string{}, true) +} + +func givenServerWithState( + t *testing.T, startCommand string, vars map[string]string, blocked bool, +) *domain.Server { + t.Helper() + return domain.NewServer( 1337, true, domain.ServerInstalled, - false, + blocked, "name", "759b875e-d910-11eb-aff7-d796d7fcf7ef", "759b875e", diff --git a/internal/app/game_server_commands/suspended_server_test.go b/internal/app/game_server_commands/suspended_server_test.go new file mode 100644 index 0000000..c6c81b3 --- /dev/null +++ b/internal/app/game_server_commands/suspended_server_test.go @@ -0,0 +1,171 @@ +package gameservercommands_test + +import ( + "context" + "io" + "sync" + "testing" + + "github.com/gameap/daemon/internal/app/components" + "github.com/gameap/daemon/internal/app/config" + "github.com/gameap/daemon/internal/app/contracts" + "github.com/gameap/daemon/internal/app/domain" + gameservercommands "github.com/gameap/daemon/internal/app/game_server_commands" + "github.com/gameap/daemon/test/mocks" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// lifecycleSpy records the lifecycle calls that reach the process manager. Any +// other call hits the nil embedded interface and fails the test loudly. +type lifecycleSpy struct { + contracts.ProcessManager + + mu sync.Mutex + calls []string +} + +func (pm *lifecycleSpy) record(call string) (domain.Result, error) { + pm.mu.Lock() + defer pm.mu.Unlock() + + pm.calls = append(pm.calls, call) + + return domain.SuccessResult, nil +} + +func (pm *lifecycleSpy) Start(_ context.Context, _ *domain.Server, _ io.Writer) (domain.Result, error) { + return pm.record("start") +} + +func (pm *lifecycleSpy) Stop(_ context.Context, _ *domain.Server, _ io.Writer) (domain.Result, error) { + return pm.record("stop") +} + +func (pm *lifecycleSpy) Restart(_ context.Context, _ *domain.Server, _ io.Writer) (domain.Result, error) { + return pm.record("restart") +} + +func (pm *lifecycleSpy) Status(_ context.Context, _ *domain.Server, _ io.Writer) (domain.Result, error) { + return pm.record("status") +} + +func (pm *lifecycleSpy) Calls() []string { + pm.mu.Lock() + defer pm.mu.Unlock() + + return append([]string(nil), pm.calls...) +} + +func givenLifecycleCommand( + t *testing.T, restartScript string, command domain.ServerCommand, server *domain.Server, +) (contracts.GameServerCommand, *lifecycleSpy) { + t.Helper() + + cfg := &config.Config{ + WorkPath: t.TempDir(), + Scripts: config.Scripts{ + Start: "{command}", + Stop: "{command}", + Status: "{command}", + Restart: restartScript, + }, + } + pm := &lifecycleSpy{} + factory := gameservercommands.NewFactory(cfg, mocks.NewServerRepository(), components.NewExecutor(), pm) + + return factory.LoadServerCommand(command, server), pm +} + +// A server suspended in the panel must not come up through a command the panel +// sent before the suspension reached it, nor through a restart: the default +// restart script goes straight to the process manager, and the stop/start path +// must not stop the server first only to be refused on the start. +func TestSuspendedServer_StartAndRestartAreRefused(t *testing.T) { + tests := []struct { + name string + command domain.ServerCommand + restartScript string + wantError string + }{ + { + name: "start", + command: domain.Start, + wantError: "[game_server_commands.defaultStartServer] start refused: server is blocked", + }, + { + name: "restart through the process manager", + command: domain.Restart, + restartScript: "{command}", + wantError: "[game_server_commands.defaultRestartServer] restart refused: server is blocked", + }, + { + name: "restart through stop and start", + command: domain.Restart, + restartScript: "", + wantError: "[game_server_commands.defaultRestartServer] restart refused: server is blocked", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := givenSuspendedServerWithStartCommand(t, "./run.sh") + cmd, pm := givenLifecycleCommand(t, tt.restartScript, tt.command, server) + + err := cmd.Execute(context.Background(), server) + + require.ErrorIs(t, err, domain.ErrServerBlocked) + assert.Equal(t, tt.wantError, err.Error()) + assert.Equal(t, gameservercommands.ErrorResult, cmd.Result()) + assert.True(t, cmd.IsComplete()) + assert.Equal( + t, + "The server is suspended in the panel and cannot be started until the suspension is lifted.\n", + string(cmd.ReadOutput()), + ) + assert.Empty(t, pm.Calls()) + }) + } +} + +// The same commands reach the process manager once the server is not +// suspended, which is what keeps the refusal test above from passing vacuously. +func TestNotSuspendedServer_StartAndRestartReachProcessManager(t *testing.T) { + tests := []struct { + name string + command domain.ServerCommand + restartScript string + wantCalls []string + }{ + { + name: "start", + command: domain.Start, + wantCalls: []string{"start"}, + }, + { + name: "restart through the process manager", + command: domain.Restart, + restartScript: "{command}", + wantCalls: []string{"restart"}, + }, + { + name: "restart through stop and start", + command: domain.Restart, + restartScript: "", + wantCalls: []string{"status", "stop", "start"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := givenServerWithStartCommand(t, "./run.sh") + cmd, pm := givenLifecycleCommand(t, tt.restartScript, tt.command, server) + + err := cmd.Execute(context.Background(), server) + + require.NoError(t, err) + assert.Equal(t, gameservercommands.SuccessResult, cmd.Result()) + assert.Equal(t, tt.wantCalls, pm.Calls()) + }) + } +} diff --git a/internal/app/servers_loop/loop.go b/internal/app/servers_loop/loop.go index b9ac626..8a04fbf 100644 --- a/internal/app/servers_loop/loop.go +++ b/internal/app/servers_loop/loop.go @@ -3,6 +3,7 @@ package serversloop import ( "context" "runtime/debug" + "slices" "sync" "time" @@ -48,6 +49,19 @@ const ( maxRestartDelay = 10 * time.Minute restartDelayFactor = 3 settleUptime = 1 * time.Minute + + // A server suspended in the panel that is found running is stopped by the + // loop, but only after it has been seen running for the grace period. The + // panel sends a stop task together with the suspension, and that stop, + // which leaves a record the panel can show, is the one expected to do it. + // The loop is the safety net for a stop that failed or never arrived, and + // for a supervisor that brought the server up on its own, such as a Windows + // service starting with the machine. + suspendedStopGrace = 30 * time.Second + // A stop that did not take is retried at this pace rather than on every + // probe of a server that is still up. + suspendedStopRetry = 1 * time.Minute + stopCommandTimeout = 10 * time.Minute ) type ServerStatusReporter interface { @@ -66,6 +80,15 @@ const ( probeStopped ) +// suspendedRun is what the loop remembers about a suspended server it found +// running. +type suspendedRun struct { + seenAt time.Time + + // stoppedAt is when the loop last launched a stop, zero before the first. + stoppedAt time.Time +} + type ServersLoop struct { cfg *config.Config serverRepo domain.ServerRepository @@ -74,9 +97,14 @@ type ServersLoop struct { nowFn func() time.Time - // starts is held by the asynchronous start goroutines so shutdown can wait - // for them instead of leaving them behind. + // suspendedRunning holds the suspended servers last seen running, by id. + // Only the tick touches it. + suspendedRunning map[int]*suspendedRun + + // starts and stops are held by the asynchronous start and stop goroutines + // so shutdown can wait for them instead of leaving them behind. starts sync.WaitGroup + stops sync.WaitGroup } func NewServersLoop( @@ -89,6 +117,7 @@ func NewServersLoop( serverRepo: serverRepo, serverCommandFactory: serverCommandFactory, nowFn: time.Now, + suspendedRunning: make(map[int]*suspendedRun), } } @@ -108,6 +137,7 @@ func (l *ServersLoop) loop(ctx context.Context) error { ticker := time.NewTicker(loopDuration) defer ticker.Stop() defer l.starts.Wait() + defer l.stops.Wait() for { select { @@ -126,6 +156,13 @@ func (l *ServersLoop) tick(ctx context.Context) { return } + // A server that left the cache and comes back is a new sighting. + for id := range l.suspendedRunning { + if !slices.Contains(ids, id) { + delete(l.suspendedRunning, id) + } + } + if len(ids) == 0 { log.Debug("No servers found to check status") @@ -184,6 +221,14 @@ func (l *ServersLoop) processServer(ctx context.Context, id int) { return } + if result == probeRunning && server.IsSuspended() { + l.stopSuspended(ctx, server) + + return + } + + delete(l.suspendedRunning, id) + if result == probeRunning { l.noticeRunning(server) return @@ -362,6 +407,82 @@ func (l *ServersLoop) startServer(ctx context.Context, server *domain.Server, at } } +// stopSuspended stops a server that is running although the panel suspended +// it, once suspendedStopGrace has passed since the loop first saw it so. Like a +// start, the stop runs off the tick and holds the server's lease, so it never +// runs alongside a stop task the panel sent. +func (l *ServersLoop) stopSuspended(ctx context.Context, server *domain.Server) { + now := l.now() + + run, seen := l.suspendedRunning[server.ID()] + if !seen { + l.suspendedRunning[server.ID()] = &suspendedRun{seenAt: now} + + return + } + + if now.Sub(run.seenAt) < suspendedStopGrace { + return + } + + if !run.stoppedAt.IsZero() && now.Sub(run.stoppedAt) < suspendedStopRetry { + return + } + + release, ok := l.serverCommandFactory.TryLockServer(server.ID()) + if !ok { + logger.Debug(ctx, "Skipping the stop of a suspended server, another command is running for this server") + + return + } + + run.stoppedAt = now + + logger.Warn(ctx, "Game server is suspended in the panel but still running, stopping it") + + l.stops.Add(1) + + go func() { + defer l.stops.Done() + defer release() + + l.stopServer(ctx, server) + }() +} + +func (l *ServersLoop) stopServer(ctx context.Context, server *domain.Server) { + defer func() { + if r := recover(); r != nil { + logger.Logger(ctx). + WithField("panic", r). + WithField("stack", string(debug.Stack())). + Error("Panic while stopping suspended game server") + } + }() + + stopCMD := l.serverCommandFactory.LoadServerCommand(domain.Stop, server) + if stopCMD == nil { + logger.Error(ctx, errors.New("stop command is not implemented for this server")) + return + } + + ctxWithTimeout, cancel := context.WithTimeout(ctx, stopCommandTimeout) + defer cancel() + + err := stopCMD.Execute(ctxWithTimeout, server) + if err != nil { + logger.Logger(ctx).WithError(err).Error("Stopping suspended game server failed") + return + } + + if stopCMD.Result() != commands.SuccessResult { + logger.Logger(ctx). + WithField("result", stopCMD.Result()). + WithField("output", string(stopCMD.ReadOutput())). + Error("Stopping suspended game server was rejected by the process manager") + } +} + // restartDelay returns how long to wait before the given attempt may be // followed by another one. Without it a server that dies immediately after every // start is restarted every five seconds forever, which for the container process diff --git a/internal/app/servers_loop/loop_test.go b/internal/app/servers_loop/loop_test.go index 1a56ce6..9b805f1 100644 --- a/internal/app/servers_loop/loop_test.go +++ b/internal/app/servers_loop/loop_test.go @@ -32,6 +32,11 @@ type fakeProcessManager struct { startCalls int statusCalls int + stopCalls int + + // stopLeavesRunning makes a stop report success while the server stays up, + // the way a stop that did not take looks from the outside. + stopLeavesRunning bool // startPanics makes the start command panic, to prove one broken process // manager cannot take the daemon down with it. @@ -101,6 +106,13 @@ func (pm *fakeProcessManager) StartCalls() int { return pm.startCalls } +func (pm *fakeProcessManager) StopCalls() int { + pm.mu.Lock() + defer pm.mu.Unlock() + + return pm.stopCalls +} + func (pm *fakeProcessManager) StatusCalls() int { pm.mu.Lock() defer pm.mu.Unlock() @@ -130,6 +142,15 @@ func (pm *fakeProcessManager) Uninstall( func (pm *fakeProcessManager) Stop( _ context.Context, _ *domain.Server, _ io.Writer, ) (domain.Result, error) { + pm.mu.Lock() + defer pm.mu.Unlock() + + pm.stopCalls++ + + if !pm.stopLeavesRunning { + pm.running = false + } + return domain.SuccessResult, nil } @@ -290,13 +311,14 @@ func newLoopFixture(t *testing.T, server *domain.Server) *loopFixture { return f } -// tick runs one iteration and waits for any start it launched, since starts run -// off the tick on their own goroutine. +// tick runs one iteration and waits for any start or stop it launched, since +// they run off the tick on their own goroutines. func (f *loopFixture) tick(t *testing.T) { t.Helper() f.loop.tick(context.Background()) f.loop.starts.Wait() + f.loop.stops.Wait() } func (f *loopFixture) advance(d time.Duration) { @@ -359,6 +381,143 @@ func TestTick_BlockedServerIsNotStarted(t *testing.T) { assert.Equal(t, 0, f.pm.StartCalls()) } +// runTicks drives the loop at the real ticker's pace for the given span: a tick +// now, then one every loop interval while less than span has passed. +func (f *loopFixture) runTicks(t *testing.T, span time.Duration) { + t.Helper() + + for elapsed := time.Duration(0); elapsed < span; elapsed += loopDuration { + f.tick(t) + f.advance(loopDuration) + } +} + +// A suspended server that is still running — the stop task failed, or a Windows +// service came up with the machine — is stopped by the loop, but only after the +// grace period that leaves the panel's own stop task to do it first. +func TestTick_SuspendedRunningServerIsStoppedAfterGrace(t *testing.T) { + server := givenServer(withBlocked()) + f := newLoopFixture(t, server) + f.pm.setRunning(true) + + f.runTicks(t, suspendedStopGrace) + require.Equal(t, 0, f.pm.StopCalls(), "not within the grace period") + + f.tick(t) + + assert.Equal(t, 1, f.pm.StopCalls()) + assert.Equal(t, 0, f.pm.StartCalls()) + + f.advance(loopDuration) + f.runTicks(t, suspendedStopRetry*2) + + assert.Equal(t, 1, f.pm.StopCalls(), "a stopped server is not stopped again") + assert.False(t, server.IsActive()) +} + +func TestTick_RunningServerIsNotStoppedWhenNotSuspended(t *testing.T) { + server := givenServer() + f := newLoopFixture(t, server) + f.pm.setRunning(true) + + f.runTicks(t, suspendedStopGrace+suspendedStopRetry*2) + + assert.Equal(t, 0, f.pm.StopCalls()) + assert.True(t, server.IsActive()) +} + +// A stop that did not take is retried, but not on every probe. +func TestTick_SuspendedServerStopIsRetriedOncePerInterval(t *testing.T) { + server := givenServer(withBlocked()) + f := newLoopFixture(t, server) + f.pm.setRunning(true) + f.pm.stopLeavesRunning = true + + f.runTicks(t, suspendedStopGrace) + f.tick(t) + require.Equal(t, 1, f.pm.StopCalls()) + + f.advance(loopDuration) + f.runTicks(t, suspendedStopRetry-loopDuration) + require.Equal(t, 1, f.pm.StopCalls(), "not again within the retry interval") + + f.tick(t) + + assert.Equal(t, 2, f.pm.StopCalls()) +} + +// The grace is counted from when the loop saw the server running while +// suspended. It starts over once the server was seen stopped, when the +// suspension was lifted and imposed again, and for a server that left the cache +// and came back. +func TestTick_SuspendedStopGraceStartsOver(t *testing.T) { + tests := []struct { + name string + interval func(f *loopFixture) + }{ + { + name: "after the server was seen stopped", + interval: func(f *loopFixture) { + f.pm.setRunning(false) + }, + }, + { + name: "after the suspension was lifted", + interval: func(f *loopFixture) { + f.repo.Set([]*domain.Server{givenServer()}) + }, + }, + { + name: "after the server left the cache", + interval: func(f *loopFixture) { + f.repo.Clear() + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f := newLoopFixture(t, givenServer(withBlocked())) + f.pm.setRunning(true) + + f.runTicks(t, suspendedStopGrace-loopDuration) + tt.interval(f) + f.tick(t) + f.advance(loopDuration) + f.repo.Set([]*domain.Server{givenServer(withBlocked())}) + f.pm.setRunning(true) + + f.runTicks(t, suspendedStopGrace) + assert.Equal(t, 0, f.pm.StopCalls(), "a new sighting waits out the whole grace period") + + f.tick(t) + assert.Equal(t, 1, f.pm.StopCalls()) + }) + } +} + +// The stop shares the server's lease with the task manager: while a stop task +// from the panel is still running, the loop leaves the server alone, and a +// skipped stop does not count as an attempt. +func TestTick_SuspendedServerIsNotStoppedWhileAnotherCommandHoldsIt(t *testing.T) { + server := givenServer(withBlocked()) + f := newLoopFixture(t, server) + f.pm.setRunning(true) + f.runTicks(t, suspendedStopGrace) + + release, ok := f.factory.TryLockServer(server.ID()) + require.True(t, ok) + f.tick(t) + release() + + require.Equal(t, 0, f.pm.StopCalls()) + + f.advance(loopDuration) + f.tick(t) + + assert.Equal(t, 1, f.pm.StopCalls()) +} + func TestTick_DisabledServerIsNotStarted(t *testing.T) { server := givenServer(withDisabled()) f := newLoopFixture(t, server) diff --git a/internal/app/servers_scheduler/execution.go b/internal/app/servers_scheduler/execution.go index 7f523d3..c5b560d 100644 --- a/internal/app/servers_scheduler/execution.go +++ b/internal/app/servers_scheduler/execution.go @@ -33,6 +33,13 @@ func (s *Scheduler) executeNow(parent context.Context, rec *executionRecord, ser return } + if server.IsSuspended() && refusedWhileSuspended(domainCmd) { + s.sendFinished(rec, + pb.ServerTaskExecutionStatus_SERVER_TASK_EXECUTION_STATUS_SKIPPED, + domain.ErrServerBlocked.Error(), nil, s.now()) + return + } + cmd := s.commandLoader.LoadServerCommand(domainCmd, server) if cmd == nil { s.sendFinished(rec, diff --git a/internal/app/servers_scheduler/helpers_test.go b/internal/app/servers_scheduler/helpers_test.go index 0882069..2196aaf 100644 --- a/internal/app/servers_scheduler/helpers_test.go +++ b/internal/app/servers_scheduler/helpers_test.go @@ -164,11 +164,15 @@ func (l *fakeLoader) Calls() int { } func newServerForTask(id int) *domain.Server { + return newServerForTaskWithState(id, false) +} + +func newServerForTaskWithState(id int, blocked bool) *domain.Server { return domain.NewServer( id, true, domain.ServerInstalled, - false, + blocked, "server-"+strconv.Itoa(id), "uuid-"+strconv.Itoa(id), "short-"+strconv.Itoa(id), diff --git a/internal/app/servers_scheduler/policy.go b/internal/app/servers_scheduler/policy.go index c24e477..ff3f316 100644 --- a/internal/app/servers_scheduler/policy.go +++ b/internal/app/servers_scheduler/policy.go @@ -13,6 +13,19 @@ import ( // tick without policy intervention. const catchupGracePeriod = time.Minute +// refusedWhileSuspended reports whether a scheduled command is skipped for a +// server suspended in the panel: the same commands the panel refuses to send, +// everything that starts the game server or rebuilds its files. A scheduled +// stop still runs. +func refusedWhileSuspended(c domain.ServerCommand) bool { + switch c { + case domain.Start, domain.Restart, domain.Update, domain.Install, domain.Reinstall: + return true + default: + return false + } +} + func mapProtoCommandToDomain(c pb.ServerTaskCommand) (domain.ServerCommand, bool) { switch c { case pb.ServerTaskCommand_SERVER_TASK_COMMAND_START: diff --git a/internal/app/servers_scheduler/scheduler_suspended_test.go b/internal/app/servers_scheduler/scheduler_suspended_test.go new file mode 100644 index 0000000..9cefd04 --- /dev/null +++ b/internal/app/servers_scheduler/scheduler_suspended_test.go @@ -0,0 +1,97 @@ +package serversscheduler + +import ( + "context" + "testing" + "time" + + pb "github.com/gameap/gameap/pkg/proto" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/durationpb" + "google.golang.org/protobuf/types/known/timestamppb" +) + +func runScheduledCommandOnce( + t *testing.T, command pb.ServerTaskCommand, suspended bool, +) (*fakeSender, *fakeLoader) { + t.Helper() + + now := time.Date(2026, 9, 26, 12, 0, 0, 0, time.UTC) + server := newServerForTaskWithState(42, suspended) + loader := &fakeLoader{cmd: &fakeCommand{output: []byte("ok")}} + sender := newFakeSender() + scheduler := newTestScheduler(loader, newFakeServerRepo(server), sender) + freezeTime(scheduler, now) + + scheduler.ApplySnapshot(&pb.ServerTaskSnapshot{ + Tasks: []*pb.ServerTask{{ + Id: 7, + ServerId: 42, + Version: 1, + Command: command, + ExecuteDate: timestamppb.New(now.Add(-time.Second)), + RepeatPeriod: durationpb.New(time.Hour), + OverlapPolicy: pb.ServerTaskOverlapPolicy_SERVER_TASK_OVERLAP_POLICY_SKIP, + CatchupPolicy: pb.ServerTaskCatchupPolicy_SERVER_TASK_CATCHUP_POLICY_SKIP, + Enabled: true, + }}, + }) + + scheduler.tick(context.Background()) + waitForFinished(t, sender, 1) + + return sender, loader +} + +// A suspended server is not brought up, updated or reinstalled by a schedule the +// operator set up before the suspension. The run is reported as skipped rather +// than failed: nothing went wrong, and the schedule carries on for when the +// suspension is lifted. +func TestExecuteNow_SuspendedServerSkipsCommandsThatWouldStartIt(t *testing.T) { + tests := []struct { + name string + command pb.ServerTaskCommand + }{ + {name: "start", command: pb.ServerTaskCommand_SERVER_TASK_COMMAND_START}, + {name: "restart", command: pb.ServerTaskCommand_SERVER_TASK_COMMAND_RESTART}, + {name: "update", command: pb.ServerTaskCommand_SERVER_TASK_COMMAND_UPDATE}, + {name: "reinstall", command: pb.ServerTaskCommand_SERVER_TASK_COMMAND_REINSTALL}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sender, loader := runScheduledCommandOnce(t, tt.command, true) + + started := sender.Started() + require.Len(t, started, 1) + finished := sender.Finished() + require.Len(t, finished, 1) + assert.Equal(t, started[0].ExecutionId, finished[0].ExecutionId) + assert.Equal(t, pb.ServerTaskExecutionStatus_SERVER_TASK_EXECUTION_STATUS_SKIPPED, finished[0].Status) + assert.Equal(t, "server is blocked", finished[0].ErrorMessage) + assert.Empty(t, finished[0].OutputInline) + assert.Equal(t, 0, loader.Calls(), "no command is even built for a skipped run") + }) + } +} + +func TestExecuteNow_SuspendedServerStillRunsScheduledStop(t *testing.T) { + sender, loader := runScheduledCommandOnce(t, pb.ServerTaskCommand_SERVER_TASK_COMMAND_STOP, true) + + finished := sender.Finished() + require.Len(t, finished, 1) + assert.Equal(t, pb.ServerTaskExecutionStatus_SERVER_TASK_EXECUTION_STATUS_SUCCESS, finished[0].Status) + assert.Empty(t, finished[0].ErrorMessage) + assert.Equal(t, []byte("ok"), finished[0].OutputInline) + assert.Equal(t, 1, loader.Calls()) +} + +func TestExecuteNow_NotSuspendedServerRunsScheduledStart(t *testing.T) { + sender, loader := runScheduledCommandOnce(t, pb.ServerTaskCommand_SERVER_TASK_COMMAND_START, false) + + finished := sender.Finished() + require.Len(t, finished, 1) + assert.Equal(t, pb.ServerTaskExecutionStatus_SERVER_TASK_EXECUTION_STATUS_SUCCESS, finished[0].Status) + assert.Equal(t, 1, loader.Calls()) +} diff --git a/internal/processmanager/README.md b/internal/processmanager/README.md index e594b4b..18bceb3 100644 --- a/internal/processmanager/README.md +++ b/internal/processmanager/README.md @@ -40,6 +40,14 @@ takes over only once the supervisor has given up and left the unit inactive or failed — and because a unit that exhausted its start limit refuses every further start, the daemon runs `systemctl reset-failed` before starting one. +A server suspended in the panel (`blocked`) is never started by the daemon: start +and restart commands are refused, the servers loop does not bring it back, a +scheduled start, restart, update or reinstall is skipped, and an installation or +update does not start it again afterwards. A copy that runs anyway — a `shawl` or +`winsw` service starting with Windows, a stop task that failed — is stopped by the +servers loop once it has been seen running for 30 seconds. The delay leaves the +stop task the panel sends with the suspension to do it first. + A status check that cannot be evaluated — a probe killed by its own deadline, an unreachable container runtime — is reported as undetermined rather than as a stopped server. The loop leaves such a server alone: treating an unreadable probe diff --git a/test/functional/game_server_suite.go b/test/functional/game_server_suite.go index b3d7d6d..5e2e8c3 100644 --- a/test/functional/game_server_suite.go +++ b/test/functional/game_server_suite.go @@ -66,7 +66,17 @@ func (suite *GameServerSuite) GivenServerWithSettings( ) *domain.Server { suite.T().Helper() - return suite.givenServer(startCommand, stopCommand, defaultServerVars(), settings) + return suite.givenServer(startCommand, stopCommand, defaultServerVars(), settings, false) +} + +// GivenSuspendedServerWithStartAndStopCommand builds a server the panel has +// suspended (blocked). +func (suite *GameServerSuite) GivenSuspendedServerWithStartAndStopCommand( + startCommand string, stopCommand string, +) *domain.Server { + suite.T().Helper() + + return suite.givenServer(startCommand, stopCommand, defaultServerVars(), domain.Settings{}, true) } // GivenServerWithVars builds a server with explicit vars, which is how a test @@ -76,7 +86,7 @@ func (suite *GameServerSuite) GivenServerWithVars( ) *domain.Server { suite.T().Helper() - return suite.givenServer(startCommand, stopCommand, vars, domain.Settings{}) + return suite.givenServer(startCommand, stopCommand, vars, domain.Settings{}, false) } func defaultServerVars() map[string]string { @@ -87,7 +97,7 @@ func defaultServerVars() map[string]string { } func (suite *GameServerSuite) givenServer( - startCommand string, stopCommand string, vars map[string]string, settings domain.Settings, + startCommand string, stopCommand string, vars map[string]string, settings domain.Settings, blocked bool, ) *domain.Server { suite.T().Helper() @@ -95,7 +105,7 @@ func (suite *GameServerSuite) givenServer( 1337, true, domain.ServerInstalled, - false, + blocked, "name", "759b875e-d910-11eb-aff7-d796d7fcf7ef", "759b875e", diff --git a/test/functional/serverscommand/restart/restart_test.go b/test/functional/serverscommand/restart/restart_test.go index b0ca1b0..0abaed2 100644 --- a/test/functional/serverscommand/restart/restart_test.go +++ b/test/functional/serverscommand/restart/restart_test.go @@ -32,6 +32,27 @@ func (suite *Suite) TestRestartViaStartStop_ServerIsActive_ExecutedStatusStopAnd } } +// A running suspended server must not be stopped by a restart that is then +// refused on the start: nothing runs at all. +func (suite *Suite) TestRestartViaStartStop_SuspendedServer_NothingIsExecuted() { + suite.GivenServerIsActive() + server := suite.GivenSuspendedServerWithStartAndStopCommand( + serverscommand.CommandScript+" start", + serverscommand.CommandScript+" stop", + ) + cmd := suite.CommandFactory.LoadServerCommand(domain.Restart, server) + + err := cmd.Execute(context.Background(), server) + + suite.Require().ErrorIs(err, domain.ErrServerBlocked) + suite.Assert().True(cmd.IsComplete()) + suite.Assert().Equal(gameservercommands.ErrorResult, cmd.Result()) + suite.Assert().Equal( + "The server is suspended in the panel and cannot be started until the suspension is lifted.\n", + string(cmd.ReadOutput()), + ) +} + func (suite *Suite) TestRestartViaStartStop_ServerIsNotActive_ExecutedStatusAndStartCommands() { suite.GivenServerIsDown() server := suite.GivenServerWithStartAndStopCommand( diff --git a/test/functional/serverscommand/start/start_test.go b/test/functional/serverscommand/start/start_test.go index 1b88ae4..fb70809 100644 --- a/test/functional/serverscommand/start/start_test.go +++ b/test/functional/serverscommand/start/start_test.go @@ -33,6 +33,24 @@ func (suite *Suite) TestStartSuccess() { } } +func (suite *Suite) TestStart_SuspendedServer_StartScriptIsNotExecuted() { + server := suite.GivenSuspendedServerWithStartAndStopCommand( + serverscommand.CommandScript+" start", + serverscommand.CommandScript+" stop", + ) + cmd := suite.CommandFactory.LoadServerCommand(domain.Start, server) + + err := cmd.Execute(context.Background(), server) + + suite.Require().ErrorIs(err, domain.ErrServerBlocked) + suite.Assert().True(cmd.IsComplete()) + suite.Assert().Equal(gameservercommands.ErrorResult, cmd.Result()) + suite.Assert().Equal( + "The server is suspended in the panel and cannot be started until the suspension is lifted.\n", + string(cmd.ReadOutput()), + ) +} + func (suite *Suite) TestStartInvalidCommand() { server := suite.GivenServerWithStartAndStopCommand( "./invalid_command.sh", From e843d9a79a2b9583941e4651ee17997b3ba2b5ef Mon Sep 17 00:00:00 2001 From: et-nik Date: Thu, 15 Oct 2026 01:48:12 +0200 Subject: [PATCH 2/2] fix tests --- .github/workflows/test.yml | 2 +- go.mod | 20 +++++++++---------- go.sum | 40 +++++++++++++++++++------------------- 3 files changed, 31 insertions(+), 31 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9c1c44a..cf75da0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -43,7 +43,7 @@ jobs: - name: Run golangci-lint uses: golangci/golangci-lint-action@v9 with: - version: v2.13.2 + version: v2.14.0 args: --timeout=10m - name: Unit Tests diff --git a/go.mod b/go.mod index fa1600a..e55944c 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/gameap/daemon -go 1.27.0 +go 1.27.2 require ( github.com/bodgit/sevenzip v1.6.5 @@ -79,7 +79,7 @@ require ( github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect github.com/felixge/httpsnoop v1.1.0 // indirect github.com/go-jose/go-jose/v4 v4.1.4 // indirect - github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/logr v1.4.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.2.6 // indirect github.com/google/s2a-go v0.1.9 // indirect @@ -112,17 +112,17 @@ require ( go.opentelemetry.io/contrib/detectors/gcp v1.44.0 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect - go.opentelemetry.io/otel v1.44.0 // indirect - go.opentelemetry.io/otel/metric v1.44.0 // indirect - go.opentelemetry.io/otel/sdk v1.44.0 // indirect - go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect - go.opentelemetry.io/otel/trace v1.44.0 // indirect + go.opentelemetry.io/otel v1.45.0 // indirect + go.opentelemetry.io/otel/metric v1.45.0 // indirect + go.opentelemetry.io/otel/sdk v1.45.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.45.0 // indirect + go.opentelemetry.io/otel/trace v1.45.0 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect go4.org v0.0.0-20260112195520-a5071408f32f // indirect - golang.org/x/crypto v0.56.0 // indirect - golang.org/x/net v0.58.0 // indirect + golang.org/x/crypto v0.57.0 // indirect + golang.org/x/net v0.60.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect - golang.org/x/text v0.41.0 // indirect + golang.org/x/text v0.42.0 // indirect golang.org/x/time v0.15.0 // indirect google.golang.org/api v0.294.0 // indirect google.golang.org/genproto v0.0.0-20260715232425-e75dac1f907d // indirect diff --git a/go.sum b/go.sum index ebf32ab..e3b79de 100644 --- a/go.sum +++ b/go.sum @@ -124,8 +124,8 @@ github.com/gameap/gameapctl v0.34.0/go.mod h1:GEUPbmHDrnFYxVUqN3FpE7afnrEQkjtdrl github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= -github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= @@ -241,30 +241,30 @@ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.6 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0/go.mod h1:Sje3i3MjSPKTSPvVWCaL8ugBzJwik3u4smCjUeuupqg= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI= -go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= -go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU= +go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0 h1:hqxVTu/GtBF+vJ8d1fzW7fRxZFvgoDjWcxwwCaFDYpU= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0/go.mod h1:z5fVEF4X5v0ESvlJqBrrFlBVoj5EQuefZpzsu7R+x5Q= -go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= -go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= -go.opentelemetry.io/otel/metric/x v0.66.0 h1:YkCrx1zLOChi9ZcZ6euupOcsgzbVlec7D/xoEU1+cTA= -go.opentelemetry.io/otel/metric/x v0.66.0/go.mod h1:d1+BDj9t96do0/1LoU1ayfCv79ZgNE41qbhBvnMOBZk= -go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= -go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= -go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= -go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= -go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= -go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M= +go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s= +go.opentelemetry.io/otel/metric/x v0.67.0 h1:PcicCNZFkZ4bXfSooXdo3WN7RBOVOtjVdo1wD358Uns= +go.opentelemetry.io/otel/metric/x v0.67.0/go.mod h1:FBjCWZe6wgcqxcMtjdGiClDKXb2YxxXii0CXftE4QtI= +go.opentelemetry.io/otel/sdk v1.45.0 h1:4VVSMgQ83dUgW2aoX5f6JgLvHwIvzcuLnF9lUdCSpCw= +go.opentelemetry.io/otel/sdk v1.45.0/go.mod h1:Sr40LgXV7DsKMMJMKOhUWOgMWTfAaqvm2kF0g7ilwuA= +go.opentelemetry.io/otel/sdk/metric v1.45.0 h1:oVFszMfyj1Am6s24Vtc7wBb8BKLcwepJjNEYILuiE3o= +go.opentelemetry.io/otel/sdk/metric v1.45.0/go.mod h1:vUWUxDZvu1WVRj8JA8S0AdhsPrZoDpA2DdZauIh4mDA= +go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag= +go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= go4.org v0.0.0-20260112195520-a5071408f32f h1:ziUVAjmTPwQMBmYR1tbdRFJPtTcQUI12fH9QQjfb0Sw= go4.org v0.0.0-20260112195520-a5071408f32f/go.mod h1:ZRJnO5ZI4zAwMFp+dS1+V6J6MSyAowhRqAE+DPa1Xp0= -golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y= -golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I= -golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= -golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU= +golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= @@ -273,8 +273,8 @@ golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= -golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= -golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=