feat: 测试交付 Q1 混沌压测骨架与 Q4 镜像骨架
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
# 混沌 / 弱网测试辅助(Q 线)
|
||||
|
||||
用 toxiproxy 官方镜像做延迟和断开;Linux 丢包用容器内 netem。所有 Docker 资源名带 `q` 前缀,用完删除。
|
||||
|
||||
## 启动 toxiproxy
|
||||
|
||||
在仓库根目录(或本目录)执行:
|
||||
|
||||
```powershell
|
||||
docker compose -p q-chaos -f test/chaos/compose.yml up -d
|
||||
```
|
||||
|
||||
API 默认映射到本机随机/固定端口见 compose 注释。查看实际端口:
|
||||
|
||||
```powershell
|
||||
docker compose -p q-chaos -f test/chaos/compose.yml port toxiproxy 8474
|
||||
```
|
||||
|
||||
清理:
|
||||
|
||||
```powershell
|
||||
docker compose -p q-chaos -f test/chaos/compose.yml down -v
|
||||
```
|
||||
|
||||
## 对运行中的服务注入故障
|
||||
|
||||
不要写死 `7443`。上游地址取自当前实例的实际监听端口(例如测试 harness 写入的 `listen.addr`,或你启动时打印的地址)。
|
||||
|
||||
1. 本机起好 NixMsg(或任意 TCP 服务),记下 `HOST:PORT`。
|
||||
2. 在 Windows 上,容器访问本机服务用 `host.docker.internal:PORT`。
|
||||
3. 用本包客户端或 curl 建代理(listen 用 `0.0.0.0:0` 让 toxiproxy 分配端口):
|
||||
|
||||
```go
|
||||
c := chaos.NewClient("http://127.0.0.1:<api端口>")
|
||||
p, err := c.CreateProxy("q-nixmsg", "0.0.0.0:0", "host.docker.internal:"+strconv.Itoa(port))
|
||||
// 客户端改连 p.Listen(把 0.0.0.0 换成 127.0.0.1)
|
||||
_, _ = c.AddLatency("q-nixmsg", "lag", 200, 50, "")
|
||||
_, _ = c.AddResetPeer("q-nixmsg", "rst", 0, "")
|
||||
```
|
||||
|
||||
等价 curl:
|
||||
|
||||
```bash
|
||||
curl -s -X POST http://127.0.0.1:<api>/proxies \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"name":"q-nixmsg","listen":"0.0.0.0:0","upstream":"host.docker.internal:<PORT>","enabled":true}'
|
||||
```
|
||||
|
||||
延迟:`POST .../proxies/q-nixmsg/toxics`,`type=latency`,`attributes.latency` / `jitter`(毫秒)。
|
||||
|
||||
断开:`type=reset_peer`,`attributes.timeout`(毫秒,0 表示尽快 RST)。
|
||||
|
||||
## 单元测试
|
||||
|
||||
```powershell
|
||||
go test ./test/chaos/ -count=1
|
||||
```
|
||||
|
||||
## netem 丢包
|
||||
|
||||
见 [netem/README.md](./netem/README.md)。脚本必须在 Linux 容器内执行(本机是 Windows)。
|
||||
@@ -0,0 +1,15 @@
|
||||
# toxiproxy:延迟与断开。项目名用 -p q-chaos,容器名带 q 前缀。
|
||||
# API 与代理端口映射到本机,具体宿主机端口用 `docker compose port` 查询,不要写死业务端口。
|
||||
|
||||
services:
|
||||
toxiproxy:
|
||||
image: ghcr.io/shopify/toxiproxy:2.12.0
|
||||
container_name: q-toxiproxy
|
||||
# 8474=API;8475 起可手工映射代理端口,或在 CreateProxy 时用 0.0.0.0:0 再 docker port 查询
|
||||
ports:
|
||||
- "8474"
|
||||
- "8475"
|
||||
- "8476"
|
||||
# 允许代理到本机服务(Docker Desktop / Windows)
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
@@ -0,0 +1,192 @@
|
||||
// Package chaos 提供基于 toxiproxy 的弱网故障注入辅助。
|
||||
// 上游地址由调用方传入,不写死端口。
|
||||
package chaos
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Client 调用 toxiproxy HTTP API。
|
||||
type Client struct {
|
||||
BaseURL string
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
// NewClient 创建客户端。baseURL 形如 http://127.0.0.1:8474。
|
||||
func NewClient(baseURL string) *Client {
|
||||
return &Client{
|
||||
BaseURL: strings.TrimRight(baseURL, "/"),
|
||||
HTTPClient: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Proxy 描述一个 toxiproxy 代理。
|
||||
type Proxy struct {
|
||||
Name string `json:"name"`
|
||||
Listen string `json:"listen"`
|
||||
Upstream string `json:"upstream"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// Toxic 描述一条故障规则。
|
||||
type Toxic struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Stream string `json:"stream,omitempty"`
|
||||
Toxicity float32 `json:"toxicity,omitempty"`
|
||||
Attributes map[string]any `json:"attributes,omitempty"`
|
||||
}
|
||||
|
||||
// CreateProxy 创建或覆盖同名代理。listen 可用 host:0 让 toxiproxy 分配端口。
|
||||
func (c *Client) CreateProxy(name, listen, upstream string) (*Proxy, error) {
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("proxy name required")
|
||||
}
|
||||
if listen == "" {
|
||||
return nil, fmt.Errorf("listen required")
|
||||
}
|
||||
if upstream == "" {
|
||||
return nil, fmt.Errorf("upstream required")
|
||||
}
|
||||
body := Proxy{
|
||||
Name: name,
|
||||
Listen: listen,
|
||||
Upstream: upstream,
|
||||
Enabled: true,
|
||||
}
|
||||
var out Proxy
|
||||
if err := c.doJSON(http.MethodPost, "/proxies", body, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// DeleteProxy 删除代理;不存在时忽略。
|
||||
func (c *Client) DeleteProxy(name string) error {
|
||||
req, err := http.NewRequest(http.MethodDelete, c.BaseURL+"/proxies/"+name, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusOK {
|
||||
return nil
|
||||
}
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("delete proxy: %s: %s", resp.Status, strings.TrimSpace(string(b)))
|
||||
}
|
||||
|
||||
// GetProxy 读取代理(含实际 listen 地址)。
|
||||
func (c *Client) GetProxy(name string) (*Proxy, error) {
|
||||
var out Proxy
|
||||
if err := c.doJSON(http.MethodGet, "/proxies/"+name, nil, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// AddLatency 注入下行/上行延迟(毫秒)。stream 为空时默认 downstream。
|
||||
func (c *Client) AddLatency(proxyName, toxicName string, latencyMs, jitterMs int, stream string) (*Toxic, error) {
|
||||
if stream == "" {
|
||||
stream = "downstream"
|
||||
}
|
||||
t := Toxic{
|
||||
Name: toxicName,
|
||||
Type: "latency",
|
||||
Stream: stream,
|
||||
Toxicity: 1,
|
||||
Attributes: map[string]any{
|
||||
"latency": latencyMs,
|
||||
"jitter": jitterMs,
|
||||
},
|
||||
}
|
||||
return c.addToxic(proxyName, t)
|
||||
}
|
||||
|
||||
// AddResetPeer 在连接上注入 TCP RST(断开)。timeoutMs 为触发前等待。
|
||||
func (c *Client) AddResetPeer(proxyName, toxicName string, timeoutMs int, stream string) (*Toxic, error) {
|
||||
if stream == "" {
|
||||
stream = "downstream"
|
||||
}
|
||||
t := Toxic{
|
||||
Name: toxicName,
|
||||
Type: "reset_peer",
|
||||
Stream: stream,
|
||||
Toxicity: 1,
|
||||
Attributes: map[string]any{
|
||||
"timeout": timeoutMs,
|
||||
},
|
||||
}
|
||||
return c.addToxic(proxyName, t)
|
||||
}
|
||||
|
||||
// RemoveToxic 删除一条 toxic。
|
||||
func (c *Client) RemoveToxic(proxyName, toxicName string) error {
|
||||
req, err := http.NewRequest(http.MethodDelete, c.BaseURL+"/proxies/"+proxyName+"/toxics/"+toxicName, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusOK {
|
||||
return nil
|
||||
}
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("remove toxic: %s: %s", resp.Status, strings.TrimSpace(string(b)))
|
||||
}
|
||||
|
||||
func (c *Client) addToxic(proxyName string, t Toxic) (*Toxic, error) {
|
||||
var out Toxic
|
||||
if err := c.doJSON(http.MethodPost, "/proxies/"+proxyName+"/toxics", t, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (c *Client) doJSON(method, path string, in any, out any) error {
|
||||
var body io.Reader
|
||||
if in != nil {
|
||||
b, err := json.Marshal(in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body = bytes.NewReader(b)
|
||||
}
|
||||
req, err := http.NewRequest(method, c.BaseURL+path, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if in != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("%s %s: %s: %s", method, path, resp.Status, strings.TrimSpace(string(respBody)))
|
||||
}
|
||||
if out == nil || len(respBody) == 0 {
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal(respBody, out)
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package chaos
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestClientCreateLatencyAndReset(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
toxics := map[string][]Toxic{}
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/proxies":
|
||||
var p Proxy
|
||||
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
|
||||
t.Errorf("decode proxy: %v", err)
|
||||
http.Error(w, err.Error(), 400)
|
||||
return
|
||||
}
|
||||
if p.Listen == "0.0.0.0:0" {
|
||||
p.Listen = "0.0.0.0:18080"
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(p)
|
||||
|
||||
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/toxics"):
|
||||
var toxic Toxic
|
||||
if err := json.NewDecoder(r.Body).Decode(&toxic); err != nil {
|
||||
http.Error(w, err.Error(), 400)
|
||||
return
|
||||
}
|
||||
name := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/proxies/"), "/toxics")
|
||||
toxics[name] = append(toxics[name], toxic)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(toxic)
|
||||
|
||||
case r.Method == http.MethodDelete:
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
c := NewClient(srv.URL)
|
||||
p, err := c.CreateProxy("q-demo", "0.0.0.0:0", "127.0.0.1:19000")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateProxy: %v", err)
|
||||
}
|
||||
if p.Upstream != "127.0.0.1:19000" {
|
||||
t.Fatalf("upstream = %q", p.Upstream)
|
||||
}
|
||||
if p.Listen != "0.0.0.0:18080" {
|
||||
t.Fatalf("listen = %q", p.Listen)
|
||||
}
|
||||
|
||||
lat, err := c.AddLatency("q-demo", "lag", 200, 50, "")
|
||||
if err != nil {
|
||||
t.Fatalf("AddLatency: %v", err)
|
||||
}
|
||||
if lat.Type != "latency" {
|
||||
t.Fatalf("type = %q", lat.Type)
|
||||
}
|
||||
if _, err := c.AddResetPeer("q-demo", "drop", 0, ""); err != nil {
|
||||
t.Fatalf("AddResetPeer: %v", err)
|
||||
}
|
||||
if got := toxics["q-demo"]; len(got) != 2 {
|
||||
t.Fatalf("toxics len = %d", len(got))
|
||||
}
|
||||
if got := toxics["q-demo"][0]; got.Type != "latency" {
|
||||
t.Fatalf("first toxic type = %q", got.Type)
|
||||
}
|
||||
if err := c.RemoveToxic("q-demo", "lag"); err != nil {
|
||||
t.Fatalf("RemoveToxic: %v", err)
|
||||
}
|
||||
if err := c.DeleteProxy("q-demo"); err != nil {
|
||||
t.Fatalf("DeleteProxy: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientRejectsEmptyFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := NewClient("http://127.0.0.1:1")
|
||||
if _, err := c.CreateProxy("", "0.0.0.0:0", "127.0.0.1:1"); err == nil {
|
||||
t.Fatal("expected error for empty name")
|
||||
}
|
||||
if _, err := c.CreateProxy("x", "", "127.0.0.1:1"); err == nil {
|
||||
t.Fatal("expected error for empty listen")
|
||||
}
|
||||
if _, err := c.CreateProxy("x", "0.0.0.0:0", ""); err == nil {
|
||||
t.Fatal("expected error for empty upstream")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateProxyErrorStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
_, _ = io.WriteString(w, "already exists")
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
c := NewClient(srv.URL)
|
||||
if _, err := c.CreateProxy("q", "0.0.0.0:0", "127.0.0.1:9"); err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
# Linux 容器内用 netem 做丢包(本机 Windows 不能直接跑 tc)
|
||||
|
||||
## 用法
|
||||
|
||||
对已经在同一网络里的目标容器网卡注入 20% 丢包(需要 `NET_ADMIN`):
|
||||
|
||||
```powershell
|
||||
# 示例:起一个带 net-tools/iproute2 的旁路容器,对网卡 eth0 丢包
|
||||
docker run --rm --name q-netem --cap-add=NET_ADMIN --network container:q-toxiproxy `
|
||||
nicolaka/netshoot `
|
||||
bash -lc "tc qdisc replace dev eth0 root netem loss 20%"
|
||||
```
|
||||
|
||||
或把本目录脚本挂进去:
|
||||
|
||||
```powershell
|
||||
docker run --rm --name q-netem --cap-add=NET_ADMIN --network container:<目标容器名> `
|
||||
-v ${PWD}/test/chaos/netem:/scripts:ro `
|
||||
nicolaka/netshoot `
|
||||
bash /scripts/apply-loss.sh eth0 20
|
||||
```
|
||||
|
||||
清除:
|
||||
|
||||
```powershell
|
||||
docker run --rm --name q-netem-clear --cap-add=NET_ADMIN --network container:<目标容器名> `
|
||||
nicolaka/netshoot `
|
||||
bash -lc "tc qdisc del dev eth0 root 2>/dev/null || true"
|
||||
```
|
||||
|
||||
用完删除容器(上面 `--rm` 已自动删)。不要把 NixMsg 业务端口写死进脚本。
|
||||
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
# 在 Linux 容器内执行:对指定网卡注入丢包。用法:apply-loss.sh <iface> <loss_percent>
|
||||
set -euo pipefail
|
||||
|
||||
IFACE="${1:-eth0}"
|
||||
LOSS="${2:-20}"
|
||||
|
||||
if ! command -v tc >/dev/null 2>&1; then
|
||||
echo "tc not found; use an image with iproute2 (e.g. nicolaka/netshoot)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
tc qdisc replace dev "$IFACE" root netem loss "${LOSS}%"
|
||||
echo "netem: iface=${IFACE} loss=${LOSS}%"
|
||||
tc qdisc show dev "$IFACE"
|
||||
@@ -0,0 +1,40 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"git.asio.asia/nixevol/NixMsg/test/load"
|
||||
)
|
||||
|
||||
// 压测客户端骨架:连上 N 个 MQTT 连接并打印连接数。完整 1000 连接 / 10 分钟压测留给 Q3。
|
||||
func main() {
|
||||
addr := flag.String("addr", "", "MQTT broker host:port(必填,不要写死业务端口)")
|
||||
n := flag.Int("n", 10, "连接数")
|
||||
prefix := flag.String("prefix", "q-bench-", "client id 前缀")
|
||||
hold := flag.Duration("hold", 3*time.Second, "保持连接时长")
|
||||
flag.Parse()
|
||||
if *addr == "" {
|
||||
fmt.Fprintln(os.Stderr, "usage: mqttbench -addr host:port [-n 10]")
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
b := &load.MQTTBench{Addr: *addr, ClientIDPrefix: *prefix, Timeout: 5 * time.Second}
|
||||
defer b.Close()
|
||||
if err := b.ConnectN(*n, os.Stdout); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("held %d connections for %s\n", b.Alive(), hold.String())
|
||||
|
||||
ch := make(chan os.Signal, 1)
|
||||
signal.Notify(ch, os.Interrupt, syscall.SIGTERM)
|
||||
select {
|
||||
case <-time.After(*hold):
|
||||
case <-ch:
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
// Package load 提供 MQTT 压测客户端骨架:先连上并统计连接数。
|
||||
package load
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// MQTTBench 保持多个 MQTT 3.1.1 TCP 连接(仅 CONNECT/CONNACK)。
|
||||
type MQTTBench struct {
|
||||
Addr string
|
||||
ClientIDPrefix string
|
||||
Timeout time.Duration
|
||||
|
||||
mu sync.Mutex
|
||||
conns []net.Conn
|
||||
alive atomic.Int64
|
||||
}
|
||||
|
||||
// ConnectN 建立 n 条连接;成功一条 alive+1,并打印当前连接数到 w(可为 nil)。
|
||||
func (b *MQTTBench) ConnectN(n int, w io.Writer) error {
|
||||
if b.Addr == "" {
|
||||
return fmt.Errorf("addr required")
|
||||
}
|
||||
if n <= 0 {
|
||||
return fmt.Errorf("n must be > 0")
|
||||
}
|
||||
timeout := b.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = 5 * time.Second
|
||||
}
|
||||
prefix := b.ClientIDPrefix
|
||||
if prefix == "" {
|
||||
prefix = "q-bench-"
|
||||
}
|
||||
|
||||
for i := 0; i < n; i++ {
|
||||
conn, err := net.DialTimeout("tcp", b.Addr, timeout)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dial %d: %w", i, err)
|
||||
}
|
||||
_ = conn.SetDeadline(time.Now().Add(timeout))
|
||||
cid := fmt.Sprintf("%s%d", prefix, i)
|
||||
if err := mqttConnect(conn, cid); err != nil {
|
||||
_ = conn.Close()
|
||||
return fmt.Errorf("connect %d: %w", i, err)
|
||||
}
|
||||
_ = conn.SetDeadline(time.Time{})
|
||||
b.mu.Lock()
|
||||
b.conns = append(b.conns, conn)
|
||||
b.mu.Unlock()
|
||||
cur := b.alive.Add(1)
|
||||
if w != nil {
|
||||
_, _ = fmt.Fprintf(w, "mqtt connections: %d\n", cur)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Alive 当前仍打开的连接数。
|
||||
func (b *MQTTBench) Alive() int64 {
|
||||
return b.alive.Load()
|
||||
}
|
||||
|
||||
// Close 关闭全部连接。
|
||||
func (b *MQTTBench) Close() {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
for _, c := range b.conns {
|
||||
_ = c.Close()
|
||||
}
|
||||
b.conns = nil
|
||||
b.alive.Store(0)
|
||||
}
|
||||
|
||||
func mqttConnect(conn net.Conn, clientID string) error {
|
||||
pkt := buildConnect(clientID)
|
||||
if _, err := conn.Write(pkt); err != nil {
|
||||
return err
|
||||
}
|
||||
header := make([]byte, 4)
|
||||
if _, err := io.ReadFull(conn, header[:2]); err != nil {
|
||||
return err
|
||||
}
|
||||
if header[0] != 0x20 {
|
||||
return fmt.Errorf("unexpected packet type 0x%02x", header[0])
|
||||
}
|
||||
// remaining length 对 CONNACK 固定为 2
|
||||
if header[1] != 2 {
|
||||
return fmt.Errorf("unexpected remaining length %d", header[1])
|
||||
}
|
||||
if _, err := io.ReadFull(conn, header[2:4]); err != nil {
|
||||
return err
|
||||
}
|
||||
if header[3] != 0 {
|
||||
return fmt.Errorf("CONNACK rc=%d", header[3])
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildConnect(clientID string) []byte {
|
||||
// Variable header: protocol name MQTT, level 4, flags 0, keepalive 60
|
||||
vh := []byte{
|
||||
0x00, 0x04, 'M', 'Q', 'T', 'T',
|
||||
0x04,
|
||||
0x00, // clean session=0 flags for skeleton; brokers may still accept
|
||||
0x00, 0x3c,
|
||||
}
|
||||
// Actually clean session bit should be set for simple benches
|
||||
vh[7] = 0x02 // Clean Session
|
||||
|
||||
id := []byte(clientID)
|
||||
payload := make([]byte, 2+len(id))
|
||||
binary.BigEndian.PutUint16(payload[0:2], uint16(len(id)))
|
||||
copy(payload[2:], id)
|
||||
|
||||
remaining := len(vh) + len(payload)
|
||||
pkt := make([]byte, 0, 2+remaining)
|
||||
pkt = append(pkt, 0x10)
|
||||
pkt = append(pkt, encodeRemainingLength(remaining)...)
|
||||
pkt = append(pkt, vh...)
|
||||
pkt = append(pkt, payload...)
|
||||
return pkt
|
||||
}
|
||||
|
||||
func encodeRemainingLength(n int) []byte {
|
||||
var out []byte
|
||||
for {
|
||||
encoded := byte(n % 128)
|
||||
n /= 128
|
||||
if n > 0 {
|
||||
encoded |= 0x80
|
||||
}
|
||||
out = append(out, encoded)
|
||||
if n == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package load
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 极简 MQTT broker:读 CONNECT,回 CONNACK accepted,保持连接。
|
||||
func startFakeMQTTBroker(t *testing.T) (addr string, closeFn func()) {
|
||||
t.Helper()
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
go func(c net.Conn) {
|
||||
defer func() { _ = c.Close() }()
|
||||
_ = c.SetDeadline(time.Now().Add(5 * time.Second))
|
||||
buf := make([]byte, 256)
|
||||
n, err := c.Read(buf)
|
||||
if err != nil || n < 2 || buf[0] != 0x10 {
|
||||
return
|
||||
}
|
||||
// CONNACK: type 0x20, remaining 2, flags 0, rc 0
|
||||
_, _ = c.Write([]byte{0x20, 0x02, 0x00, 0x00})
|
||||
_ = c.SetDeadline(time.Time{})
|
||||
_, _ = io.Copy(io.Discard, c)
|
||||
}(conn)
|
||||
}
|
||||
}()
|
||||
return ln.Addr().String(), func() {
|
||||
close(done)
|
||||
_ = ln.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func TestMQTTBenchConnectN(t *testing.T) {
|
||||
addr, stop := startFakeMQTTBroker(t)
|
||||
defer stop()
|
||||
|
||||
var out bytes.Buffer
|
||||
b := &MQTTBench{Addr: addr, ClientIDPrefix: "q-t-", Timeout: 2 * time.Second}
|
||||
defer b.Close()
|
||||
if err := b.ConnectN(3, &out); err != nil {
|
||||
t.Fatalf("ConnectN: %v", err)
|
||||
}
|
||||
if b.Alive() != 3 {
|
||||
t.Fatalf("alive=%d", b.Alive())
|
||||
}
|
||||
got := out.String()
|
||||
if !bytes.Contains(out.Bytes(), []byte("mqtt connections: 3")) {
|
||||
t.Fatalf("output missing count: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMQTTBenchRequiresAddr(t *testing.T) {
|
||||
b := &MQTTBench{}
|
||||
if err := b.ConnectN(1, nil); err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"git.asio.asia/nixevol/NixMsg/test/report"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
fmt.Fprintln(os.Stderr, "usage: genreport <results.json> [out.md]")
|
||||
os.Exit(2)
|
||||
}
|
||||
r, err := report.LoadResults(os.Args[1])
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
items, err := report.Normalize(r)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
out := os.Stdout
|
||||
if len(os.Args) >= 3 {
|
||||
f, err := os.Create(os.Args[2])
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
out = f
|
||||
}
|
||||
if err := report.WriteMarkdown(out, items); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
// Package report 按 PRD 第 10 节 F01–F23 生成验收对照表。
|
||||
package report
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Status 验收结果。
|
||||
type Status string
|
||||
|
||||
const (
|
||||
StatusPass Status = "pass"
|
||||
StatusFail Status = "fail"
|
||||
StatusUntested Status = "untested"
|
||||
)
|
||||
|
||||
// Item 单条验收结果。
|
||||
type Item struct {
|
||||
ID string `json:"id"`
|
||||
Status Status `json:"status"`
|
||||
Note string `json:"note,omitempty"`
|
||||
}
|
||||
|
||||
// Results 结果文件格式。
|
||||
type Results struct {
|
||||
GeneratedAt string `json:"generated_at,omitempty"`
|
||||
Items []Item `json:"items"`
|
||||
}
|
||||
|
||||
// Feature 验收总表一行。
|
||||
type Feature struct {
|
||||
ID string
|
||||
Summary string
|
||||
}
|
||||
|
||||
// Features 是 PRD 第 10 节 F01–F23。
|
||||
var Features = []Feature{
|
||||
{ID: "F01", Summary: "批量开通整批校验、停用、删除群主转让、删除后同编号重开不串数据"},
|
||||
{ID: "F02", Summary: "新设备登录后旧设备自动退出、换 IP 用令牌重连、两种密码锁定、重置密码后被踢、服务器故障不误报密码错误"},
|
||||
{ID: "F03", Summary: "断开后状态及时变离线,全表可列出"},
|
||||
{ID: "F04", Summary: "只通知订阅了的端"},
|
||||
{ID: "F05", Summary: "崩溃不丢已提交消息,消息号去重和冲突,密码门生效,配额生效"},
|
||||
{ID: "F06", Summary: "群成员收到同一份,入群前不补,发送者不收到自己的"},
|
||||
{ID: "F07", Summary: "256 KiB 通过,超出拒绝,接收上限生效"},
|
||||
{ID: "F08", Summary: "弱网最终送达且应用层不重复,重启后续传"},
|
||||
{ID: "F09", Summary: "保留时间从发送时刻起算,超时过期"},
|
||||
{ID: "F10", Summary: "短断线送到,长断线丢弃,服务器重启后宽限内重连送到"},
|
||||
{ID: "F11", Summary: "发送方离线后到点仍发送"},
|
||||
{ID: "F12", Summary: "延迟窗口内撤回对方收不到"},
|
||||
{ID: "F13", Summary: "未推送必撤成功;群部分确认得到部分撤回"},
|
||||
{ID: "F14", Summary: "回执能补送给当时离线的发送方"},
|
||||
{ID: "F15", Summary: "输一次记住、改密失效、回复免密、进群仍要密码、防多账号轮流猜"},
|
||||
{ID: "F16", Summary: "群主权限、退出后不再收到、解散后同编号新群不收旧消息"},
|
||||
{ID: "F17", Summary: "后台管端、管注册、管群、查记录,响应里没有正文;API 令牌可用且不能越权"},
|
||||
{ID: "F18", Summary: "送达后正文消失;记录天数 0 时连记录消失;防重仍在"},
|
||||
{ID: "F19", Summary: "四种 SDK 通过同一清单"},
|
||||
{ID: "F20", Summary: "裸 MQTT 能登录、收、确认、发"},
|
||||
{ID: "F21", Summary: "默认一个端口提供后台、WebSocket、TCP、注册;后台可分到单独端口"},
|
||||
{ID: "F22", Summary: "初始化后单文件或 Docker 启动、备份恢复、升级迁移、证书自动重载、指标可抓取"},
|
||||
{ID: "F23", Summary: "注册开关、安全码校验、换码不影响已注册、输错锁定"},
|
||||
}
|
||||
|
||||
// LoadResults 读取结果 JSON;缺项视为未测。
|
||||
func LoadResults(path string) (Results, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return Results{}, err
|
||||
}
|
||||
var r Results
|
||||
if err := json.Unmarshal(data, &r); err != nil {
|
||||
return Results{}, fmt.Errorf("parse results: %w", err)
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// Normalize 把结果补全为 F01–F23;非法 status 报错。
|
||||
func Normalize(r Results) ([]Item, error) {
|
||||
byID := make(map[string]Item, len(r.Items))
|
||||
for _, it := range r.Items {
|
||||
id := strings.ToUpper(strings.TrimSpace(it.ID))
|
||||
st, err := parseStatus(it.Status)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", id, err)
|
||||
}
|
||||
it.ID = id
|
||||
it.Status = st
|
||||
byID[id] = it
|
||||
}
|
||||
out := make([]Item, 0, len(Features))
|
||||
for _, f := range Features {
|
||||
if it, ok := byID[f.ID]; ok {
|
||||
out = append(out, it)
|
||||
continue
|
||||
}
|
||||
out = append(out, Item{ID: f.ID, Status: StatusUntested, Note: "结果文件未提供"})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func parseStatus(s Status) (Status, error) {
|
||||
switch Status(strings.ToLower(string(s))) {
|
||||
case StatusPass, "通过", "ok", "passed":
|
||||
return StatusPass, nil
|
||||
case StatusFail, "失败", "failed":
|
||||
return StatusFail, nil
|
||||
case StatusUntested, "未测", "skip", "skipped", "":
|
||||
return StatusUntested, nil
|
||||
default:
|
||||
return "", fmt.Errorf("unknown status %q", s)
|
||||
}
|
||||
}
|
||||
|
||||
func statusCN(s Status) string {
|
||||
switch s {
|
||||
case StatusPass:
|
||||
return "通过"
|
||||
case StatusFail:
|
||||
return "失败"
|
||||
default:
|
||||
return "未测"
|
||||
}
|
||||
}
|
||||
|
||||
// WriteMarkdown 写出 F01–F23 对照表。
|
||||
func WriteMarkdown(w io.Writer, items []Item) error {
|
||||
summary := map[Status]int{}
|
||||
byID := make(map[string]Item, len(items))
|
||||
for _, it := range items {
|
||||
byID[it.ID] = it
|
||||
summary[it.Status]++
|
||||
}
|
||||
if _, err := fmt.Fprintf(w, "# NixMsg 验收对照表(PRD 第 10 节)\n\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(w, "生成时间:%s\n\n", time.Now().UTC().Format(time.RFC3339)); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(w, "汇总:通过 %d,失败 %d,未测 %d\n\n", summary[StatusPass], summary[StatusFail], summary[StatusUntested]); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(w, "| 编号 | 一句话 | 结果 | 备注 |\n|---|---|---|---|\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, f := range Features {
|
||||
it := byID[f.ID]
|
||||
note := it.Note
|
||||
if note == "" {
|
||||
note = "—"
|
||||
}
|
||||
if _, err := fmt.Fprintf(w, "| %s | %s | %s | %s |\n", f.ID, f.Summary, statusCN(it.Status), note); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EmptyResults 返回全部未测的样例。
|
||||
func EmptyResults() Results {
|
||||
items := make([]Item, 0, len(Features))
|
||||
for _, f := range Features {
|
||||
items = append(items, Item{ID: f.ID, Status: StatusUntested, Note: ""})
|
||||
}
|
||||
return Results{
|
||||
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
Items: items,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package report
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizeFillsMissingAsUntested(t *testing.T) {
|
||||
t.Parallel()
|
||||
items, err := Normalize(Results{Items: []Item{
|
||||
{ID: "F01", Status: StatusPass, Note: "ok"},
|
||||
{ID: "f08", Status: "失败", Note: "flaky"},
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(items) != 23 {
|
||||
t.Fatalf("len=%d", len(items))
|
||||
}
|
||||
if items[0].Status != StatusPass || items[0].Note != "ok" {
|
||||
t.Fatalf("F01=%+v", items[0])
|
||||
}
|
||||
if items[7].Status != StatusFail {
|
||||
t.Fatalf("F08=%+v", items[7])
|
||||
}
|
||||
if items[22].Status != StatusUntested {
|
||||
t.Fatalf("F23=%+v", items[22])
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteMarkdownSample(t *testing.T) {
|
||||
t.Parallel()
|
||||
path := filepath.Join("testdata", "sample_results.json")
|
||||
r, err := LoadResults(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
items, err := Normalize(r)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := WriteMarkdown(&buf, items); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := buf.String()
|
||||
for _, id := range []string{"F01", "F12", "F23"} {
|
||||
if !strings.Contains(out, id) {
|
||||
t.Fatalf("missing %s in:\n%s", id, out)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(out, "未测") {
|
||||
t.Fatalf("expected 未测:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "通过 0,失败 0,未测 23") {
|
||||
t.Fatalf("summary mismatch:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptyResultsRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "empty.json")
|
||||
raw, err := json.MarshalIndent(EmptyResults(), "", " ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if writeErr := os.WriteFile(path, raw, 0o644); writeErr != nil {
|
||||
t.Fatal(writeErr)
|
||||
}
|
||||
r, err := LoadResults(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(r.Items) != 23 {
|
||||
t.Fatalf("items=%d", len(r.Items))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseStatusRejectsUnknown(t *testing.T) {
|
||||
t.Parallel()
|
||||
if _, err := Normalize(Results{Items: []Item{{ID: "F01", Status: "maybe"}}}); err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"generated_at": "2026-09-30T00:00:00Z",
|
||||
"items": [
|
||||
{"id": "F01", "status": "untested", "note": ""},
|
||||
{"id": "F02", "status": "untested", "note": ""},
|
||||
{"id": "F03", "status": "untested", "note": ""},
|
||||
{"id": "F04", "status": "untested", "note": ""},
|
||||
{"id": "F05", "status": "untested", "note": ""},
|
||||
{"id": "F06", "status": "untested", "note": ""},
|
||||
{"id": "F07", "status": "untested", "note": ""},
|
||||
{"id": "F08", "status": "untested", "note": ""},
|
||||
{"id": "F09", "status": "untested", "note": ""},
|
||||
{"id": "F10", "status": "untested", "note": ""},
|
||||
{"id": "F11", "status": "untested", "note": ""},
|
||||
{"id": "F12", "status": "untested", "note": ""},
|
||||
{"id": "F13", "status": "untested", "note": ""},
|
||||
{"id": "F14", "status": "untested", "note": ""},
|
||||
{"id": "F15", "status": "untested", "note": ""},
|
||||
{"id": "F16", "status": "untested", "note": ""},
|
||||
{"id": "F17", "status": "untested", "note": ""},
|
||||
{"id": "F18", "status": "untested", "note": ""},
|
||||
{"id": "F19", "status": "untested", "note": ""},
|
||||
{"id": "F20", "status": "untested", "note": ""},
|
||||
{"id": "F21", "status": "untested", "note": ""},
|
||||
{"id": "F22", "status": "untested", "note": ""},
|
||||
{"id": "F23", "status": "untested", "note": ""}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user