feat: establish secure update foundation
Container-Image veröffentlichen / container (push) v1.4.0 wurde manuell veröffentlicht und verifiziert
Container-Image veröffentlichen / container (push) v1.4.0 wurde manuell veröffentlicht und verifiziert
This commit is contained in:
1 parent
f02ec9977c
commit
bb60c06c0e
28 files changed
+1403
-280
No files matched your search
@@ -32,7 +32,11 @@ type State struct {
|
||||
CredentialConfigured bool
|
||||
UpdateAvailable bool
|
||||
AgentAvailable bool
|
||||
AgentCompatible bool
|
||||
AgentStatus updateprotocol.Status
|
||||
AgentVersion string
|
||||
AgentProtocol int
|
||||
ComposeSyncRequired bool
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
@@ -76,6 +80,7 @@ func New(database *store.Store, cipher *secure.Cipher, apiURL, agentSocket strin
|
||||
},
|
||||
},
|
||||
}
|
||||
settings = manager.reconcileInstalledVersion(context.Background(), settings)
|
||||
manager.setStoredState(settings)
|
||||
return manager, nil
|
||||
}
|
||||
@@ -192,6 +197,12 @@ func (m *Manager) RequestUpdate(ctx context.Context, version string) error {
|
||||
if comparison, err := compareVersions(state.CurrentVersion, version); err != nil || comparison >= 0 {
|
||||
return errors.New("die ausgewählte Version ist nicht neuer als die laufende Version")
|
||||
}
|
||||
if !state.AgentAvailable {
|
||||
return errors.New("Update-Agent ist nicht erreichbar")
|
||||
}
|
||||
if !state.AgentCompatible {
|
||||
return errors.New("Web-App und Update-Agent müssen zuerst gemeinsam mit Docker Compose aktualisiert werden")
|
||||
}
|
||||
settings, err := m.store.GetSoftwareUpdateSettings(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -203,8 +214,11 @@ func (m *Manager) RequestUpdate(ctx context.Context, version string) error {
|
||||
if err := m.store.SetDesiredSoftwareVersion(ctx, version); err != nil {
|
||||
return err
|
||||
}
|
||||
request := updateprotocol.Request{Version: version, RegistryUsername: settings.RegistryUsername, RegistryToken: token}
|
||||
request := updateprotocol.Request{Version: version, CurrentVersion: state.CurrentVersion, RegistryUsername: settings.RegistryUsername, RegistryToken: token}
|
||||
if err := m.agent.Start(ctx, request); err != nil {
|
||||
cleanupCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
m.clearDesiredVersion(cleanupCtx, version)
|
||||
cancel()
|
||||
return err
|
||||
}
|
||||
settings, err = m.store.GetSoftwareUpdateSettings(ctx)
|
||||
@@ -217,11 +231,26 @@ func (m *Manager) RequestUpdate(ctx context.Context, version string) error {
|
||||
func (m *Manager) UpdateStatus(ctx context.Context) (updateprotocol.Status, error) {
|
||||
status, err := m.agent.Status(ctx)
|
||||
m.stateMu.Lock()
|
||||
desiredVersion := m.state.DesiredVersion
|
||||
m.state.AgentAvailable = err == nil
|
||||
if err == nil {
|
||||
m.state.AgentStatus = status
|
||||
m.state.AgentVersion = status.AgentVersion
|
||||
m.state.AgentProtocol = status.Protocol
|
||||
m.state.AgentCompatible = agentCompatible(status)
|
||||
m.state.ComposeSyncRequired = !m.state.AgentCompatible
|
||||
} else {
|
||||
m.state.AgentCompatible = false
|
||||
m.state.ComposeSyncRequired = false
|
||||
}
|
||||
m.stateMu.Unlock()
|
||||
if err == nil && status.Phase == updateprotocol.PhaseFailed && status.TargetVersion != "" {
|
||||
m.clearDesiredVersion(ctx, status.TargetVersion)
|
||||
} else if err == nil && status.Phase == updateprotocol.PhaseIdle && desiredVersion != "" {
|
||||
// An agent restart intentionally resets its in-memory queue. Do not leave
|
||||
// an update marked as pending when no update can still be running.
|
||||
m.clearDesiredVersion(ctx, desiredVersion)
|
||||
}
|
||||
return status, err
|
||||
}
|
||||
|
||||
@@ -290,11 +319,55 @@ func (m *Manager) setStoredState(settings model.SoftwareUpdateSettings) {
|
||||
}
|
||||
m.stateMu.Lock()
|
||||
state.AgentAvailable = m.state.AgentAvailable
|
||||
state.AgentCompatible = m.state.AgentCompatible
|
||||
state.AgentStatus = m.state.AgentStatus
|
||||
state.AgentVersion = m.state.AgentVersion
|
||||
state.AgentProtocol = m.state.AgentProtocol
|
||||
state.ComposeSyncRequired = state.AgentAvailable && !state.AgentCompatible
|
||||
m.state = state
|
||||
m.stateMu.Unlock()
|
||||
}
|
||||
|
||||
func (m *Manager) reconcileInstalledVersion(ctx context.Context, settings model.SoftwareUpdateSettings) model.SoftwareUpdateSettings {
|
||||
if settings.DesiredVersion == "" {
|
||||
return settings
|
||||
}
|
||||
comparison, err := compareVersions(buildinfo.Version, settings.DesiredVersion)
|
||||
if err != nil || comparison < 0 {
|
||||
return settings
|
||||
}
|
||||
if err := m.store.ClearDesiredSoftwareVersion(ctx, settings.DesiredVersion); err != nil {
|
||||
m.logger.Warn("clear completed software update state", "error", err)
|
||||
return settings
|
||||
}
|
||||
settings.DesiredVersion = ""
|
||||
return settings
|
||||
}
|
||||
|
||||
func (m *Manager) clearDesiredVersion(ctx context.Context, expected string) {
|
||||
if expected == "" {
|
||||
return
|
||||
}
|
||||
if err := m.store.ClearDesiredSoftwareVersion(ctx, expected); err != nil {
|
||||
m.logger.Warn("clear stale software update state", "error", err)
|
||||
return
|
||||
}
|
||||
settings, err := m.store.GetSoftwareUpdateSettings(ctx)
|
||||
if err != nil {
|
||||
m.logger.Warn("refresh software update state", "error", err)
|
||||
return
|
||||
}
|
||||
m.setStoredState(settings)
|
||||
}
|
||||
|
||||
func agentCompatible(status updateprotocol.Status) bool {
|
||||
if status.Protocol < buildinfo.MinimumAgentProtocol || status.Protocol > buildinfo.UpdateProtocolVersion {
|
||||
return false
|
||||
}
|
||||
comparison, err := compareVersions(status.AgentVersion, buildinfo.MinimumAgentVersion)
|
||||
return err == nil && comparison >= 0
|
||||
}
|
||||
|
||||
func (m *Manager) refreshAgentStatus(ctx context.Context) {
|
||||
statusCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
@@ -13,8 +13,10 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/buildinfo"
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/secure"
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/store"
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/updateprotocol"
|
||||
)
|
||||
|
||||
func TestManagerKeepsUpdateReminderWhenGiteaBecomesUnavailable(t *testing.T) {
|
||||
@@ -31,7 +33,7 @@ func TestManagerKeepsUpdateReminderWhenGiteaBecomesUnavailable(t *testing.T) {
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"tag_name":"v1.3.1","html_url":"https://example.invalid/releases/v1.3.1","draft":false,"prerelease":false}`))
|
||||
_, _ = w.Write([]byte(`{"tag_name":"v1.4.1","html_url":"https://example.invalid/releases/v1.4.1","draft":false,"prerelease":false}`))
|
||||
}))
|
||||
defer api.Close()
|
||||
|
||||
@@ -64,7 +66,7 @@ func TestManagerKeepsUpdateReminderWhenGiteaBecomesUnavailable(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
state := manager.State()
|
||||
if !state.UpdateAvailable || state.LatestVersion != "v1.3.1" || state.LastError != "" {
|
||||
if !state.UpdateAvailable || state.LatestVersion != "v1.4.1" || state.LastError != "" {
|
||||
t.Fatalf("new release was not detected: %#v", state)
|
||||
}
|
||||
settings, err := database.GetSoftwareUpdateSettings(ctx)
|
||||
@@ -80,7 +82,71 @@ func TestManagerKeepsUpdateReminderWhenGiteaBecomesUnavailable(t *testing.T) {
|
||||
t.Fatal("unavailable Gitea endpoint was reported as successful")
|
||||
}
|
||||
state = manager.State()
|
||||
if !state.UpdateAvailable || state.LatestVersion != "v1.3.1" || state.LastError == "" {
|
||||
if !state.UpdateAvailable || state.LatestVersion != "v1.4.1" || state.LastError == "" {
|
||||
t.Fatalf("temporary outage removed the last known update reminder: %#v", state)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentCompatibilityStartsAtStableV14Protocol(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
status updateprotocol.Status
|
||||
want bool
|
||||
}{
|
||||
{name: "v1.4 baseline", status: updateprotocol.Status{AgentVersion: "v1.4.0", Protocol: 2}, want: true},
|
||||
{name: "newer compatible agent version", status: updateprotocol.Status{AgentVersion: "v2.0.0", Protocol: buildinfo.UpdateProtocolVersion}, want: true},
|
||||
{name: "legacy protocol", status: updateprotocol.Status{Protocol: 0}},
|
||||
{name: "old agent version", status: updateprotocol.Status{AgentVersion: "v1.3.0", Protocol: 2}},
|
||||
{name: "future unknown protocol", status: updateprotocol.Status{AgentVersion: "v2.0.0", Protocol: buildinfo.UpdateProtocolVersion + 1}},
|
||||
{name: "missing version", status: updateprotocol.Status{Protocol: 2}},
|
||||
}
|
||||
for _, test := range tests {
|
||||
test := test
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := agentCompatible(test.status); got != test.want {
|
||||
t.Fatalf("agentCompatible(%#v) = %v; want %v", test.status, got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerClearsCompletedDesiredVersionOnStartup(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
database, err := store.Open(ctx, filepath.Join(t.TempDir(), "app.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
if err := database.SetDesiredSoftwareVersion(ctx, buildinfo.Version); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keyFile := filepath.Join(t.TempDir(), "master-key")
|
||||
key := make([]byte, 32)
|
||||
for index := range key {
|
||||
key[index] = byte(index + 1)
|
||||
}
|
||||
if err := os.WriteFile(keyFile, []byte(base64.RawStdEncoding.EncodeToString(key)), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cipher, err := secure.LoadCipher(keyFile)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
manager, err := New(database, cipher, "https://git.example/api/releases/latest", filepath.Join(t.TempDir(), "agent.sock"), time.Hour, slog.New(slog.NewTextHandler(os.Stderr, nil)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if manager.State().DesiredVersion != "" {
|
||||
t.Fatalf("installed version remained pending: %#v", manager.State())
|
||||
}
|
||||
settings, err := database.GetSoftwareUpdateSettings(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if settings.DesiredVersion != "" {
|
||||
t.Fatalf("completed desired version remained persisted: %#v", settings)
|
||||
}
|
||||
}
|
||||
@@ -1,66 +1,15 @@
|
||||
package appupdate
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/updateprotocol"
|
||||
)
|
||||
|
||||
type semanticVersion struct {
|
||||
major int
|
||||
minor int
|
||||
patch int
|
||||
}
|
||||
|
||||
func parseVersion(raw string) (semanticVersion, error) {
|
||||
if !strings.HasPrefix(raw, "v") || strings.ContainsAny(raw, "+-") {
|
||||
return semanticVersion{}, fmt.Errorf("version must use vMAJOR.MINOR.PATCH")
|
||||
}
|
||||
parts := strings.Split(strings.TrimPrefix(raw, "v"), ".")
|
||||
if len(parts) != 3 {
|
||||
return semanticVersion{}, fmt.Errorf("version must use vMAJOR.MINOR.PATCH")
|
||||
}
|
||||
values := make([]int, 3)
|
||||
for index, part := range parts {
|
||||
if part == "" || (len(part) > 1 && part[0] == '0') {
|
||||
return semanticVersion{}, fmt.Errorf("version contains an invalid number")
|
||||
}
|
||||
value, err := strconv.Atoi(part)
|
||||
if err != nil || value < 0 || value > 1_000_000 {
|
||||
return semanticVersion{}, fmt.Errorf("version contains an invalid number")
|
||||
}
|
||||
values[index] = value
|
||||
}
|
||||
return semanticVersion{major: values[0], minor: values[1], patch: values[2]}, nil
|
||||
}
|
||||
import "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/updateprotocol"
|
||||
|
||||
func compareVersions(left, right string) (int, error) {
|
||||
a, err := parseVersion(left)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
b, err := parseVersion(right)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
av, bv := []int{a.major, a.minor, a.patch}, []int{b.major, b.minor, b.patch}
|
||||
for index := range av {
|
||||
if av[index] < bv[index] {
|
||||
return -1, nil
|
||||
}
|
||||
if av[index] > bv[index] {
|
||||
return 1, nil
|
||||
}
|
||||
}
|
||||
return 0, nil
|
||||
return updateprotocol.CompareVersions(left, right)
|
||||
}
|
||||
|
||||
func validReleaseVersion(version string) bool {
|
||||
if !updateprotocol.ValidVersion(version) {
|
||||
return false
|
||||
}
|
||||
_, err := parseVersion(version)
|
||||
_, err := updateprotocol.CompareVersions(version, version)
|
||||
return err == nil
|
||||
}
|
||||
@@ -10,6 +10,7 @@ func TestCompareVersionsUsesStrictStableSemanticVersions(t *testing.T) {
|
||||
want int
|
||||
}{
|
||||
{left: "v1.2.2", right: "v1.2.3", want: -1},
|
||||
{left: "v1.3.0", right: "v2.0.0", want: -1},
|
||||
{left: "v2.0.0", right: "v1.99.99", want: 1},
|
||||
{left: "v1.2.2", right: "v1.2.2", want: 0},
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user