126 lines
3.6 KiB
Go
126 lines
3.6 KiB
Go
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
|
|
}
|