mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-10-12 01:58:46 +02:00
- Stop closes the control server right after the deactivation-pin check, so a stopping process no longer answers /started for its successor. releaseResources still closes it for stop paths that skip Stop; a sync.Once makes the double close safe. - upgrade and restart wait for a 200 from /started via newReadySocketControlClient; a 408 (start never finished) now rolls the upgrade back and makes restart warn instead of reporting success. - The plain socket client still accepts any answer, for the deactivation-pin path.
151 lines
6.0 KiB
Go
151 lines
6.0 KiB
Go
package cli
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/kardianos/service"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/Control-D-Inc/ctrld"
|
|
)
|
|
|
|
// A stopping process must not answer /started for the service that replaces
|
|
// it. `ctrld upgrade` treats any reply as "the new binary is up", so a reply
|
|
// from the old process, while it waits for its components to stop, turned an
|
|
// upgrade to a never-ready build into "Upgrade successful" instead of a
|
|
// rollback (ctrld-qa upgrade-readiness-rollback on v1.0).
|
|
func TestStopClosesControlServerBeforeRunFinishes(t *testing.T) {
|
|
// A short path, as the other control server tests use: t.TempDir() is
|
|
// longer than a Unix socket path may be.
|
|
f, err := os.CreateTemp("", "")
|
|
require.NoError(t, err)
|
|
sock := f.Name()
|
|
f.Close()
|
|
t.Cleanup(func() { os.Remove(sock) })
|
|
cs, err := newControlServer(sock)
|
|
require.NoError(t, err)
|
|
cs.register(startedPath, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }))
|
|
require.NoError(t, cs.start())
|
|
|
|
p := &prog{
|
|
cfg: &ctrld.Config{},
|
|
cs: cs,
|
|
waitCh: make(chan struct{}), stopCh: make(chan struct{}), runDone: make(chan struct{}),
|
|
runAbortCh: make(chan struct{}), dnsWatcherStopCh: make(chan struct{}),
|
|
pinCodeValidCh: make(chan struct{}, 1),
|
|
}
|
|
p.logger.Store(mainLog.Load())
|
|
p.pinCodeValidCh <- struct{}{} // accept the stop even if a deactivation pin is configured
|
|
|
|
cc := newControlClient(sock)
|
|
resp, err := cc.post(startedPath, nil)
|
|
require.NoError(t, err, "the running service answers")
|
|
resp.Body.Close()
|
|
|
|
require.NoError(t, p.Stop(nil))
|
|
// runDone is still open: the run has not finished, as when a component is
|
|
// slow to stop. The socket must already be closed.
|
|
_, err = cc.post(startedPath, nil)
|
|
require.Error(t, err, "a stopped service answered /started")
|
|
|
|
p.stopControlServer() // the later releaseResources call is harmless
|
|
}
|
|
|
|
// startTestControlServer starts a control server on a short socket path in a
|
|
// directory of its own, as the socket client expects, answering /started with
|
|
// whatever started returns.
|
|
func startTestControlServer(t *testing.T, started func() int) (dir, sock string, cs *controlServer) {
|
|
t.Helper()
|
|
// A short path, as the other control server tests use: t.TempDir() is
|
|
// longer than a Unix socket path may be.
|
|
dir, err := os.MkdirTemp("", "cs")
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { os.RemoveAll(dir) })
|
|
sock = filepath.Join(dir, ctrldControlUnixSock)
|
|
cs, err = newControlServer(sock)
|
|
require.NoError(t, err)
|
|
cs.register(startedPath, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(started()) }))
|
|
cs.register(listClientsPath, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }))
|
|
require.NoError(t, cs.start())
|
|
t.Cleanup(func() { _ = cs.stop() })
|
|
// start serves on a goroutine, and the server tracks the listener only
|
|
// once that runs: a Shutdown before then leaves the listener, and its
|
|
// unlink, to Serve's own deferred close. Wait until it answers, as a
|
|
// service that has been running does. A path other than /started keeps
|
|
// the started func's call count to the test's own requests.
|
|
require.Eventually(t, func() bool {
|
|
resp, err := newControlClient(sock).post(listClientsPath, nil)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
resp.Body.Close()
|
|
return true
|
|
}, 5*time.Second, 10*time.Millisecond, "control server did not start serving")
|
|
return dir, sock, cs
|
|
}
|
|
|
|
// Stop unlinks the socket path as it closes the socket, before the successor
|
|
// starts: unlinking it later removed the path the successor had bound.
|
|
func TestStopRemovesControlSocketPath(t *testing.T) {
|
|
_, sock, cs := startTestControlServer(t, func() int { return http.StatusOK })
|
|
p := &prog{
|
|
cfg: &ctrld.Config{},
|
|
cs: cs,
|
|
waitCh: make(chan struct{}), stopCh: make(chan struct{}), runDone: make(chan struct{}),
|
|
runAbortCh: make(chan struct{}), dnsWatcherStopCh: make(chan struct{}),
|
|
pinCodeValidCh: make(chan struct{}, 1),
|
|
}
|
|
p.logger.Store(mainLog.Load())
|
|
p.pinCodeValidCh <- struct{}{}
|
|
|
|
require.NoError(t, p.Stop(nil))
|
|
_, err := os.Stat(sock)
|
|
require.True(t, os.IsNotExist(err), "socket path still present after Stop: %v", err)
|
|
}
|
|
|
|
// A stop path that never calls Stop still closes the control server.
|
|
func TestReleaseResourcesClosesControlServer(t *testing.T) {
|
|
_, sock, cs := startTestControlServer(t, func() int { return http.StatusOK })
|
|
p := &prog{cfg: &ctrld.Config{}, cs: cs}
|
|
p.logger.Store(mainLog.Load())
|
|
|
|
p.releaseResources()
|
|
_, err := newControlClient(sock).post(startedPath, nil)
|
|
require.Error(t, err, "the control server answered after releaseResources")
|
|
}
|
|
|
|
// `ctrld upgrade` must not count a successor that started but never became
|
|
// ready: it answers /started with 408, and only a 200 means ready. The plain
|
|
// socket client keeps accepting any answer, because the deactivation pin check
|
|
// must reach a running service whether or not it is ready.
|
|
func TestReadySocketControlClientRequiresReady(t *testing.T) {
|
|
running := &fakeService{statuses: []service.Status{service.StatusRunning}}
|
|
|
|
t.Run("never ready", func(t *testing.T) {
|
|
dir, _, _ := startTestControlServer(t, func() int { return http.StatusRequestTimeout })
|
|
require.Nil(t, newReadySocketControlClientWithTimeout(context.Background(), running, dir, 2*time.Second),
|
|
"a service answering 408 counted as ready")
|
|
require.NotNil(t, newSocketControlClientWithTimeout(context.Background(), running, dir, 2*time.Second),
|
|
"the plain client must still reach a running service that is not ready")
|
|
})
|
|
|
|
t.Run("becomes ready", func(t *testing.T) {
|
|
var calls atomic.Int32
|
|
dir, _, _ := startTestControlServer(t, func() int {
|
|
if calls.Add(1) == 1 {
|
|
return http.StatusRequestTimeout
|
|
}
|
|
return http.StatusOK
|
|
})
|
|
require.NotNil(t, dialSocketControlServer(context.Background(), running, dir, 10*time.Second, true),
|
|
"the poll gave up on a service that became ready")
|
|
require.GreaterOrEqual(t, calls.Load(), int32(2), "the poll stopped at the 408")
|
|
})
|
|
}
|