kvm/internal/ota/ota.go

212 lines
5.6 KiB
Go

package ota
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"time"
"github.com/Masterminds/semver/v3"
)
// UpdateReleaseAPIEndpoint updates the release API endpoint
func (s *State) UpdateReleaseAPIEndpoint(endpoint string) {
s.releaseAPIEndpoint = endpoint
}
// GetReleaseAPIEndpoint returns the release API endpoint
func (s *State) GetReleaseAPIEndpoint() string {
return s.releaseAPIEndpoint
}
func (s *State) fetchUpdateMetadata(ctx context.Context, deviceID string, includePreRelease bool) (*UpdateMetadata, error) {
metadata := &UpdateMetadata{}
updateURL, err := url.Parse(s.releaseAPIEndpoint)
if err != nil {
return nil, fmt.Errorf("error parsing update metadata URL: %w", err)
}
query := updateURL.Query()
query.Set("deviceId", deviceID)
query.Set("prerelease", fmt.Sprintf("%v", includePreRelease))
updateURL.RawQuery = query.Encode()
logger.Info().Str("url", updateURL.String()).Msg("Checking for updates")
req, err := http.NewRequestWithContext(ctx, "GET", updateURL.String(), nil)
if err != nil {
return nil, fmt.Errorf("error creating request: %w", err)
}
client := s.client()
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("error sending request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
err = json.NewDecoder(resp.Body).Decode(metadata)
if err != nil {
return nil, fmt.Errorf("error decoding response: %w", err)
}
return metadata, nil
}
func (s *State) TryUpdate(ctx context.Context, deviceID string, includePreRelease bool) error {
scopedLogger := s.l.With().
Str("deviceID", deviceID).
Str("includePreRelease", fmt.Sprintf("%v", includePreRelease)).
Logger()
scopedLogger.Info().Msg("Trying to update...")
if s.updating {
return fmt.Errorf("update already in progress")
}
s.updating = true
s.onProgressUpdate()
defer func() {
s.updating = false
s.onProgressUpdate()
}()
appUpdate, systemUpdate, err := s.getUpdateStatus(ctx, deviceID, includePreRelease)
if err != nil {
return s.componentUpdateError("Error checking for updates", err, &scopedLogger)
}
s.metadataFetchedAt = time.Now()
s.onProgressUpdate()
if appUpdate.available {
appUpdate.pending = true
}
if systemUpdate.available {
systemUpdate.pending = true
}
if appUpdate.pending {
scopedLogger.Info().
Str("url", appUpdate.url).
Str("hash", appUpdate.hash).
Msg("App update available")
if err := s.updateApp(ctx, appUpdate); err != nil {
return s.componentUpdateError("Error updating app", err, &scopedLogger)
}
} else {
scopedLogger.Info().Msg("App is up to date")
}
if systemUpdate.pending {
if err := s.updateSystem(ctx, systemUpdate); err != nil {
return s.componentUpdateError("Error updating system", err, &scopedLogger)
}
} else {
scopedLogger.Info().Msg("System is up to date")
}
if s.rebootNeeded {
scopedLogger.Info().Msg("System Rebooting due to OTA update")
postRebootAction := &PostRebootAction{
HealthCheck: "/device/status",
RedirectUrl: fmt.Sprintf("/settings/general/update?version=%s", systemUpdate.version),
}
if err := s.reboot(true, postRebootAction, 10*time.Second); err != nil {
return s.componentUpdateError("Error requesting reboot", err, &scopedLogger)
}
}
return nil
}
func (s *State) getUpdateStatus(
ctx context.Context,
deviceID string,
includePreRelease bool,
) (
appUpdate *componentUpdateStatus,
systemUpdate *componentUpdateStatus,
err error,
) {
appUpdate = &componentUpdateStatus{}
systemUpdate = &componentUpdateStatus{}
err = nil
// Get local versions
systemVersionLocal, appVersionLocal, err := s.getLocalVersion()
if err != nil {
return nil, nil, fmt.Errorf("error getting local version: %w", err)
}
appUpdate.localVersion = appVersionLocal.String()
systemUpdate.localVersion = systemVersionLocal.String()
// Get remote metadata
remoteMetadata, err := s.fetchUpdateMetadata(ctx, deviceID, includePreRelease)
if err != nil {
err = fmt.Errorf("error checking for updates: %w", err)
return
}
appUpdate.url = remoteMetadata.AppURL
appUpdate.hash = remoteMetadata.AppHash
appUpdate.version = remoteMetadata.AppVersion
systemUpdate.url = remoteMetadata.SystemURL
systemUpdate.hash = remoteMetadata.SystemHash
systemUpdate.version = remoteMetadata.SystemVersion
// Get remote versions
systemVersionRemote, err := semver.NewVersion(remoteMetadata.SystemVersion)
if err != nil {
err = fmt.Errorf("error parsing remote system version: %w", err)
return
}
systemUpdate.available = systemVersionRemote.GreaterThan(systemVersionLocal)
appVersionRemote, err := semver.NewVersion(remoteMetadata.AppVersion)
if err != nil {
err = fmt.Errorf("error parsing remote app version: %w, %s", err, remoteMetadata.AppVersion)
return
}
appUpdate.available = appVersionRemote.GreaterThan(appVersionLocal)
// Handle pre-release updates
isRemoteSystemPreRelease := systemVersionRemote.Prerelease() != ""
isRemoteAppPreRelease := appVersionRemote.Prerelease() != ""
if isRemoteSystemPreRelease && !includePreRelease {
systemUpdate.available = false
}
if isRemoteAppPreRelease && !includePreRelease {
appUpdate.available = false
}
s.componentUpdateStatuses["app"] = *appUpdate
s.componentUpdateStatuses["system"] = *systemUpdate
return
}
// GetUpdateStatus returns the current update status (for backwards compatibility)
func (s *State) GetUpdateStatus(ctx context.Context, deviceID string, includePreRelease bool) (*UpdateStatus, error) {
_, _, err := s.getUpdateStatus(ctx, deviceID, includePreRelease)
if err != nil {
return nil, fmt.Errorf("error getting update status: %w", err)
}
return s.ToUpdateStatus(), nil
}