feat: backend native executor implement
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
// Package daemonserver accepts local CLI requests over the frozen Unix Socket.
|
||||
package daemonserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"yms-daemon/internal/backendupdate"
|
||||
"yms-daemon/internal/daemonapi"
|
||||
"yms-daemon/internal/transaction"
|
||||
)
|
||||
|
||||
const maximumRequestBytes = 1 << 20
|
||||
|
||||
type backendUpdater interface {
|
||||
UpdateRepack(context.Context, string, backendupdate.ProgressReporter) (transaction.Transaction, error)
|
||||
UpdateNativeJAR(context.Context, string, backendupdate.ProgressReporter) (transaction.Transaction, error)
|
||||
Restart(context.Context, backendupdate.ProgressReporter) (transaction.Transaction, error)
|
||||
}
|
||||
|
||||
// Server owns the local Unix Socket and dispatches requests to the transaction orchestrator.
|
||||
type Server struct {
|
||||
socketPath string
|
||||
updater backendUpdater
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func New(socketPath string, updater backendUpdater, 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 logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
return &Server{socketPath: socketPath, updater: updater, logger: logger}, nil
|
||||
}
|
||||
|
||||
// Serve listens until ctx is canceled. Each accepted request owns its response connection.
|
||||
func (s *Server) Serve(ctx context.Context) error {
|
||||
if err := prepareSocketPath(s.socketPath); err != nil {
|
||||
return err
|
||||
}
|
||||
listener, err := net.Listen("unix", s.socketPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("listen on daemon Unix Socket %s: %w", s.socketPath, err)
|
||||
}
|
||||
if err := os.Chmod(s.socketPath, 0o600); err != nil {
|
||||
_ = listener.Close()
|
||||
return fmt.Errorf("set daemon Unix Socket permissions: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = listener.Close()
|
||||
_ = os.Remove(s.socketPath)
|
||||
}()
|
||||
|
||||
s.logger.InfoContext(ctx, "daemon server listening", "socket", s.socketPath)
|
||||
var connections sync.WaitGroup
|
||||
defer connections.Wait()
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
_ = listener.Close()
|
||||
}()
|
||||
for {
|
||||
connection, err := listener.Accept()
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("accept daemon Unix Socket connection: %w", err)
|
||||
}
|
||||
connections.Add(1)
|
||||
go func() {
|
||||
defer connections.Done()
|
||||
s.handle(ctx, connection)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handle(ctx context.Context, connection net.Conn) {
|
||||
defer connection.Close()
|
||||
_ = connection.SetReadDeadline(time.Now().Add(10 * time.Second))
|
||||
request, err := decodeRequest(connection)
|
||||
_ = connection.SetReadDeadline(time.Time{})
|
||||
if err != nil {
|
||||
_ = s.writeResponse(connection, daemonapi.Response{Kind: daemonapi.ResponseResult, Error: err.Error()})
|
||||
return
|
||||
}
|
||||
if request.Service != "backend" {
|
||||
_ = s.writeResponse(connection, daemonapi.Response{Kind: daemonapi.ResponseResult, Error: "service must be backend"})
|
||||
return
|
||||
}
|
||||
progressWritable := true
|
||||
report := func(progress backendupdate.Progress) {
|
||||
if !progressWritable {
|
||||
return
|
||||
}
|
||||
err := s.writeResponse(connection, daemonapi.Response{
|
||||
Kind: daemonapi.ResponseProgress,
|
||||
TransactionID: progress.TransactionID,
|
||||
State: string(progress.State),
|
||||
Message: progress.Message,
|
||||
})
|
||||
if err != nil {
|
||||
progressWritable = false
|
||||
s.logger.WarnContext(ctx, "write daemon update progress", "error", err)
|
||||
}
|
||||
}
|
||||
var record transaction.Transaction
|
||||
var updateErr error
|
||||
switch request.Operation {
|
||||
case daemonapi.OperationUpdate:
|
||||
switch request.InputType {
|
||||
case daemonapi.InputTypeRepackZIP:
|
||||
record, updateErr = s.updater.UpdateRepack(ctx, request.File, report)
|
||||
case daemonapi.InputTypeNativeJAR:
|
||||
record, updateErr = s.updater.UpdateNativeJAR(ctx, request.File, report)
|
||||
default:
|
||||
_ = s.writeResponse(connection, daemonapi.Response{Kind: daemonapi.ResponseResult, Error: "inputType must be repack-zip or native-jar"})
|
||||
return
|
||||
}
|
||||
case daemonapi.OperationRestart:
|
||||
if request.InputType != "" || request.File != "" {
|
||||
_ = s.writeResponse(connection, daemonapi.Response{Kind: daemonapi.ResponseResult, Error: "restart does not accept inputType or file"})
|
||||
return
|
||||
}
|
||||
record, updateErr = s.updater.Restart(ctx, report)
|
||||
default:
|
||||
_ = s.writeResponse(connection, daemonapi.Response{Kind: daemonapi.ResponseResult, Error: "operation must be update or restart"})
|
||||
return
|
||||
}
|
||||
response := daemonapi.Response{Kind: daemonapi.ResponseResult, TransactionID: record.ID, State: string(record.State)}
|
||||
if updateErr != nil {
|
||||
response.Error = updateErr.Error()
|
||||
s.logger.ErrorContext(ctx, "backend operation failed", "operation", request.Operation, "transaction_id", record.ID, "state", record.State, "error", updateErr)
|
||||
} else {
|
||||
s.logger.InfoContext(ctx, "backend operation completed", "operation", request.Operation, "transaction_id", record.ID, "state", record.State)
|
||||
}
|
||||
if err := s.writeResponse(connection, response); err != nil {
|
||||
s.logger.ErrorContext(ctx, "write daemon operation result", "operation", request.Operation, "transaction_id", record.ID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) writeResponse(connection net.Conn, response daemonapi.Response) error {
|
||||
return json.NewEncoder(connection).Encode(response)
|
||||
}
|
||||
|
||||
func decodeRequest(reader io.Reader) (daemonapi.Request, error) {
|
||||
limited := io.LimitReader(reader, maximumRequestBytes+1)
|
||||
decoder := json.NewDecoder(limited)
|
||||
decoder.DisallowUnknownFields()
|
||||
var request daemonapi.Request
|
||||
if err := decoder.Decode(&request); err != nil {
|
||||
return daemonapi.Request{}, fmt.Errorf("decode daemon request: %w", err)
|
||||
}
|
||||
var trailing any
|
||||
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
|
||||
if err == nil {
|
||||
return daemonapi.Request{}, errors.New("daemon request contains multiple JSON values")
|
||||
}
|
||||
return daemonapi.Request{}, fmt.Errorf("decode daemon request trailing content: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(request.Operation) != request.Operation || strings.TrimSpace(request.Service) != request.Service || strings.TrimSpace(request.InputType) != request.InputType || strings.TrimSpace(request.File) != request.File {
|
||||
return daemonapi.Request{}, errors.New("daemon request fields must not contain surrounding whitespace")
|
||||
}
|
||||
return request, nil
|
||||
}
|
||||
|
||||
func prepareSocketPath(socketPath string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(socketPath), 0o755); err != nil {
|
||||
return fmt.Errorf("create daemon Unix Socket directory: %w", err)
|
||||
}
|
||||
info, err := os.Lstat(socketPath)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect daemon Unix Socket path: %w", err)
|
||||
}
|
||||
if info.Mode()&os.ModeSocket == 0 {
|
||||
return fmt.Errorf("daemon Unix Socket path is occupied by a non-socket file: %s", socketPath)
|
||||
}
|
||||
if err := os.Remove(socketPath); err != nil {
|
||||
return fmt.Errorf("remove stale daemon Unix Socket: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package daemonserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"yms-daemon/internal/backendupdate"
|
||||
"yms-daemon/internal/daemonapi"
|
||||
"yms-daemon/internal/daemonclient"
|
||||
"yms-daemon/internal/transaction"
|
||||
)
|
||||
|
||||
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)))
|
||||
if err != nil {
|
||||
t.Fatalf("create daemon server: %v", err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
serveResult := make(chan error, 1)
|
||||
go func() { serveResult <- server.Serve(ctx) }()
|
||||
waitForSocket(t, socketPath, serveResult)
|
||||
|
||||
packagePath := filepath.Join(t.TempDir(), "package.zip")
|
||||
if err := os.WriteFile(packagePath, []byte("zip"), 0o600); err != nil {
|
||||
t.Fatalf("write update package: %v", err)
|
||||
}
|
||||
var progress []daemonapi.Response
|
||||
response, err := daemonclient.Update(context.Background(), socketPath, "backend", daemonapi.InputTypeRepackZIP, packagePath, func(event daemonapi.Response) {
|
||||
progress = append(progress, event)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("submit backend update: %v", err)
|
||||
}
|
||||
if response.TransactionID != updater.record.ID || response.State != string(transaction.StateCommitted) || updater.file != packagePath || updater.inputType != daemonapi.InputTypeRepackZIP {
|
||||
t.Fatalf("unexpected update response or dispatch: response=%+v file=%s", response, updater.file)
|
||||
}
|
||||
if len(progress) != 1 || progress[0].Message != "test update progress" {
|
||||
t.Fatalf("unexpected streamed update progress: %+v", progress)
|
||||
}
|
||||
|
||||
cancel()
|
||||
select {
|
||||
case err := <-serveResult:
|
||||
if err != nil {
|
||||
t.Fatalf("stop daemon server: %v", err)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("daemon server did not stop")
|
||||
}
|
||||
}
|
||||
|
||||
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)))
|
||||
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)
|
||||
|
||||
jarPath := filepath.Join(t.TempDir(), "glory-soft-yms.jar")
|
||||
if err := os.WriteFile(jarPath, []byte("jar"), 0o600); err != nil {
|
||||
t.Fatalf("write direct native backend JAR: %v", err)
|
||||
}
|
||||
response, err := daemonclient.Update(context.Background(), socketPath, "backend", daemonapi.InputTypeNativeJAR, jarPath, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("submit direct native backend update: %v", err)
|
||||
}
|
||||
if response.TransactionID != updater.record.ID || updater.file != jarPath || updater.inputType != daemonapi.InputTypeNativeJAR {
|
||||
t.Fatalf("unexpected direct update response or dispatch: response=%+v file=%s inputType=%s", response, updater.file, updater.inputType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerReturnsTransactionFailure(t *testing.T) {
|
||||
socketPath := shortSocketPath(t)
|
||||
updater := &fakeUpdater{
|
||||
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)))
|
||||
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)
|
||||
|
||||
packagePath := filepath.Join(t.TempDir(), "package.zip")
|
||||
response, err := daemonclient.Update(context.Background(), socketPath, "backend", daemonapi.InputTypeRepackZIP, packagePath, nil)
|
||||
if err == nil || response.TransactionID != updater.record.ID || response.State != string(transaction.StateRolledBack) {
|
||||
t.Fatalf("unexpected failed update response: response=%+v err=%v", response, err)
|
||||
}
|
||||
}
|
||||
|
||||
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)))
|
||||
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)
|
||||
|
||||
var progress []daemonapi.Response
|
||||
response, err := daemonclient.Restart(context.Background(), socketPath, "backend", func(event daemonapi.Response) {
|
||||
progress = append(progress, event)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("submit backend restart: %v", err)
|
||||
}
|
||||
if response.TransactionID != updater.record.ID || updater.operation != daemonapi.OperationRestart {
|
||||
t.Fatalf("unexpected restart response or dispatch: response=%+v operation=%s", response, updater.operation)
|
||||
}
|
||||
if len(progress) != 1 || progress[0].Message != "test restart progress" {
|
||||
t.Fatalf("unexpected streamed restart progress: %+v", progress)
|
||||
}
|
||||
}
|
||||
|
||||
func shortSocketPath(t *testing.T) string {
|
||||
t.Helper()
|
||||
directory, err := os.MkdirTemp("", "yd-")
|
||||
if err != nil {
|
||||
t.Fatalf("create short Unix Socket directory: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.RemoveAll(directory) })
|
||||
return filepath.Join(directory, "daemon.sock")
|
||||
}
|
||||
|
||||
func waitForSocket(t *testing.T, socketPath string, serveResult <-chan error) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
select {
|
||||
case err := <-serveResult:
|
||||
if errors.Is(err, syscall.EPERM) {
|
||||
t.Skip("Unix Socket creation is not permitted by the test sandbox")
|
||||
}
|
||||
t.Fatalf("daemon server stopped before creating Unix Socket: %v", err)
|
||||
default:
|
||||
}
|
||||
info, err := os.Lstat(socketPath)
|
||||
if err == nil && info.Mode()&os.ModeSocket != 0 {
|
||||
return
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("daemon Unix Socket was not created: %s", socketPath)
|
||||
}
|
||||
|
||||
type fakeUpdater struct {
|
||||
record transaction.Transaction
|
||||
err error
|
||||
file string
|
||||
inputType string
|
||||
operation string
|
||||
}
|
||||
|
||||
func (u *fakeUpdater) UpdateRepack(_ context.Context, file string, report backendupdate.ProgressReporter) (transaction.Transaction, error) {
|
||||
u.file = file
|
||||
u.inputType = daemonapi.InputTypeRepackZIP
|
||||
u.operation = daemonapi.OperationUpdate
|
||||
if report != nil {
|
||||
report(backendupdate.Progress{TransactionID: u.record.ID, State: transaction.StateStarting, Message: "test update progress"})
|
||||
}
|
||||
return u.record, u.err
|
||||
}
|
||||
|
||||
func (u *fakeUpdater) UpdateNativeJAR(_ context.Context, file string, report backendupdate.ProgressReporter) (transaction.Transaction, error) {
|
||||
u.file = file
|
||||
u.inputType = daemonapi.InputTypeNativeJAR
|
||||
u.operation = daemonapi.OperationUpdate
|
||||
if report != nil {
|
||||
report(backendupdate.Progress{TransactionID: u.record.ID, State: transaction.StateStarting, Message: "test update progress"})
|
||||
}
|
||||
return u.record, u.err
|
||||
}
|
||||
|
||||
func (u *fakeUpdater) Restart(_ context.Context, report backendupdate.ProgressReporter) (transaction.Transaction, error) {
|
||||
u.operation = daemonapi.OperationRestart
|
||||
if report != nil {
|
||||
report(backendupdate.Progress{TransactionID: u.record.ID, State: transaction.StateStarting, Message: "test restart progress"})
|
||||
}
|
||||
return u.record, u.err
|
||||
}
|
||||
Reference in New Issue
Block a user