feat: complete daemon operations and recovery tooling

This commit is contained in:
2026-08-22 15:34:09 +08:00
parent a0a4a7556a
commit 1abe8df6ee
19 changed files with 1421 additions and 171 deletions
+51 -5
View File
@@ -38,6 +38,15 @@ type backendUpdater interface {
Restart(context.Context, backendupdate.ProgressReporter) (transaction.Transaction, error)
}
// backendDiagnoser 抽象了后端状态诊断与对账能力,供 status/doctor/reconcile 操作使用。
// 它由 backendstatus 包在运行时注入,便于在测试中用轻量替身替换。
type backendDiagnoser interface {
// Diagnose 执行只读诊断,返回结构化诊断结果。
Diagnose(context.Context) (daemonapi.Diagnosis, error)
// Reconcile 生成修复计划或执行自动修复,apply 为 true 时执行修复动作。
Reconcile(context.Context, bool) (daemonapi.Diagnosis, error)
}
// Server 持有本地 Unix Socket 并把收到的请求分派给事务编排器。
// 它负责连接的生命周期管理、请求解码与校验、执行进度回写以及最终结果的返回。
type Server struct {
@@ -45,24 +54,29 @@ type Server struct {
socketPath string
// updater 后端更新与重启动作的实际执行者。
updater backendUpdater
// diagnoser 后端状态诊断与对账动作的实际执行者。
diagnoser backendDiagnoser
// logger 用于记录服务运行与请求处理过程中的日志。
logger *slog.Logger
}
// New 构造一个 Server 实例并校验入参。
// socketPath 必须是规范化的绝对路径,updater 不能为空,logger 为空时回退为默认日志器。
// 当路径非法或 updater 为空时返回 error。
func New(socketPath string, updater backendUpdater, logger *slog.Logger) (*Server, error) {
// socketPath 必须是规范化的绝对路径,updater 与 diagnoser 不能为空,logger 为空时回退为默认日志器。
// 当路径非法或依赖为空时返回 error。
func New(socketPath string, updater backendUpdater, diagnoser backendDiagnoser, logger *slog.Logger) (*Server, error) {
if !filepath.IsAbs(socketPath) || filepath.Clean(socketPath) != socketPath {
return nil, errors.New("daemon Unix Socket path must be a clean absolute path")
}
if updater == nil {
return nil, errors.New("backend updater is required")
}
if diagnoser == nil {
return nil, errors.New("backend diagnoser is required")
}
if logger == nil {
logger = slog.Default()
}
return &Server{socketPath: socketPath, updater: updater, logger: logger}, nil
return &Server{socketPath: socketPath, updater: updater, diagnoser: diagnoser, logger: logger}, nil
}
// Serve 监听 Unix Socket 直到 ctx 被取消,返回前会等待所有连接处理完成并清理 Socket 文件。
@@ -176,8 +190,24 @@ func (s *Server) handle(ctx context.Context, connection net.Conn) {
return
}
record, updateErr = s.updater.Restart(ctx, report)
case daemonapi.OperationStatus, daemonapi.OperationDoctor:
if request.InputType != "" || request.File != "" || request.ImageReference != "" || request.Apply {
_ = s.writeResponse(connection, daemonapi.Response{Kind: daemonapi.ResponseResult, Error: request.Operation + " does not accept inputType, file, imageReference, or apply"})
return
}
diagnosis, diagnosisErr := s.diagnoser.Diagnose(ctx)
s.writeDiagnosis(ctx, connection, request.Operation, diagnosis, diagnosisErr)
return
case daemonapi.OperationReconcile:
if request.InputType != "" || request.File != "" || request.ImageReference != "" {
_ = s.writeResponse(connection, daemonapi.Response{Kind: daemonapi.ResponseResult, Error: "reconcile does not accept inputType, file, or imageReference"})
return
}
diagnosis, diagnosisErr := s.diagnoser.Reconcile(ctx, request.Apply)
s.writeDiagnosis(ctx, connection, request.Operation, diagnosis, diagnosisErr)
return
default:
_ = s.writeResponse(connection, daemonapi.Response{Kind: daemonapi.ResponseResult, Error: "operation must be update or restart"})
_ = s.writeResponse(connection, daemonapi.Response{Kind: daemonapi.ResponseResult, Error: "operation must be update, restart, status, doctor, or reconcile"})
return
}
response := daemonapi.Response{Kind: daemonapi.ResponseResult, TransactionID: record.ID, State: string(record.State)}
@@ -197,6 +227,22 @@ func (s *Server) writeResponse(connection net.Conn, response daemonapi.Response)
return json.NewEncoder(connection).Encode(response)
}
// writeDiagnosis 把 status/doctor/reconcile 的诊断结果写回客户端。
// 诊断失败时返回携带错误信息的响应并清空诊断结果,同时记录相应日志。
func (s *Server) writeDiagnosis(ctx context.Context, connection net.Conn, operation string, diagnosis daemonapi.Diagnosis, err error) {
response := daemonapi.Response{Kind: daemonapi.ResponseResult, Diagnosis: &diagnosis}
if err != nil {
response.Diagnosis = nil
response.Error = err.Error()
s.logger.ErrorContext(ctx, "backend diagnosis failed", "operation", operation, "error", err)
} else {
s.logger.InfoContext(ctx, "backend diagnosis completed", "operation", operation, "healthy", diagnosis.Healthy, "items", len(diagnosis.Items))
}
if writeErr := s.writeResponse(connection, response); writeErr != nil {
s.logger.ErrorContext(ctx, "write daemon diagnosis result", "operation", operation, "error", writeErr)
}
}
// decodeRequest 从 reader 中解码并校验一条客户端请求。
// 它限制请求体大小不超过 maximumRequestBytes,禁止未知字段,并要求请求只能包含一个 JSON 值,
// 且所有字符串字段不得带有前后空白字符;任一条不满足时返回 error。
+72 -5
View File
@@ -22,7 +22,7 @@ import (
func TestServerAcceptsBackendUpdateThroughUnixSocket(t *testing.T) {
socketPath := shortSocketPath(t)
updater := &fakeUpdater{record: transaction.Transaction{ID: "transaction-01", State: transaction.StateCommitted}}
server, err := New(socketPath, updater, slog.New(slog.NewTextHandler(io.Discard, nil)))
server, err := New(socketPath, updater, &fakeDiagnoser{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err != nil {
t.Fatalf("create daemon server: %v", err)
}
@@ -65,7 +65,7 @@ func TestServerAcceptsBackendUpdateThroughUnixSocket(t *testing.T) {
func TestServerAcceptsDirectNativeJARThroughUnixSocket(t *testing.T) {
socketPath := shortSocketPath(t)
updater := &fakeUpdater{record: transaction.Transaction{ID: "transaction-direct-01", State: transaction.StateCommitted}}
server, err := New(socketPath, updater, slog.New(slog.NewTextHandler(io.Discard, nil)))
server, err := New(socketPath, updater, &fakeDiagnoser{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err != nil {
t.Fatalf("create daemon server: %v", err)
}
@@ -93,7 +93,7 @@ func TestServerAcceptsDirectNativeJARThroughUnixSocket(t *testing.T) {
func TestServerAcceptsContainerImageThroughUnixSocket(t *testing.T) {
socketPath := shortSocketPath(t)
updater := &fakeUpdater{record: transaction.Transaction{ID: "transaction-container-01", State: transaction.StateCommitted}}
server, err := New(socketPath, updater, slog.New(slog.NewTextHandler(io.Discard, nil)))
server, err := New(socketPath, updater, &fakeDiagnoser{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err != nil {
t.Fatalf("create daemon server: %v", err)
}
@@ -121,7 +121,7 @@ func TestServerReturnsTransactionFailure(t *testing.T) {
record: transaction.Transaction{ID: "transaction-02", State: transaction.StateRolledBack},
err: errors.New("health check failed"),
}
server, err := New(socketPath, updater, slog.New(slog.NewTextHandler(io.Discard, nil)))
server, err := New(socketPath, updater, &fakeDiagnoser{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err != nil {
t.Fatalf("create daemon server: %v", err)
}
@@ -143,7 +143,7 @@ func TestServerReturnsTransactionFailure(t *testing.T) {
func TestServerAcceptsBackendRestartThroughUnixSocket(t *testing.T) {
socketPath := shortSocketPath(t)
updater := &fakeUpdater{record: transaction.Transaction{ID: "transaction-restart-01", State: transaction.StateCommitted}}
server, err := New(socketPath, updater, slog.New(slog.NewTextHandler(io.Discard, nil)))
server, err := New(socketPath, updater, &fakeDiagnoser{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err != nil {
t.Fatalf("create daemon server: %v", err)
}
@@ -168,6 +168,55 @@ func TestServerAcceptsBackendRestartThroughUnixSocket(t *testing.T) {
}
}
// TestServerReturnsStatusThroughUnixSocket 验证服务端能通过 Unix Socket 处理 status 请求,
// 并把诊断结果返回给客户端。
func TestServerReturnsStatusThroughUnixSocket(t *testing.T) {
socketPath := shortSocketPath(t)
updater := &fakeUpdater{}
diagnosis := daemonapi.Diagnosis{Service: "backend", Type: "container", Healthy: true, Items: []daemonapi.DiagnosisItem{{Level: daemonapi.DiagnosisLevelOK, Code: "ok", Message: "ok"}}}
diagnoser := &fakeDiagnoser{diagnosis: diagnosis}
server, err := New(socketPath, updater, diagnoser, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err != nil {
t.Fatalf("create daemon server: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
serveResult := make(chan error, 1)
go func() { serveResult <- server.Serve(ctx) }()
waitForSocket(t, socketPath, serveResult)
result, err := daemonclient.Status(context.Background(), socketPath, "backend")
if err != nil {
t.Fatalf("submit status: %v", err)
}
if result.Service != "backend" || !result.Healthy {
t.Fatalf("unexpected status result: %+v", result)
}
}
// TestServerReconcileForwardsApply 验证 reconcile --apply 会把 apply 标志正确传递给诊断器。
func TestServerReconcileForwardsApply(t *testing.T) {
socketPath := shortSocketPath(t)
updater := &fakeUpdater{}
diagnoser := &fakeDiagnoser{diagnosis: daemonapi.Diagnosis{Service: "backend", Type: "container", Healthy: true}}
server, err := New(socketPath, updater, diagnoser, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err != nil {
t.Fatalf("create daemon server: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
serveResult := make(chan error, 1)
go func() { serveResult <- server.Serve(ctx) }()
waitForSocket(t, socketPath, serveResult)
if _, err := daemonclient.Reconcile(context.Background(), socketPath, "backend", true); err != nil {
t.Fatalf("submit reconcile apply: %v", err)
}
if !diagnoser.applied {
t.Fatal("reconcile --apply was not forwarded to diagnoser")
}
}
// shortSocketPath 创建一个短目录并返回其下的 daemon.sock 路径,用于规避 Unix Socket 路径长度上限。
// 目录会在测试结束时自动清理。
func shortSocketPath(t *testing.T) string {
@@ -259,3 +308,21 @@ func (u *fakeUpdater) Restart(_ context.Context, report backendupdate.ProgressRe
}
return u.record, u.err
}
// fakeDiagnoser backendDiagnoser 接口的测试替身,返回预设的空诊断结果。
type fakeDiagnoser struct {
diagnosis daemonapi.Diagnosis
err error
applied bool
}
// Diagnose 返回预设的诊断结果。
func (d *fakeDiagnoser) Diagnose(context.Context) (daemonapi.Diagnosis, error) {
return d.diagnosis, d.err
}
// Reconcile 返回预设的诊断结果,并记录 apply 标志。
func (d *fakeDiagnoser) Reconcile(_ context.Context, apply bool) (daemonapi.Diagnosis, error) {
d.applied = apply
return d.diagnosis, d.err
}