feat: refactor to ymsctl & yms-daemon
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
package backendexecutor
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -62,6 +63,8 @@ type Request struct {
|
||||
ConfigLocation string
|
||||
RestartPolicy containerengine.RestartPolicy
|
||||
HealthEndpoint string
|
||||
StartLog bool
|
||||
LogReporter func(string)
|
||||
}
|
||||
|
||||
// Executor drives the persisted transaction up to SWITCHING after the new container is healthy.
|
||||
@@ -210,6 +213,21 @@ func (e *Executor) startAndCheck(ctx context.Context, transactionID string, requ
|
||||
if _, err := e.coordinator.ExecuteStep(ctx, transactionID, startIntent(request), startOperation); err != nil {
|
||||
return err
|
||||
}
|
||||
if request.StartLog && request.LogReporter != nil {
|
||||
logs, err := e.engine.ContainerLogs(ctx, request.ContainerName)
|
||||
if err == nil {
|
||||
scanner := bufio.NewScanner(logs)
|
||||
for scanner.Scan() {
|
||||
request.LogReporter(scanner.Text())
|
||||
}
|
||||
_ = logs.Close()
|
||||
if err := scanner.Err(); err != nil {
|
||||
return fmt.Errorf("read container startup logs: %w", err)
|
||||
}
|
||||
} else {
|
||||
request.LogReporter("unable to read container startup logs: " + err.Error())
|
||||
}
|
||||
}
|
||||
healthOperation := &healthOperation{
|
||||
engine: e.engine,
|
||||
checker: e.checker,
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
@@ -408,6 +409,10 @@ func (e *fakeEngine) StartContainer(_ context.Context, name string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *fakeEngine) ContainerLogs(context.Context, string) (io.ReadCloser, error) {
|
||||
return io.NopCloser(strings.NewReader("")), nil
|
||||
}
|
||||
|
||||
func (e *fakeEngine) StopContainer(_ context.Context, name string) error {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
|
||||
@@ -33,6 +33,7 @@ type persistedContainerRequest struct {
|
||||
PreviousPort int `json:"previousPort"`
|
||||
PreviousContainer string `json:"previousContainer"`
|
||||
TargetHealthEndpoint string `json:"targetHealthEndpoint"`
|
||||
StartLog bool `json:"startLog"`
|
||||
GatewayBeforePath string `json:"gatewayBeforePath"`
|
||||
GatewayAfterPath string `json:"gatewayAfterPath"`
|
||||
GatewayReceiptPath string `json:"gatewayReceiptPath"`
|
||||
@@ -40,7 +41,7 @@ type persistedContainerRequest struct {
|
||||
|
||||
// UpdateContainerImage pulls one development image, freezes its repository
|
||||
// digest, and updates the inactive Docker backend slot.
|
||||
func (u *Updater) UpdateContainerImage(ctx context.Context, imageReference string, report ProgressReporter) (transaction.Transaction, error) {
|
||||
func (u *Updater) UpdateContainerImage(ctx context.Context, imageReference string, startLog bool, report ProgressReporter) (transaction.Transaction, error) {
|
||||
if u.containerExecutor == nil || u.engine == nil {
|
||||
return transaction.Transaction{}, errors.New("container backend updater is not configured")
|
||||
}
|
||||
@@ -138,6 +139,7 @@ func (u *Updater) UpdateContainerImage(ctx context.Context, imageReference strin
|
||||
TargetPort: targetPort, TargetContainer: targetSlot.ContainerName,
|
||||
PreviousPort: before.ActivePort, PreviousContainer: previousContainer,
|
||||
TargetHealthEndpoint: targetSlot.HealthEndpoint,
|
||||
StartLog: startLog,
|
||||
GatewayBeforePath: beforePath, GatewayAfterPath: afterPath,
|
||||
GatewayReceiptPath: filepath.Join(transactionRoot, "gateway.applied"),
|
||||
}
|
||||
@@ -294,6 +296,10 @@ func (u *Updater) runContainerUpdate(ctx context.Context, record transaction.Tra
|
||||
ConfigLocation: deploymentconfig.ContainerConfigLocation,
|
||||
RestartPolicy: containerengine.RestartPolicy{Name: "no"},
|
||||
HealthEndpoint: request.TargetHealthEndpoint,
|
||||
StartLog: request.StartLog,
|
||||
LogReporter: func(line string) {
|
||||
reportProgress(report, Progress{TransactionID: record.ID, State: transaction.StateStarting, Message: "CONTAINER LOG " + line})
|
||||
},
|
||||
}
|
||||
switch record.State {
|
||||
case transaction.StateCreated, transaction.StateValidating, transaction.StatePrepared, transaction.StateStarting:
|
||||
|
||||
@@ -31,7 +31,7 @@ func TestContainerUpdaterPullsSwitchesAndStopsPreviousSlot(t *testing.T) {
|
||||
},
|
||||
}, 0)
|
||||
|
||||
record, err := updater.UpdateContainerImage(ctx, containerTestImage, nil)
|
||||
record, err := updater.UpdateContainerImage(ctx, containerTestImage, true, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("update container backend: %v", err)
|
||||
}
|
||||
@@ -61,7 +61,7 @@ func TestContainerUpdaterFirstInstallCreatesInactiveSlotBeforeSwitch(t *testing.
|
||||
return nil
|
||||
}
|
||||
|
||||
record, err := updater.UpdateContainerImage(ctx, containerTestImage, func(item Progress) {
|
||||
record, err := updater.UpdateContainerImage(ctx, containerTestImage, true, func(item Progress) {
|
||||
progress = append(progress, item)
|
||||
})
|
||||
if err != nil {
|
||||
@@ -99,7 +99,7 @@ func TestContainerUpdaterRejectsMissingActiveWithPresentInactive(t *testing.T) {
|
||||
},
|
||||
}, 0)
|
||||
|
||||
_, err := updater.UpdateContainerImage(context.Background(), containerTestImage, nil)
|
||||
_, err := updater.UpdateContainerImage(context.Background(), containerTestImage, true, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "active backend container backend-8080 is missing but inactive container backend-8081 is running") {
|
||||
t.Fatalf("unexpected missing-active result: %v", err)
|
||||
}
|
||||
@@ -116,7 +116,7 @@ func TestContainerUpdaterRecoversFailedRollbackThenRetriesSameImage(t *testing.T
|
||||
return nil
|
||||
}
|
||||
|
||||
rollingBack, err := updater.UpdateContainerImage(ctx, containerTestImage, nil)
|
||||
rollingBack, err := updater.UpdateContainerImage(ctx, containerTestImage, true, nil)
|
||||
if err == nil || rollingBack.State != transaction.StateRollingBack {
|
||||
t.Fatalf("unexpected failed rollback result: record=%+v err=%v", rollingBack, err)
|
||||
}
|
||||
@@ -126,12 +126,12 @@ func TestContainerUpdaterRecoversFailedRollbackThenRetriesSameImage(t *testing.T
|
||||
}
|
||||
|
||||
failGateway = false
|
||||
rolledBack, err := updater.UpdateContainerImage(ctx, containerTestImage, nil)
|
||||
rolledBack, err := updater.UpdateContainerImage(ctx, containerTestImage, true, nil)
|
||||
if err == nil || rolledBack.State != transaction.StateRolledBack {
|
||||
t.Fatalf("unexpected resumed rollback result: record=%+v err=%v", rolledBack, err)
|
||||
}
|
||||
|
||||
committed, err := updater.UpdateContainerImage(ctx, containerTestImage, nil)
|
||||
committed, err := updater.UpdateContainerImage(ctx, containerTestImage, true, nil)
|
||||
if err != nil || committed.State != transaction.StateCommitted {
|
||||
t.Fatalf("retry same image after rollback: record=%+v err=%v", committed, err)
|
||||
}
|
||||
@@ -169,7 +169,7 @@ func TestContainerUpdaterRejectsMissingCommittedContainer(t *testing.T) {
|
||||
t.Fatalf("commit previous backend deployment: %v", err)
|
||||
}
|
||||
|
||||
_, err = updater.UpdateContainerImage(ctx, containerTestImage, nil)
|
||||
_, err = updater.UpdateContainerImage(ctx, containerTestImage, true, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "committed active backend container backend-8080 is missing") {
|
||||
t.Fatalf("unexpected committed-container drift result: %v", err)
|
||||
}
|
||||
@@ -336,6 +336,10 @@ func (e *containerUpdateEngine) StartContainer(_ context.Context, name string) e
|
||||
e.containers[name] = record
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *containerUpdateEngine) ContainerLogs(context.Context, string) (io.ReadCloser, error) {
|
||||
return io.NopCloser(strings.NewReader("")), nil
|
||||
}
|
||||
func (e *containerUpdateEngine) StopContainer(_ context.Context, name string) error {
|
||||
e.stopped = append(e.stopped, name)
|
||||
record, found := e.containers[name]
|
||||
|
||||
@@ -79,6 +79,7 @@ type Engine interface {
|
||||
InspectImage(context.Context, string) (Image, error)
|
||||
CreateContainer(context.Context, ContainerSpec) (Container, error)
|
||||
StartContainer(context.Context, string) error
|
||||
ContainerLogs(context.Context, string) (io.ReadCloser, error)
|
||||
StopContainer(context.Context, string) error
|
||||
InspectContainer(context.Context, string) (Container, error)
|
||||
RemoveContainer(context.Context, string, bool) error
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package containerengine
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -8,6 +9,7 @@ import (
|
||||
"io"
|
||||
|
||||
cerrdefs "github.com/containerd/errdefs"
|
||||
"github.com/moby/moby/api/pkg/stdcopy"
|
||||
"github.com/moby/moby/api/types/container"
|
||||
"github.com/moby/moby/api/types/jsonstream"
|
||||
"github.com/moby/moby/api/types/mount"
|
||||
@@ -148,6 +150,19 @@ func (e *MobyEngine) StartContainer(ctx context.Context, idOrName string) error
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *MobyEngine) ContainerLogs(ctx context.Context, idOrName string) (io.ReadCloser, error) {
|
||||
stream, err := e.client.ContainerLogs(ctx, idOrName, client.ContainerLogsOptions{ShowStdout: true, ShowStderr: true, Tail: "all"})
|
||||
if err != nil {
|
||||
return nil, engineError("read container logs", err)
|
||||
}
|
||||
defer stream.Close()
|
||||
var output bytes.Buffer
|
||||
if _, err := stdcopy.StdCopy(&output, &output, stream); err != nil {
|
||||
return nil, fmt.Errorf("decode container logs: %w", err)
|
||||
}
|
||||
return io.NopCloser(bytes.NewReader(output.Bytes())), nil
|
||||
}
|
||||
|
||||
func (e *MobyEngine) StopContainer(ctx context.Context, idOrName string) error {
|
||||
if _, err := e.client.ContainerStop(ctx, idOrName, client.ContainerStopOptions{}); err != nil {
|
||||
return engineError("stop container", err)
|
||||
|
||||
@@ -17,6 +17,7 @@ type Request struct {
|
||||
InputType string `json:"inputType"`
|
||||
File string `json:"file"`
|
||||
ImageReference string `json:"imageReference"`
|
||||
StartLog bool `json:"startLog"`
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
|
||||
@@ -20,14 +20,14 @@ func Update(ctx context.Context, socketPath string, service string, inputType st
|
||||
return submit(ctx, socketPath, request, progress)
|
||||
}
|
||||
|
||||
func UpdateContainerImage(ctx context.Context, socketPath string, service string, imageReference string, progress func(daemonapi.Response)) (daemonapi.Response, error) {
|
||||
func UpdateContainerImage(ctx context.Context, socketPath string, service string, imageReference string, startLog bool, progress func(daemonapi.Response)) (daemonapi.Response, error) {
|
||||
if !filepath.IsAbs(socketPath) {
|
||||
return daemonapi.Response{}, errors.New("daemon socket path must be absolute")
|
||||
}
|
||||
if imageReference == "" {
|
||||
return daemonapi.Response{}, errors.New("container image reference is required")
|
||||
}
|
||||
request := daemonapi.Request{Operation: daemonapi.OperationUpdate, Service: service, InputType: daemonapi.InputTypeContainerImage, ImageReference: imageReference}
|
||||
request := daemonapi.Request{Operation: daemonapi.OperationUpdate, Service: service, InputType: daemonapi.InputTypeContainerImage, ImageReference: imageReference, StartLog: startLog}
|
||||
return submit(ctx, socketPath, request, progress)
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ 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)
|
||||
UpdateContainerImage(context.Context, string, backendupdate.ProgressReporter) (transaction.Transaction, error)
|
||||
UpdateContainerImage(context.Context, string, bool, backendupdate.ProgressReporter) (transaction.Transaction, error)
|
||||
Restart(context.Context, backendupdate.ProgressReporter) (transaction.Transaction, error)
|
||||
}
|
||||
|
||||
@@ -141,7 +141,7 @@ func (s *Server) handle(ctx context.Context, connection net.Conn) {
|
||||
_ = s.writeResponse(connection, daemonapi.Response{Kind: daemonapi.ResponseResult, Error: "container-image requires imageReference and does not accept file"})
|
||||
return
|
||||
}
|
||||
record, updateErr = s.updater.UpdateContainerImage(ctx, request.ImageReference, report)
|
||||
record, updateErr = s.updater.UpdateContainerImage(ctx, request.ImageReference, request.StartLog, report)
|
||||
default:
|
||||
_ = s.writeResponse(connection, daemonapi.Response{Kind: daemonapi.ResponseResult, Error: "inputType must be repack-zip, native-jar, or container-image"})
|
||||
return
|
||||
|
||||
@@ -98,7 +98,7 @@ func TestServerAcceptsContainerImageThroughUnixSocket(t *testing.T) {
|
||||
waitForSocket(t, socketPath, serveResult)
|
||||
|
||||
imageReference := "harbor.ymswell.asia/ymswell/glory-ymswell:20260813-184902-a37bf50d-v1.1.8.1"
|
||||
response, err := daemonclient.UpdateContainerImage(context.Background(), socketPath, "backend", imageReference, nil)
|
||||
response, err := daemonclient.UpdateContainerImage(context.Background(), socketPath, "backend", imageReference, true, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("submit container backend image: %v", err)
|
||||
}
|
||||
@@ -217,7 +217,7 @@ func (u *fakeUpdater) UpdateNativeJAR(_ context.Context, file string, report bac
|
||||
return u.record, u.err
|
||||
}
|
||||
|
||||
func (u *fakeUpdater) UpdateContainerImage(_ context.Context, imageReference string, report backendupdate.ProgressReporter) (transaction.Transaction, error) {
|
||||
func (u *fakeUpdater) UpdateContainerImage(_ context.Context, imageReference string, _ bool, report backendupdate.ProgressReporter) (transaction.Transaction, error) {
|
||||
u.file = imageReference
|
||||
u.inputType = daemonapi.InputTypeContainerImage
|
||||
u.operation = daemonapi.OperationUpdate
|
||||
|
||||
@@ -18,6 +18,13 @@ type Transaction struct {
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// ListFilter limits the history query used by the ymsctl list command.
|
||||
type ListFilter struct {
|
||||
Limit int
|
||||
Service string
|
||||
State State
|
||||
}
|
||||
|
||||
// CreateRequest 包含创建事务所需的不可变请求信息。
|
||||
type CreateRequest struct {
|
||||
ID string
|
||||
|
||||
@@ -335,6 +335,56 @@ func (s *Store) ActiveTransaction(ctx context.Context) (Transaction, error) {
|
||||
return getActiveTransaction(ctx, s.db)
|
||||
}
|
||||
|
||||
// ListRecent returns committed, rolled-back, failed, and in-progress transactions
|
||||
// in reverse creation order. Filters are exact values and never inferred.
|
||||
func (s *Store) ListRecent(ctx context.Context, filter ListFilter) ([]Transaction, error) {
|
||||
if filter.Limit <= 0 || filter.Limit > 1000 {
|
||||
return nil, errors.New("transaction history limit must be between 1 and 1000")
|
||||
}
|
||||
query := `SELECT id, idempotency_key, source, service, request_json, state, version, created_at, updated_at FROM transactions WHERE 1=1`
|
||||
args := make([]any, 0, 3)
|
||||
if filter.Service != "" {
|
||||
query += " AND service = ?"
|
||||
args = append(args, filter.Service)
|
||||
}
|
||||
if filter.State != "" {
|
||||
query += " AND state = ?"
|
||||
args = append(args, string(filter.State))
|
||||
}
|
||||
query += " ORDER BY created_at DESC, id DESC LIMIT ?"
|
||||
args = append(args, filter.Limit)
|
||||
rows, err := s.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list transaction history: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
result := make([]Transaction, 0, filter.Limit)
|
||||
for rows.Next() {
|
||||
var item Transaction
|
||||
var state string
|
||||
var requestJSON string
|
||||
var createdAt, updatedAt string
|
||||
if err := rows.Scan(&item.ID, &item.IdempotencyKey, &item.Source, &item.Service, &requestJSON, &state, &item.Version, &createdAt, &updatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan transaction history: %w", err)
|
||||
}
|
||||
item.Request = json.RawMessage(requestJSON)
|
||||
item.State = State(state)
|
||||
item.CreatedAt, err = time.Parse(time.RFC3339Nano, createdAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse transaction history creation time: %w", err)
|
||||
}
|
||||
item.UpdatedAt, err = time.Parse(time.RFC3339Nano, updatedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse transaction history update time: %w", err)
|
||||
}
|
||||
result = append(result, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate transaction history: %w", err)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Transition 校验并原子提交状态变化及其恢复事件。
|
||||
func (s *Store) Transition(ctx context.Context, id string, next State, message string) (Transaction, error) {
|
||||
if !next.Valid() {
|
||||
|
||||
@@ -105,6 +105,26 @@ func TestStoreMigratesVersionOneAndCommitsBackendContainerDeployment(t *testing.
|
||||
}
|
||||
}
|
||||
|
||||
func TestListRecentScansSQLiteRequestTextAsRawJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
store := openTestStore(t)
|
||||
record, _, err := store.CreateTransaction(ctx, CreateRequest{
|
||||
ID: "list-transaction", IdempotencyKey: "list-idempotency", Source: "test", Service: "backend",
|
||||
Request: json.RawMessage(`{"inputType":"native-jar"}`),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create list transaction: %v", err)
|
||||
}
|
||||
items, err := store.ListRecent(ctx, ListFilter{Limit: 20})
|
||||
if err != nil {
|
||||
t.Fatalf("list transactions: %v", err)
|
||||
}
|
||||
if len(items) != 1 || items[0].ID != record.ID || string(items[0].Request) != `{"inputType":"native-jar"}` {
|
||||
t.Fatalf("unexpected listed transaction: %+v", items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendContainerDeploymentCommitRequiresDrainingTransaction(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
|
||||
Reference in New Issue
Block a user