Files
soft_quay/core/updater/health.go
T

126 lines
3.6 KiB
Go
Raw Normal View History

2026-07-19 22:00:38 +08:00
package updater
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"time"
)
type healthRecord struct {
SchemaVersion int `json:"schema_version"`
RequestID string `json:"request_id"`
}
// FileHealthWaiter reads the fixed root health file without accepting a
// caller-provided locator.
type FileHealthWaiter struct {
PollInterval time.Duration
}
// WaitForHealth waits for a matching acknowledgement below target's root.
func (waiter FileHealthWaiter) WaitForHealth(ctx context.Context, targetDir, requestID string, timeout time.Duration) error {
layout, err := inspectTarget(targetDir)
if err != nil {
return err
}
if !validRequestID(requestID) {
return fmt.Errorf("%w: unsafe request ID", ErrInvalidRequest)
}
interval := waiter.PollInterval
if interval <= 0 {
interval = 250 * time.Millisecond
}
timer := time.NewTimer(timeout)
defer timer.Stop()
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
record, readErr := readHealth(layout.health)
if readErr == nil {
if record.RequestID != requestID {
return fmt.Errorf("%w: request ID does not match", ErrHealthInvalid)
}
return nil
}
if !os.IsNotExist(readErr) {
return readErr
}
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return ErrHealthTimeout
case <-ticker.C:
}
}
}
// AcknowledgeHealthFromExecutable writes the acknowledgement only when the
// running executable is exactly <root>/app/SoftBox.exe.
func AcknowledgeHealthFromExecutable(executablePath, requestID string, syncer DirectorySyncer) error {
if syncer == nil {
return fmt.Errorf("%w: directory syncer is required", ErrInvalidRequest)
}
if !validRequestID(requestID) {
return fmt.Errorf("%w: unsafe request ID", ErrInvalidRequest)
}
if !filepath.IsAbs(executablePath) || filepath.Base(filepath.Clean(executablePath)) != ProductExecutableName {
return fmt.Errorf("%w: executable is not SoftBox.exe", ErrUnsafeLayout)
}
appDir := filepath.Dir(filepath.Clean(executablePath))
if filepath.Base(appDir) != appDirectoryName {
return fmt.Errorf("%w: executable is outside root/app", ErrUnsafeLayout)
}
layout, err := inspectTarget(appDir)
if err != nil {
return err
}
if err := requireRealDirectory(layout.target, "target"); err != nil {
return err
}
if err := validateRegularFile(executablePath); err != nil {
return fmt.Errorf("%w: %v", ErrUnsafeLayout, err)
}
transaction, exists, err := loadTransaction(layout)
if err != nil {
return err
}
if !exists || transaction.RequestID != requestID ||
(transaction.Phase != phaseStagingActivated && transaction.Phase != phaseLaunched) {
return fmt.Errorf("%w: no matching activated transaction", ErrHealthInvalid)
}
record := healthRecord{SchemaVersion: transactionSchemaVersion, RequestID: requestID}
data, err := json.Marshal(record)
if err != nil {
return err
}
data = append(data, '\n')
return replaceRegularFile(layout.root, layout.health, ".self-update-health-*.tmp", data, syncer)
}
func readHealth(path string) (healthRecord, error) {
data, err := readRegularFile(path)
if err != nil {
return healthRecord{}, err
}
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
var record healthRecord
if err := decoder.Decode(&record); err != nil {
return healthRecord{}, fmt.Errorf("%w: %v", ErrHealthInvalid, err)
}
var extra interface{}
if err := decoder.Decode(&extra); err != io.EOF {
return healthRecord{}, ErrHealthInvalid
}
if record.SchemaVersion != transactionSchemaVersion || !validRequestID(record.RequestID) {
return healthRecord{}, ErrHealthInvalid
}
return record, nil
}