Skip to content

Commit 63169f4

Browse files
committed
Added server status related apis
1 parent 02e8c37 commit 63169f4

8 files changed

Lines changed: 191 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ This project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- Added `openrun server stop --wait`: waits for the server process to fully exit instead of returning as soon as shutdown starts (the final litestream sync runs as the process exits, so scripts that move or restore data directories after a stop need this). Over the unix domain socket the server's pid (now returned by the stop API) is polled; over http(s) the listener port is polled as a best effort signal. Also added `openrun server status` (prints `ok` when the server connection works) and `openrun server version` (reports the server's build version and commit).
13+
1014
### Fixed
1115

1216
- The `openrun` CLI now discovers a machine scoped Windows service install: when `OPENRUN_HOME` is not set and no config is found relative to the executable (a winget binary is a links shim, so executable-relative discovery finds nothing), it checks `%ProgramData%\openrun\openrun.toml` and connects to the server's unix domain socket under that home, like `/var/lib/openrun` on Linux. Previously the CLI fell back to `$HOME\openrun` and failed to find the service's socket unless `OPENRUN_HOME` was set machine-wide.

cmd/openrun/server_cmds.go

Lines changed: 98 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,14 @@ import (
99
"errors"
1010
"fmt"
1111
"io"
12+
"net"
1213
"net/url"
1314
"os"
1415
"os/signal"
1516
"strconv"
17+
"strings"
1618
"syscall"
19+
"time"
1720

1821
"github.com/openrundev/openrun/internal/system"
1922
"github.com/openrundev/openrun/internal/types"
@@ -40,11 +43,27 @@ func getServerCommands(serverConfig *types.ServerConfig, clientConfig *types.Cli
4043
{
4144
Name: "stop",
4245
Usage: "Stop the openrun server",
43-
Flags: flags,
46+
Flags: []cli.Flag{newBoolFlag("wait", "w", "Wait for the server process to exit instead of returning as soon as shutdown starts", false)},
4447
Action: func(cCtx *cli.Context) error {
4548
return stopServer(cCtx, clientConfig)
4649
},
4750
},
51+
{
52+
Name: "status",
53+
Usage: "Report ok if the server connection works",
54+
Flags: flags,
55+
Action: func(cCtx *cli.Context) error {
56+
return serverStatus(cCtx, clientConfig)
57+
},
58+
},
59+
{
60+
Name: "version",
61+
Usage: "Report the server version",
62+
Flags: flags,
63+
Action: func(cCtx *cli.Context) error {
64+
return serverVersion(cCtx, clientConfig)
65+
},
66+
},
4867
{
4968
Name: "restart",
5069
Usage: "Restart the openrun server in-place with zero downtime, reloading the config and picking up a new binary",
@@ -202,14 +221,90 @@ func waitForShutdownSignal(server *api.Server) {
202221
}
203222
}
204223

205-
func stopServer(_ *cli.Context, clientConfig *types.ClientConfig) error {
224+
func stopServer(cCtx *cli.Context, clientConfig *types.ClientConfig) error {
206225
client := newHttpClient(clientConfig)
207226

208-
var response types.AppVersionListResponse
227+
var response types.ServerStopResponse
209228
err := client.Post("/_openrun/stop", nil, nil, &response)
210229
if err != nil && !errors.Is(err, io.EOF) {
211230
return err
212231
}
232+
if cCtx.Bool("wait") {
233+
return waitForServerExit(clientConfig, response.PID)
234+
}
235+
return nil
236+
}
237+
238+
// waitForServerExit blocks until the stopped server has fully exited: the
239+
// stop API responds when shutdown starts, and cleanup (final litestream
240+
// sync) runs as the process exits, after the listeners are already closed.
241+
// Over the unix domain socket the server's pid is polled; over http(s) the
242+
// listener port is polled instead, a best effort signal for remote servers
243+
func waitForServerExit(clientConfig *types.ClientConfig, pid int) error {
244+
serverUri := os.ExpandEnv(clientConfig.ServerUri)
245+
overTCP := strings.HasPrefix(serverUri, "http://") || strings.HasPrefix(serverUri, "https://")
246+
var addr string
247+
if overTCP {
248+
parsed, err := url.Parse(serverUri)
249+
if err != nil {
250+
return fmt.Errorf("error parsing server_uri for --wait: %w", err)
251+
}
252+
addr = parsed.Host
253+
if parsed.Port() == "" {
254+
if parsed.Scheme == "https" {
255+
addr += ":443"
256+
} else {
257+
addr += ":80"
258+
}
259+
}
260+
}
261+
262+
running := func() bool {
263+
if !overTCP && pid > 0 {
264+
return system.ProcessExists(pid)
265+
}
266+
network, target := "tcp", addr
267+
if !overTCP {
268+
network, target = "unix", serverUri
269+
}
270+
conn, err := net.DialTimeout(network, target, time.Second)
271+
if err != nil {
272+
return false
273+
}
274+
conn.Close() //nolint:errcheck
275+
return true
276+
}
277+
278+
for deadline := time.Now().Add(120 * time.Second); time.Now().Before(deadline); {
279+
if !running() {
280+
return nil
281+
}
282+
time.Sleep(100 * time.Millisecond)
283+
}
284+
return fmt.Errorf("timed out waiting for the server to exit")
285+
}
286+
287+
func serverStatus(_ *cli.Context, clientConfig *types.ClientConfig) error {
288+
client := newHttpClient(clientConfig)
289+
290+
var response types.ServerStatusResponse
291+
err := client.Get("/_openrun/server_status", nil, &response)
292+
if err != nil {
293+
return err
294+
}
295+
fmt.Println(response.Status)
296+
return nil
297+
}
298+
299+
func serverVersion(_ *cli.Context, clientConfig *types.ClientConfig) error {
300+
client := newHttpClient(clientConfig)
301+
302+
var response types.ServerVersionResponse
303+
err := client.Get("/_openrun/server_version", nil, &response)
304+
if err != nil {
305+
return err
306+
}
307+
fmt.Printf("%s (commit %s)\n", response.Version, response.Commit)
213308
return nil
214309
}
215310

internal/server/router.go

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -756,7 +756,21 @@ func (h *Handler) stopServer(r *http.Request) (any, error) {
756756
updateOperationInContext(r, "stop_server")
757757
h.server.RequestStop()
758758

759-
return map[string]any{}, nil
759+
return types.ServerStopResponse{PID: os.Getpid()}, nil
760+
}
761+
762+
// serverStatus reports "ok": reaching this handler means the connection and
763+
// authentication both worked
764+
func (h *Handler) serverStatus(_ *http.Request) (any, error) {
765+
return types.ServerStatusResponse{Status: "ok"}, nil
766+
}
767+
768+
// serverVersion reports the server's build version and commit
769+
func (h *Handler) serverVersion(r *http.Request) (any, error) {
770+
if err := h.server.enforceGlobalPerm(r.Context(), types.PermissionConfigBasicRead, ""); err != nil {
771+
return nil, err
772+
}
773+
return types.ServerVersionResponse{Version: types.GetVersion(), Commit: types.GetCommit()}, nil
760774
}
761775

762776
// restartServer performs a zero downtime in-place restart: a new server
@@ -1815,6 +1829,16 @@ func (h *Handler) serveInternal(enableBasicAuth bool) http.Handler {
18151829
h.apiHandler(w, r, enableBasicAuth, "restart_server", h.restartServer, false)
18161830
}))
18171831

1832+
// Server status: lightweight connectivity check
1833+
r.Get("/server_status", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1834+
h.apiHandler(w, r, enableBasicAuth, "server_status", h.serverStatus, false)
1835+
}))
1836+
1837+
// Server version
1838+
r.Get("/server_version", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1839+
h.apiHandler(w, r, enableBasicAuth, "server_version", h.serverVersion, false)
1840+
}))
1841+
18181842
// Get apps
18191843
r.Get("/apps", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
18201844
h.apiHandler(w, r, enableBasicAuth, "list_apps", h.getApps, false)

internal/system/process_unix.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
package system
77

88
import (
9+
"errors"
910
"os"
1011
"os/exec"
1112
"syscall"
@@ -20,3 +21,14 @@ func SetProcessGroup(cmd *exec.Cmd) {
2021
func KillGroup(process *os.Process) error {
2122
return syscall.Kill(-process.Pid, syscall.SIGKILL)
2223
}
24+
25+
// ProcessExists reports whether a process with the given pid is running.
26+
// EPERM means the process exists but is owned by another user
27+
func ProcessExists(pid int) bool {
28+
process, err := os.FindProcess(pid)
29+
if err != nil {
30+
return false
31+
}
32+
err = process.Signal(syscall.Signal(0))
33+
return err == nil || errors.Is(err, syscall.EPERM)
34+
}

internal/system/process_windows.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,14 @@ func KillGroup(process *os.Process) error {
2828
}
2929
return nil
3030
}
31+
32+
// ProcessExists reports whether a process with the given pid is running. On
33+
// Windows FindProcess opens a process handle, which fails if the pid is gone
34+
func ProcessExists(pid int) bool {
35+
process, err := os.FindProcess(pid)
36+
if err != nil {
37+
return false
38+
}
39+
process.Release() //nolint:errcheck
40+
return true
41+
}

internal/types/api.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,25 @@ type ConfigResponse struct {
310310
DynamicConfig DynamicConfig `json:"dynamic_config"`
311311
}
312312

313+
// ServerStopResponse is the response of the server stop API. The PID lets a
314+
// local client wait for the process to fully exit: the API responds when
315+
// shutdown starts, and cleanup (final litestream sync) runs as the process
316+
// exits, after the listeners are already closed
317+
type ServerStopResponse struct {
318+
PID int `json:"pid"`
319+
}
320+
321+
// ServerStatusResponse is the response of the server status API
322+
type ServerStatusResponse struct {
323+
Status string `json:"status"`
324+
}
325+
326+
// ServerVersionResponse is the response of the server version API
327+
type ServerVersionResponse struct {
328+
Version string `json:"version"`
329+
Commit string `json:"commit"`
330+
}
331+
313332
// CreateSecretRequest is the request body for storing a secret in a writable
314333
// secret provider. Either Name (explicit name) or Prefix (a unique name is
315334
// generated with the prefix) must be set. Encoding "base64" is used to pass

tests/run_cli_tests.sh

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -788,7 +788,9 @@ EOF
788788

789789
commander test $VERBOSE test_basics.yaml
790790
MATCHED_TESTS+=(test_basics.yaml)
791-
CL_CONFIG_FILE=config_basic_test.toml GOCOVERDIR=$GOCOVERDIR/../client ../openrun server stop
791+
# --wait exercises the wait-for-exit path (stop returns when shutdown
792+
# starts; --wait polls the server pid until the process is gone)
793+
CL_CONFIG_FILE=config_basic_test.toml GOCOVERDIR=$GOCOVERDIR/../client ../openrun server stop --wait
792794
SERVER_PID=""
793795
rm -rf metadata run/openrun.sock config_basic_*.toml
794796
fi

tests/test_basics.yaml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,25 @@ tests:
3737
line-count: 0
3838
exit-code: 1
3939

40+
basic052: # server status reports ok over UDS
41+
command: ../openrun server status
42+
stdout:
43+
exactly: "ok"
44+
exit-code: 0
45+
46+
basic053: # server status fails over http, admin api's are disabled there
47+
command: CL_CONFIG_FILE=config_basic_client_np.toml ../openrun server status
48+
stderr: "error: 404 page not found"
49+
stdout:
50+
line-count: 0
51+
exit-code: 1
52+
53+
basic054: # server version reports the server build version
54+
command: ../openrun server version
55+
stdout:
56+
exactly: "dev (commit dev_build)" # test binary is built without version ldflags
57+
exit-code: 0
58+
4059
basic060: ## create test1 app with default of authentication enabled
4160
command: ../openrun app create ./testapp /test1
4261
exit-code: 0

0 commit comments

Comments
 (0)