Compare commits

...
Author SHA1 Message Date
Nixevol dd5a331db2 feat: 实现 test/harness 集成测试启动器 2026-09-30 06:30:03 +08:00
9 changed files with 970 additions and 0 deletions
+23
View File
@@ -96,6 +96,29 @@
- 备选方案:失败时自动还原备份再退出。
- 影响:与任务说明一致;运维需知备份路径。
### T0.5 2026-09-30
1. **admin init / 管理员密码**
- 原条款:TASKS T0.5「自动执行 `admin init` 拿到管理员密码」;命令行启动器 JSON 含管理员密码。
- 实际做法:定义 `AdminInitializer`(默认 `CLIAdminInit`);二进制尚无 `admin` 子命令时返回 `ErrAdminInitUnsupported`,启动器跳过并继续起 serve;`admin_password` 字段为空字符串。示例集成测试只验 `/healthz`。
- 原因:`admin init` 属 P1,当前 main 仅有 `version`/`serve`。
- 备选方案:harness 内嵌假密码写入库(无表结构可写);或阻塞等 P1。
- 影响:P1 合入后无需改调用方接口,密码解析约定见 `parseAdminPassword`;Q/SDK 集成测试在拿到非空密码前勿依赖管理登录。
2. **MQTT 测试客户端范围**
- 原条款:能收发 DEVELOPMENT 第 6 节应用帧。
- 实际做法:提供 TCP / WebSocket(`/mqtt`,子协议 `mqtt`)传输层 `MQTTClient`,收发原始 MQTT 控制包字节;不实现 CONNECT/hello/主题业务。
- 原因:内置 broker 与协议处理尚未合入(连接 N / 后续任务);T0.5 先给可连传输与占位 API。
- 备选方案:引入完整 MQTT 客户端库并编假 broker。
- 影响:业务级帧测试在 broker 可用后由各线基于 `Send`/`Recv` 或再包一层完成。
3. **进程停止方式**
- 原条款:优雅停机(DEVELOPMENT 7.8 / P1)。
- 实际做法:测试启动器对子进程使用 `Kill`(Windows 上 `Interrupt` 不可靠)。
- 原因:保证并行测试与清理在 Windows 上稳定。
- 备选方案:Unix 发 SIGTERM;Windows 用 Job Object / Ctrl+Break。
- 影响:不覆盖优雅停机验收;该验收仍归 P1/Q。
## 平台 P
+161
View File
@@ -0,0 +1,161 @@
package harness
import (
"bytes"
"errors"
"fmt"
"io"
"net/http"
"net/http/cookiejar"
"net/url"
"os"
"os/exec"
"strings"
"time"
)
// ErrAdminInitUnsupported 表示当前二进制还没有 admin init 子命令。
var ErrAdminInitUnsupported = errors.New("admin init unsupported")
// AdminInitializer 在启动 serve 前初始化管理员密码。
// P1 实现 admin init 后,默认实现会解析终端输出中的密码。
type AdminInitializer interface {
Init(binPath, configPath string) (password string, err error)
}
// CLIAdminInit 调用 `nixmsg admin init`;若命令不存在则返回 ErrAdminInitUnsupported。
type CLIAdminInit struct{}
// Init 执行 admin init。
func (CLIAdminInit) Init(binPath, configPath string) (string, error) {
cmd := exec.Command(binPath, "admin", "init")
cmd.Env = append(os.Environ(), "NIXMSG_CONFIG="+configPath)
out, err := cmd.CombinedOutput()
text := string(out)
if err != nil {
if isAdminInitUnsupported(text) {
return "", ErrAdminInitUnsupported
}
return "", fmt.Errorf("admin init: %w\n%s", err, text)
}
pass := parseAdminPassword(text)
if pass == "" {
return "", fmt.Errorf("admin init succeeded but password not found in output:\n%s", text)
}
return pass, nil
}
func isAdminInitUnsupported(text string) bool {
lower := strings.ToLower(text)
return strings.Contains(lower, "unknown command: admin") ||
strings.Contains(lower, "unknown command") && strings.Contains(lower, "admin")
}
func parseAdminPassword(out string) string {
// 约定:P1 实现后密码单独占一行,或出现在 "password:" 之后。
lines := strings.Split(out, "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
lower := strings.ToLower(line)
switch {
case strings.HasPrefix(lower, "password:"):
return strings.TrimSpace(line[len("password:"):])
case strings.HasPrefix(lower, "admin password:"):
return strings.TrimSpace(line[len("admin password:"):])
}
}
for i := len(lines) - 1; i >= 0; i-- {
line := strings.TrimSpace(lines[i])
if len(line) >= 12 && !strings.Contains(strings.ToLower(line), "error") {
fields := strings.Fields(line)
if len(fields) == 1 {
return fields[0]
}
}
}
return ""
}
// AdminClient 管理接口 HTTP 客户端。
// Cookie 会话模式下,改变状态的方法会自动加 X-Nixmsg-Request: 1。
// 设置 APIToken 后走 Bearer,不再要求该头。
type AdminClient struct {
BaseURL string
HTTP *http.Client
APIToken string
}
// NewAdminClient 创建带 CookieJar 的管理客户端。baseURL 形如 http://127.0.0.1:12345。
func NewAdminClient(baseURL string) (*AdminClient, error) {
jar, err := cookiejar.New(nil)
if err != nil {
return nil, err
}
return &AdminClient{
BaseURL: strings.TrimRight(baseURL, "/"),
HTTP: &http.Client{
Timeout: 30 * time.Second,
Jar: jar,
},
}, nil
}
// SetSessionCookie 手动写入管理员会话 Cookie(测试辅助)。
func (c *AdminClient) SetSessionCookie(value string) error {
u, err := url.Parse(c.BaseURL)
if err != nil {
return err
}
c.HTTP.Jar.SetCookies(u, []*http.Cookie{{
Name: "nixmsg_admin",
Value: value,
Path: "/",
}})
return nil
}
// Do 发送请求。method 为 POST/PUT/PATCH/DELETE 且未使用 API 令牌时,自动加 X-Nixmsg-Request: 1。
func (c *AdminClient) Do(method, path string, body []byte, contentType string) (*http.Response, error) {
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
var rdr io.Reader
if body != nil {
rdr = bytes.NewReader(body)
}
req, err := http.NewRequest(method, c.BaseURL+path, rdr)
if err != nil {
return nil, err
}
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
if c.APIToken != "" {
req.Header.Set("Authorization", "Bearer "+c.APIToken)
} else if isMutatingMethod(method) {
req.Header.Set("X-Nixmsg-Request", "1")
}
return c.HTTP.Do(req)
}
// Get JSON GET。
func (c *AdminClient) Get(path string) (*http.Response, error) {
return c.Do(http.MethodGet, path, nil, "")
}
// PostJSON POST application/json。
func (c *AdminClient) PostJSON(path string, body []byte) (*http.Response, error) {
return c.Do(http.MethodPost, path, body, "application/json")
}
func isMutatingMethod(method string) bool {
switch strings.ToUpper(method) {
case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete:
return true
default:
return false
}
}
+55
View File
@@ -0,0 +1,55 @@
package harness_test
import (
"io"
"net/http"
"net/http/httptest"
"testing"
"git.asio.asia/nixevol/NixMsg/test/harness"
)
func TestAdminClientMutatingHeader(t *testing.T) {
t.Parallel()
var sawRequestHdr, sawAuth bool
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("X-Nixmsg-Request") == "1" {
sawRequestHdr = true
}
if r.Header.Get("Authorization") != "" {
sawAuth = true
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{}`))
}))
defer srv.Close()
c, err := harness.NewAdminClient(srv.URL)
if err != nil {
t.Fatal(err)
}
resp, err := c.PostJSON("/api/admin/login", []byte(`{"password":"x"}`))
if err != nil {
t.Fatal(err)
}
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
if !sawRequestHdr {
t.Fatal("expected X-Nixmsg-Request on POST")
}
sawRequestHdr = false
c.APIToken = "nxm_test"
resp, err = c.PostJSON("/api/admin/endpoints", []byte(`{}`))
if err != nil {
t.Fatal(err)
}
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
if sawRequestHdr {
t.Fatal("API token mode should not set X-Nixmsg-Request")
}
if !sawAuth {
t.Fatal("expected Authorization bearer")
}
}
+63
View File
@@ -0,0 +1,63 @@
package harness
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"sync"
)
var (
binOnce sync.Once
binPath string
binErr error
)
// Binary 返回已编译好的 nixmsg 可执行文件路径(整个进程只编译一次)。
func Binary() (string, error) {
binOnce.Do(func() {
root, err := moduleRoot()
if err != nil {
binErr = err
return
}
dir, err := os.MkdirTemp("", "nixmsg-harness-bin-*")
if err != nil {
binErr = err
return
}
name := "nixmsg"
if runtime.GOOS == "windows" {
name += ".exe"
}
out := filepath.Join(dir, name)
cmd := exec.Command("go", "build", "-o", out, "./cmd/nixmsg")
cmd.Dir = root
cmd.Env = append(os.Environ(), "CGO_ENABLED=0")
if outBytes, runErr := cmd.CombinedOutput(); runErr != nil {
binErr = fmt.Errorf("build nixmsg: %w\n%s", runErr, outBytes)
return
}
binPath = out
})
return binPath, binErr
}
func moduleRoot() (string, error) {
dir, err := os.Getwd()
if err != nil {
return "", err
}
for {
if _, statErr := os.Stat(filepath.Join(dir, "go.mod")); statErr == nil {
return dir, nil
}
parent := filepath.Dir(dir)
if parent == dir {
return "", fmt.Errorf("go.mod not found from %s", dir)
}
dir = parent
}
}
+35
View File
@@ -0,0 +1,35 @@
package main
import (
"encoding/json"
"fmt"
"os"
"os/signal"
"syscall"
"git.asio.asia/nixevol/NixMsg/test/harness"
)
func main() {
srv, err := harness.Start(harness.Options{KeepDir: true})
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
info := srv.ConnectionInfo()
enc := json.NewEncoder(os.Stdout)
enc.SetEscapeHTML(false)
if err := enc.Encode(info); err != nil {
_ = srv.Stop()
_ = os.RemoveAll(srv.DataDir)
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
<-sigCh
_ = srv.Stop()
_ = os.RemoveAll(srv.DataDir)
}
+116
View File
@@ -0,0 +1,116 @@
package harness_test
import (
"io"
"net/http"
"os"
"path/filepath"
"sync"
"testing"
"time"
"git.asio.asia/nixevol/NixMsg/test/harness"
)
func TestHealthzAndCleanup(t *testing.T) {
srv, err := harness.Start(harness.Options{})
if err != nil {
t.Fatal(err)
}
dataDir := srv.DataDir
httpBase := srv.HTTPBase
resp, err := http.Get(httpBase + "/healthz")
if err != nil {
_ = srv.Stop()
t.Fatalf("healthz: %v", err)
}
body, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
if resp.StatusCode != http.StatusOK {
_ = srv.Stop()
t.Fatalf("status=%d body=%s", resp.StatusCode, body)
}
if string(body) != "ok" {
_ = srv.Stop()
t.Fatalf("body=%q", body)
}
if err = srv.Stop(); err != nil {
t.Fatalf("stop: %v", err)
}
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if _, statErr := os.Stat(dataDir); os.IsNotExist(statErr) {
break
}
time.Sleep(20 * time.Millisecond)
}
if _, statErr := os.Stat(dataDir); !os.IsNotExist(statErr) {
t.Fatalf("data dir still exists: %s", dataDir)
}
_, err = http.Get(httpBase + "/healthz")
if err == nil {
t.Fatal("healthz still reachable after stop")
}
}
func TestParallelIsolation(t *testing.T) {
var (
wg sync.WaitGroup
mu sync.Mutex
addrs []string
dataDirs []string
)
runOne := func(i int) {
defer wg.Done()
srv, err := harness.Start(harness.Options{})
if err != nil {
t.Errorf("start %d: %v", i, err)
return
}
defer func() { _ = srv.Stop() }()
resp, err := http.Get(srv.HTTPBase + "/healthz")
if err != nil {
t.Errorf("healthz %d: %v", i, err)
return
}
_ = resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("healthz %d status=%d", i, resp.StatusCode)
return
}
mu.Lock()
addrs = append(addrs, srv.Addr)
dataDirs = append(dataDirs, srv.DataDir)
mu.Unlock()
}
const n = 2
wg.Add(n)
for i := 0; i < n; i++ {
go runOne(i)
}
wg.Wait()
if t.Failed() {
return
}
if len(addrs) != n || len(dataDirs) != n {
t.Fatalf("got %d addrs %d dirs", len(addrs), len(dataDirs))
}
if addrs[0] == addrs[1] {
t.Fatalf("same listen addr: %s", addrs[0])
}
if dataDirs[0] == dataDirs[1] {
t.Fatalf("same data dir: %s", dataDirs[0])
}
for _, d := range dataDirs {
if filepath.Clean(d) == "" {
t.Fatal("empty data dir")
}
}
}
+34
View File
@@ -0,0 +1,34 @@
// Package harness 提供集成测试启动器。
package harness
// Info 是命令行启动器打印到 stdout 的连接信息。
type Info struct {
Listen string `json:"listen"`
HTTPBase string `json:"http_base"`
AdminListen string `json:"admin_listen,omitempty"`
AdminHTTPBase string `json:"admin_http_base"`
MQTTWS string `json:"mqtt_ws"`
MQTTTCP string `json:"mqtt_tcp"`
AdminPassword string `json:"admin_password"`
DataDir string `json:"data_dir"`
ConfigPath string `json:"config_path"`
}
// ConnectionInfo 组装给外部语言测试用的 JSON 结构。
func (s *Server) ConnectionInfo() Info {
adminBase := s.AdminHTTPBase
if adminBase == "" {
adminBase = s.HTTPBase
}
return Info{
Listen: s.Addr,
HTTPBase: s.HTTPBase,
AdminListen: s.AdminAddr,
AdminHTTPBase: adminBase,
MQTTWS: "ws://" + s.Addr + "/mqtt",
MQTTTCP: s.Addr,
AdminPassword: s.AdminPassword,
DataDir: s.DataDir,
ConfigPath: s.ConfigPath,
}
}
+321
View File
@@ -0,0 +1,321 @@
package harness
import (
"bufio"
"crypto/rand"
"crypto/sha1"
"encoding/base64"
"encoding/binary"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strings"
"time"
)
// MQTTClient 测试用 MQTT 传输层:能连上 WebSocket /mqtt 或裸 TCP,收发原始 MQTT 控制包字节。
// 不实现业务握手(hello)与主题约定;完整帧协议留给各线集成测试自行组合。
type MQTTClient interface {
Send(packet []byte) error
Recv() ([]byte, error)
Close() error
LocalAddr() net.Addr
RemoteAddr() net.Addr
}
// DialMQTTTCP 连接裸 MQTT TCP(与 listen 同一地址)。
func DialMQTTTCP(addr string, timeout time.Duration) (MQTTClient, error) {
if timeout <= 0 {
timeout = 5 * time.Second
}
conn, err := net.DialTimeout("tcp", addr, timeout)
if err != nil {
return nil, err
}
_ = conn.SetDeadline(time.Now().Add(timeout))
return &tcpMQTT{conn: conn, r: bufio.NewReader(conn)}, nil
}
// DialMQTTWebSocket 连接 ws(s)://host/mqtt,子协议 mqtt。
func DialMQTTWebSocket(httpBase string, timeout time.Duration) (MQTTClient, error) {
if timeout <= 0 {
timeout = 5 * time.Second
}
base := strings.TrimRight(httpBase, "/")
u, err := url.Parse(base)
if err != nil {
return nil, err
}
switch u.Scheme {
case "http":
u.Scheme = "ws"
case "https":
u.Scheme = "wss"
case "ws", "wss":
case "":
u.Scheme = "ws"
default:
return nil, fmt.Errorf("unsupported scheme %q", u.Scheme)
}
u.Path = "/mqtt"
u.RawQuery = ""
u.Fragment = ""
key := make([]byte, 16)
if _, err = rand.Read(key); err != nil {
return nil, err
}
secKey := base64.StdEncoding.EncodeToString(key)
httpURL := *u
if u.Scheme == "ws" {
httpURL.Scheme = "http"
} else {
httpURL.Scheme = "https"
}
req, err := http.NewRequest(http.MethodGet, httpURL.String(), nil)
if err != nil {
return nil, err
}
req.Header.Set("Connection", "Upgrade")
req.Header.Set("Upgrade", "websocket")
req.Header.Set("Sec-WebSocket-Version", "13")
req.Header.Set("Sec-WebSocket-Key", secKey)
req.Header.Set("Sec-WebSocket-Protocol", "mqtt")
host := u.Hostname()
port := u.Port()
if port == "" {
if u.Scheme == "wss" {
port = "443"
} else {
port = "80"
}
}
dialer := net.Dialer{Timeout: timeout}
raw, err := dialer.Dial("tcp", net.JoinHostPort(host, port))
if err != nil {
return nil, err
}
_ = raw.SetDeadline(time.Now().Add(timeout))
if err = req.Write(raw); err != nil {
_ = raw.Close()
return nil, err
}
br := bufio.NewReader(raw)
resp, err := http.ReadResponse(br, req)
if err != nil {
_ = raw.Close()
return nil, err
}
if resp.StatusCode != http.StatusSwitchingProtocols {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
_ = resp.Body.Close()
_ = raw.Close()
return nil, fmt.Errorf("websocket upgrade status %d: %s", resp.StatusCode, body)
}
if resp.Header.Get("Sec-WebSocket-Accept") != wsAcceptKey(secKey) {
_ = raw.Close()
return nil, fmt.Errorf("bad Sec-WebSocket-Accept")
}
if proto := resp.Header.Get("Sec-WebSocket-Protocol"); proto != "" && proto != "mqtt" {
_ = raw.Close()
return nil, fmt.Errorf("unexpected subprotocol %q", proto)
}
return &wsMQTT{conn: raw, r: br}, nil
}
func wsAcceptKey(secKey string) string {
const guid = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
sum := sha1.Sum([]byte(secKey + guid))
return base64.StdEncoding.EncodeToString(sum[:])
}
type tcpMQTT struct {
conn net.Conn
r *bufio.Reader
}
func (c *tcpMQTT) Send(packet []byte) error {
_, err := c.conn.Write(packet)
return err
}
func (c *tcpMQTT) Recv() ([]byte, error) {
return readMQTTPacket(c.r)
}
func (c *tcpMQTT) Close() error {
return c.conn.Close()
}
func (c *tcpMQTT) LocalAddr() net.Addr {
return c.conn.LocalAddr()
}
func (c *tcpMQTT) RemoteAddr() net.Addr {
return c.conn.RemoteAddr()
}
type wsMQTT struct {
conn net.Conn
r *bufio.Reader
}
func (c *wsMQTT) Send(packet []byte) error {
return writeWSClientBinary(c.conn, packet)
}
func (c *wsMQTT) Recv() ([]byte, error) {
for {
payload, opcode, err := readWSFrame(c.r)
if err != nil {
return nil, err
}
switch opcode {
case 0x2:
return payload, nil
case 0x8:
return nil, io.EOF
case 0x9:
_ = writeWSClientControl(c.conn, 0xA, payload)
case 0xA:
continue
default:
continue
}
}
}
func (c *wsMQTT) Close() error {
return c.conn.Close()
}
func (c *wsMQTT) LocalAddr() net.Addr {
return c.conn.LocalAddr()
}
func (c *wsMQTT) RemoteAddr() net.Addr {
return c.conn.RemoteAddr()
}
func readMQTTPacket(r *bufio.Reader) ([]byte, error) {
first, err := r.ReadByte()
if err != nil {
return nil, err
}
remaining, remBytes, err := readMQTTRemaining(r)
if err != nil {
return nil, err
}
buf := make([]byte, 1+len(remBytes)+remaining)
buf[0] = first
copy(buf[1:], remBytes)
if remaining > 0 {
if _, err := io.ReadFull(r, buf[1+len(remBytes):]); err != nil {
return nil, err
}
}
return buf, nil
}
func readMQTTRemaining(r *bufio.Reader) (value int, raw []byte, err error) {
multiplier := 1
for i := 0; i < 4; i++ {
encoded, readErr := r.ReadByte()
if readErr != nil {
return 0, nil, readErr
}
raw = append(raw, encoded)
value += int(encoded&127) * multiplier
if encoded&128 == 0 {
return value, raw, nil
}
multiplier *= 128
}
return 0, nil, fmt.Errorf("mqtt remaining length overflow")
}
func writeWSClientBinary(w io.Writer, payload []byte) error {
return writeWSClientFrame(w, 0x2, payload)
}
func writeWSClientControl(w io.Writer, opcode byte, payload []byte) error {
return writeWSClientFrame(w, opcode, payload)
}
func writeWSClientFrame(w io.Writer, opcode byte, payload []byte) error {
mask := make([]byte, 4)
if _, err := rand.Read(mask); err != nil {
return err
}
header := []byte{0x80 | (opcode & 0x0f)}
n := len(payload)
switch {
case n < 126:
header = append(header, 0x80|byte(n))
case n <= 65535:
header = append(header, 0x80|126, byte(n>>8), byte(n))
default:
var ext [8]byte
binary.BigEndian.PutUint64(ext[:], uint64(n))
header = append(header, 0x80|127)
header = append(header, ext[:]...)
}
header = append(header, mask...)
masked := make([]byte, n)
for i := 0; i < n; i++ {
masked[i] = payload[i] ^ mask[i%4]
}
if _, err := w.Write(header); err != nil {
return err
}
_, err := w.Write(masked)
return err
}
func readWSFrame(r *bufio.Reader) (payload []byte, opcode byte, err error) {
b0, err := r.ReadByte()
if err != nil {
return nil, 0, err
}
opcode = b0 & 0x0f
b1, err := r.ReadByte()
if err != nil {
return nil, 0, err
}
masked := b1&0x80 != 0
n := int(b1 & 0x7f)
switch n {
case 126:
var ext [2]byte
if _, err := io.ReadFull(r, ext[:]); err != nil {
return nil, 0, err
}
n = int(binary.BigEndian.Uint16(ext[:]))
case 127:
var ext [8]byte
if _, err := io.ReadFull(r, ext[:]); err != nil {
return nil, 0, err
}
n = int(binary.BigEndian.Uint64(ext[:]))
}
var maskKey [4]byte
if masked {
if _, err := io.ReadFull(r, maskKey[:]); err != nil {
return nil, 0, err
}
}
payload = make([]byte, n)
if n > 0 {
if _, err := io.ReadFull(r, payload); err != nil {
return nil, 0, err
}
if masked {
for i := 0; i < n; i++ {
payload[i] ^= maskKey[i%4]
}
}
}
return payload, opcode, nil
}
+162
View File
@@ -0,0 +1,162 @@
package harness
import (
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
// Options 控制测试进程启动。
type Options struct {
// Listen 写入配置的 listen,默认 127.0.0.1:0。
Listen string
// AdminListen 可选;非空时写入 admin_listen,并读取 admin.addr。
AdminListen string
// AdminInit 可选;nil 时用 CLIAdminInit。不支持时忽略并继续启动。
AdminInit AdminInitializer
// KeepDir 为 true 时 Stop 不删除数据目录(命令行启动器在清理前可读)。
KeepDir bool
}
// Server 是一次集成测试用的真实 nixmsg 进程。
type Server struct {
BinPath string
ConfigPath string
DataDir string
Addr string // 来自 listen.addr,形如 127.0.0.1:12345
AdminAddr string // 来自 admin.addr(若有)
AdminPassword string
HTTPBase string
AdminHTTPBase string
cmd *exec.Cmd
keepDir bool
stopped bool
}
// Start 编译(如需)并启动服务:临时目录、listen 端口 0、读 listen.addr。
func Start(opts Options) (*Server, error) {
bin, err := Binary()
if err != nil {
return nil, err
}
dataDir, err := os.MkdirTemp("", "nixmsg-harness-*")
if err != nil {
return nil, err
}
listen := opts.Listen
if listen == "" {
listen = "127.0.0.1:0"
}
cfgPath := filepath.Join(dataDir, "config.yaml")
cfg := fmt.Sprintf("listen: %q\ndata_dir: %q\n", listen, filepath.ToSlash(dataDir))
if opts.AdminListen != "" {
cfg += fmt.Sprintf("admin_listen: %q\n", opts.AdminListen)
}
if err = os.WriteFile(cfgPath, []byte(cfg), 0o644); err != nil {
_ = os.RemoveAll(dataDir)
return nil, err
}
adminInit := opts.AdminInit
if adminInit == nil {
adminInit = CLIAdminInit{}
}
password, initErr := adminInit.Init(bin, cfgPath)
if initErr != nil && !errors.Is(initErr, ErrAdminInitUnsupported) {
_ = os.RemoveAll(dataDir)
return nil, initErr
}
if errors.Is(initErr, ErrAdminInitUnsupported) {
password = ""
}
cmd := exec.Command(bin, "serve")
cmd.Env = append(os.Environ(), "NIXMSG_CONFIG="+cfgPath)
cmd.Stdout = os.Stderr
cmd.Stderr = os.Stderr
if err = cmd.Start(); err != nil {
_ = os.RemoveAll(dataDir)
return nil, fmt.Errorf("start serve: %w", err)
}
s := &Server{
BinPath: bin,
ConfigPath: cfgPath,
DataDir: dataDir,
AdminPassword: password,
cmd: cmd,
keepDir: opts.KeepDir,
}
addr, err := waitAddrFile(filepath.Join(dataDir, "listen.addr"), 10*time.Second)
if err != nil {
_ = s.Stop()
return nil, fmt.Errorf("wait listen.addr: %w", err)
}
s.Addr = addr
s.HTTPBase = "http://" + addr
if opts.AdminListen != "" {
adminAddr, adminErr := waitAddrFile(filepath.Join(dataDir, "admin.addr"), 10*time.Second)
if adminErr == nil {
s.AdminAddr = adminAddr
s.AdminHTTPBase = "http://" + adminAddr
}
}
if s.AdminHTTPBase == "" {
s.AdminHTTPBase = s.HTTPBase
}
return s, nil
}
// AdminClient 返回指向管理接口的 HTTP 客户端。
func (s *Server) AdminClient() (*AdminClient, error) {
base := s.AdminHTTPBase
if base == "" {
base = s.HTTPBase
}
return NewAdminClient(base)
}
// Stop 结束进程并删除临时目录(除非 KeepDir)。
func (s *Server) Stop() error {
if s == nil || s.stopped {
return nil
}
s.stopped = true
var stopErr error
if s.cmd != nil && s.cmd.Process != nil {
_ = s.cmd.Process.Kill()
_, _ = s.cmd.Process.Wait()
}
if !s.keepDir && s.DataDir != "" {
if err := os.RemoveAll(s.DataDir); err != nil {
stopErr = err
}
}
return stopErr
}
func waitAddrFile(path string, timeout time.Duration) (string, error) {
deadline := time.Now().Add(timeout)
var lastErr error
for time.Now().Before(deadline) {
b, err := os.ReadFile(path)
if err == nil {
addr := strings.TrimSpace(string(b))
if addr != "" {
return addr, nil
}
lastErr = fmt.Errorf("empty addr file")
} else {
lastErr = err
}
time.Sleep(20 * time.Millisecond)
}
return "", lastErr
}