mirror of
https://github.com/Dvorinka/beszel.git
synced 2026-06-03 21:02:56 +00:00
feat(hub): implement native in-app container updates
Introduces the ability for registered users to trigger Beszel container updates directly from the web interface. - Added `app_update` logic to the hub to pull the latest image from GHCR and recreate the container. - Implemented `/api/beszel/update` and `/api/beszel/update/apply` endpoints. - Added a new `AppUpdatePanel` in the settings UI to check for and apply updates. - Added update notifications in the navbar and settings. - Updated `docker-compose.yml` and `README.md` to include the required Docker socket mount for update functionality. - Added a new public status page route that bypasses authentication. - Refactored several TypeScript interfaces to replace `any` with `unknown` or specific types for better type safety. - Updated localization files to support new update-related strings.
This commit is contained in:
+4
-44
@@ -1,17 +1,13 @@
|
||||
package hub
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/blang/semver"
|
||||
"github.com/google/uuid"
|
||||
"github.com/henrygd/beszel"
|
||||
"github.com/henrygd/beszel/internal/alerts"
|
||||
"github.com/henrygd/beszel/internal/ghupdate"
|
||||
"github.com/henrygd/beszel/internal/hub/config"
|
||||
"github.com/henrygd/beszel/internal/hub/systems"
|
||||
"github.com/henrygd/beszel/internal/hub/utils"
|
||||
@@ -20,13 +16,6 @@ import (
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
// UpdateInfo holds information about the latest update check
|
||||
type UpdateInfo struct {
|
||||
lastCheck time.Time
|
||||
Version string `json:"v"`
|
||||
Url string `json:"url"`
|
||||
}
|
||||
|
||||
var containerIDPattern = regexp.MustCompile(`^[a-fA-F0-9]{12,64}$`)
|
||||
|
||||
// Middleware to allow only admin role users
|
||||
@@ -104,11 +93,9 @@ func (h *Hub) registerApiRoutes(se *core.ServeEvent) error {
|
||||
// get public key and version
|
||||
apiAuth.GET("/info", h.getInfo)
|
||||
apiAuth.GET("/getkey", h.getInfo) // deprecated - keep for compatibility w/ integrations
|
||||
// check for updates
|
||||
if optIn, _ := utils.GetEnv("CHECK_UPDATES"); optIn == "true" {
|
||||
var updateInfo UpdateInfo
|
||||
apiAuth.GET("/update", updateInfo.getUpdate)
|
||||
}
|
||||
// check for and apply app image updates
|
||||
apiAuth.GET("/update", h.getUpdate)
|
||||
apiAuth.POST("/update/apply", h.applyUpdate)
|
||||
// send test notification
|
||||
apiAuth.POST("/test-notification", h.SendTestNotification)
|
||||
// heartbeat status and test
|
||||
@@ -148,34 +135,7 @@ func (h *Hub) getInfo(e *core.RequestEvent) error {
|
||||
Key: h.pubKey,
|
||||
Version: beszel.Version,
|
||||
}
|
||||
if optIn, _ := utils.GetEnv("CHECK_UPDATES"); optIn == "true" {
|
||||
info.CheckUpdate = true
|
||||
}
|
||||
return e.JSON(http.StatusOK, info)
|
||||
}
|
||||
|
||||
// getUpdate checks for the latest release on GitHub and returns update info if a newer version is available
|
||||
func (info *UpdateInfo) getUpdate(e *core.RequestEvent) error {
|
||||
if time.Since(info.lastCheck) < 6*time.Hour {
|
||||
return e.JSON(http.StatusOK, info)
|
||||
}
|
||||
info.lastCheck = time.Now()
|
||||
latestRelease, err := ghupdate.FetchLatestRelease(context.Background(), http.DefaultClient, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
currentVersion, err := semver.Parse(strings.TrimPrefix(beszel.Version, "v"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
latestVersion, err := semver.Parse(strings.TrimPrefix(latestRelease.Tag, "v"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if latestVersion.GT(currentVersion) {
|
||||
info.Version = strings.TrimPrefix(latestRelease.Tag, "v")
|
||||
info.Url = latestRelease.Url
|
||||
}
|
||||
info.CheckUpdate = true
|
||||
return e.JSON(http.StatusOK, info)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,567 @@
|
||||
package hub
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const (
|
||||
appUpdateImage = "ghcr.io/dvorinka/beszel:latest"
|
||||
appUpdateRepository = "dvorinka/beszel"
|
||||
dockerSocketPath = "/var/run/docker.sock"
|
||||
)
|
||||
|
||||
var updateContainerIDPattern = regexp.MustCompile(`[0-9a-f]{64}`)
|
||||
var appUpdateHTTPClient = &http.Client{Timeout: 20 * time.Second}
|
||||
|
||||
type updateCacheState struct {
|
||||
mu sync.Mutex
|
||||
checked time.Time
|
||||
info UpdateInfo
|
||||
}
|
||||
|
||||
var appUpdateCache updateCacheState
|
||||
|
||||
type updateApplyState struct {
|
||||
mu sync.Mutex
|
||||
running bool
|
||||
}
|
||||
|
||||
var appUpdateApply updateApplyState
|
||||
|
||||
// UpdateInfo holds information about the latest GHCR image check.
|
||||
type UpdateInfo struct {
|
||||
Version string `json:"v,omitempty"`
|
||||
Url string `json:"url,omitempty"`
|
||||
CurrentVersion string `json:"currentVersion"`
|
||||
Image string `json:"image"`
|
||||
CurrentImageID string `json:"currentImageId,omitempty"`
|
||||
CurrentDigest string `json:"currentDigest,omitempty"`
|
||||
LatestDigest string `json:"latestDigest,omitempty"`
|
||||
UpdateAvailable bool `json:"updateAvailable"`
|
||||
CanApply bool `json:"canApply"`
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
LastCheck string `json:"lastCheck"`
|
||||
}
|
||||
|
||||
type applyUpdateResponse struct {
|
||||
Started bool `json:"started"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type dockerAPI struct {
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
type dockerContainerInspect struct {
|
||||
ID string `json:"Id"`
|
||||
Name string `json:"Name"`
|
||||
Image string `json:"Image"`
|
||||
Config map[string]any `json:"Config"`
|
||||
HostConfig map[string]any `json:"HostConfig"`
|
||||
NetworkSettings dockerNetworkSettings `json:"NetworkSettings"`
|
||||
}
|
||||
|
||||
type dockerNetworkSettings struct {
|
||||
Networks map[string]map[string]any `json:"Networks"`
|
||||
}
|
||||
|
||||
type dockerImageInspect struct {
|
||||
ID string `json:"Id"`
|
||||
RepoDigests []string `json:"RepoDigests"`
|
||||
}
|
||||
|
||||
type dockerCreateResponse struct {
|
||||
ID string `json:"Id"`
|
||||
Warnings []string `json:"Warnings"`
|
||||
}
|
||||
|
||||
func (h *Hub) getUpdate(e *core.RequestEvent) error {
|
||||
info := getCachedUpdateInfo(false)
|
||||
return e.JSON(http.StatusOK, info)
|
||||
}
|
||||
|
||||
func (h *Hub) applyUpdate(e *core.RequestEvent) error {
|
||||
if !beginAppUpdate() {
|
||||
return e.BadRequestError("An app update is already running.", nil)
|
||||
}
|
||||
helperStarted := false
|
||||
defer func() {
|
||||
if !helperStarted {
|
||||
finishAppUpdate()
|
||||
}
|
||||
}()
|
||||
|
||||
info := getCachedUpdateInfo(true)
|
||||
if !info.CanApply {
|
||||
return e.BadRequestError(info.Message, nil)
|
||||
}
|
||||
if !info.UpdateAvailable {
|
||||
return e.BadRequestError("Beszel is already using the latest image digest.", nil)
|
||||
}
|
||||
|
||||
docker, err := newDockerAPI()
|
||||
if err != nil {
|
||||
return e.BadRequestError(err.Error(), nil)
|
||||
}
|
||||
container, err := docker.inspectContainer(currentContainerID(docker))
|
||||
if err != nil {
|
||||
return e.BadRequestError("Current Beszel container was not found through Docker.", err)
|
||||
}
|
||||
if err := docker.pullImage(appUpdateImage); err != nil {
|
||||
return e.InternalServerError("Failed to pull latest Beszel image.", err)
|
||||
}
|
||||
if err := docker.startUpdateHelper(container.ID, appUpdateImage); err != nil {
|
||||
return e.InternalServerError("Failed to start update helper.", err)
|
||||
}
|
||||
helperStarted = true
|
||||
time.AfterFunc(5*time.Minute, finishAppUpdate)
|
||||
|
||||
appUpdateCache.mu.Lock()
|
||||
appUpdateCache.info.Version = "latest"
|
||||
appUpdateCache.info.Status = "updating"
|
||||
appUpdateCache.info.Message = "Update helper started. Beszel will restart after the new container is ready."
|
||||
appUpdateCache.mu.Unlock()
|
||||
|
||||
return e.JSON(http.StatusOK, applyUpdateResponse{
|
||||
Started: true,
|
||||
Message: "Update helper started. Beszel will restart after the new container is ready.",
|
||||
})
|
||||
}
|
||||
|
||||
func getCachedUpdateInfo(force bool) UpdateInfo {
|
||||
appUpdateCache.mu.Lock()
|
||||
if !force && time.Since(appUpdateCache.checked) < 30*time.Minute && appUpdateCache.info.CurrentVersion != "" {
|
||||
info := appUpdateCache.info
|
||||
appUpdateCache.mu.Unlock()
|
||||
return info
|
||||
}
|
||||
appUpdateCache.mu.Unlock()
|
||||
|
||||
info := checkUpdateInfo()
|
||||
|
||||
appUpdateCache.mu.Lock()
|
||||
appUpdateCache.checked = time.Now()
|
||||
appUpdateCache.info = info
|
||||
appUpdateCache.mu.Unlock()
|
||||
|
||||
return info
|
||||
}
|
||||
|
||||
func checkUpdateInfo() UpdateInfo {
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
info := UpdateInfo{
|
||||
Url: "https://github.com/dvorinka/Beszel/pkgs/container/beszel",
|
||||
CurrentVersion: beszel.Version,
|
||||
Image: appUpdateImage,
|
||||
Status: "checking",
|
||||
LastCheck: now,
|
||||
}
|
||||
|
||||
latestDigest, err := fetchGHCRDigest(context.Background(), appUpdateHTTPClient, appUpdateRepository, "latest")
|
||||
if err != nil {
|
||||
info.Status = "check-failed"
|
||||
info.Message = "Could not read latest image digest from GHCR: " + err.Error()
|
||||
return info
|
||||
}
|
||||
info.LatestDigest = latestDigest
|
||||
|
||||
docker, err := newDockerAPI()
|
||||
if err != nil {
|
||||
info.Status = "docker-unavailable"
|
||||
info.Message = "Automatic updates need the Docker socket mounted at /var/run/docker.sock."
|
||||
return info
|
||||
}
|
||||
containerID := currentContainerID(docker)
|
||||
container, err := docker.inspectContainer(containerID)
|
||||
if err != nil {
|
||||
info.Status = "container-unavailable"
|
||||
info.Message = "Docker is available, but the running Beszel container could not be inspected."
|
||||
return info
|
||||
}
|
||||
info.CurrentImageID = container.Image
|
||||
info.CanApply = true
|
||||
|
||||
image, err := docker.inspectImage(container.Image)
|
||||
if err == nil {
|
||||
info.CurrentDigest = findRepoDigest(image.RepoDigests, appUpdateRepository)
|
||||
}
|
||||
|
||||
switch {
|
||||
case info.CurrentDigest == "":
|
||||
info.Version = "latest"
|
||||
info.Status = "unknown"
|
||||
info.UpdateAvailable = true
|
||||
info.Message = "Running image digest is unknown. Update can pull and recreate from latest."
|
||||
case digestValue(info.CurrentDigest) != digestValue(info.LatestDigest):
|
||||
info.Version = "latest"
|
||||
info.Status = "update-available"
|
||||
info.UpdateAvailable = true
|
||||
info.Message = "New Beszel image is available."
|
||||
default:
|
||||
info.Status = "up-to-date"
|
||||
info.Message = "Beszel is already using the latest image digest."
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
func beginAppUpdate() bool {
|
||||
appUpdateApply.mu.Lock()
|
||||
defer appUpdateApply.mu.Unlock()
|
||||
if appUpdateApply.running {
|
||||
return false
|
||||
}
|
||||
appUpdateApply.running = true
|
||||
return true
|
||||
}
|
||||
|
||||
func finishAppUpdate() {
|
||||
appUpdateApply.mu.Lock()
|
||||
appUpdateApply.running = false
|
||||
appUpdateApply.mu.Unlock()
|
||||
}
|
||||
|
||||
func fetchGHCRDigest(ctx context.Context, client *http.Client, repository, tag string) (string, error) {
|
||||
manifestURL := fmt.Sprintf("https://ghcr.io/v2/%s/manifests/%s", repository, tag)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, manifestURL, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Accept", strings.Join([]string{
|
||||
"application/vnd.oci.image.index.v1+json",
|
||||
"application/vnd.docker.distribution.manifest.list.v2+json",
|
||||
"application/vnd.oci.image.manifest.v1+json",
|
||||
"application/vnd.docker.distribution.manifest.v2+json",
|
||||
}, ", "))
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
token, err := fetchRegistryToken(ctx, client, resp.Header.Get("WWW-Authenticate"))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req, err = http.NewRequestWithContext(ctx, http.MethodGet, manifestURL, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Accept", "application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json")
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err = client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode >= 400 {
|
||||
return "", fmt.Errorf("registry returned %s: %s", resp.Status, strings.TrimSpace(string(raw)))
|
||||
}
|
||||
digest := resp.Header.Get("Docker-Content-Digest")
|
||||
if digest == "" {
|
||||
return "", errors.New("registry response did not include Docker-Content-Digest")
|
||||
}
|
||||
return digest, nil
|
||||
}
|
||||
|
||||
func fetchRegistryToken(ctx context.Context, client *http.Client, challenge string) (string, error) {
|
||||
params := parseBearerChallenge(challenge)
|
||||
realm := params["realm"]
|
||||
if realm == "" {
|
||||
return "", errors.New("registry auth challenge missing realm")
|
||||
}
|
||||
tokenURL, err := url.Parse(realm)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
query := tokenURL.Query()
|
||||
for _, key := range []string{"service", "scope"} {
|
||||
if params[key] != "" {
|
||||
query.Set(key, params[key])
|
||||
}
|
||||
}
|
||||
tokenURL.RawQuery = query.Encode()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, tokenURL.String(), nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
return "", fmt.Errorf("token service returned %s: %s", resp.Status, strings.TrimSpace(string(raw)))
|
||||
}
|
||||
var data struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &data); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if data.Token == "" {
|
||||
return "", errors.New("token service returned empty token")
|
||||
}
|
||||
return data.Token, nil
|
||||
}
|
||||
|
||||
func parseBearerChallenge(challenge string) map[string]string {
|
||||
out := make(map[string]string)
|
||||
challenge = strings.TrimSpace(strings.TrimPrefix(challenge, "Bearer"))
|
||||
for _, part := range strings.Split(challenge, ",") {
|
||||
key, value, ok := strings.Cut(strings.TrimSpace(part), "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
out[key] = strings.Trim(value, `"`)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func newDockerAPI() (*dockerAPI, error) {
|
||||
if _, err := os.Stat(dockerSocketPath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
transport := &http.Transport{
|
||||
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||
return (&net.Dialer{}).DialContext(ctx, "unix", dockerSocketPath)
|
||||
},
|
||||
}
|
||||
return &dockerAPI{
|
||||
client: &http.Client{
|
||||
Timeout: 10 * time.Minute,
|
||||
Transport: transport,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (d *dockerAPI) do(method, path string, body any, out any) error {
|
||||
var reader io.Reader
|
||||
if body != nil {
|
||||
raw, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reader = bytes.NewReader(raw)
|
||||
}
|
||||
req, err := http.NewRequest(method, "http://docker"+path, reader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := d.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
return fmt.Errorf("Docker API %s %s returned %s: %s", method, path, resp.Status, strings.TrimSpace(string(raw)))
|
||||
}
|
||||
if out != nil && len(raw) > 0 {
|
||||
return json.Unmarshal(raw, out)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *dockerAPI) inspectContainer(id string) (*dockerContainerInspect, error) {
|
||||
if strings.TrimSpace(id) == "" {
|
||||
return nil, errors.New("empty container id")
|
||||
}
|
||||
var inspect dockerContainerInspect
|
||||
err := d.do(http.MethodGet, "/containers/"+url.PathEscape(id)+"/json", nil, &inspect)
|
||||
return &inspect, err
|
||||
}
|
||||
|
||||
func (d *dockerAPI) inspectImage(id string) (*dockerImageInspect, error) {
|
||||
var inspect dockerImageInspect
|
||||
err := d.do(http.MethodGet, "/images/"+url.PathEscape(id)+"/json", nil, &inspect)
|
||||
return &inspect, err
|
||||
}
|
||||
|
||||
func (d *dockerAPI) pullImage(image string) error {
|
||||
name, tag, _ := strings.Cut(image, ":")
|
||||
if tag == "" {
|
||||
tag = "latest"
|
||||
}
|
||||
path := "/images/create?fromImage=" + url.QueryEscape(name) + "&tag=" + url.QueryEscape(tag)
|
||||
return d.do(http.MethodPost, path, nil, nil)
|
||||
}
|
||||
|
||||
func (d *dockerAPI) startUpdateHelper(targetID, image string) error {
|
||||
name := "beszel-update-" + shortID(targetID) + "-" + fmt.Sprint(time.Now().Unix())
|
||||
createBody := map[string]any{
|
||||
"Image": image,
|
||||
"Cmd": []string{
|
||||
"container-update-helper",
|
||||
"--target", targetID,
|
||||
"--image", image,
|
||||
},
|
||||
"HostConfig": map[string]any{
|
||||
"AutoRemove": true,
|
||||
"Binds": []string{dockerSocketPath + ":" + dockerSocketPath},
|
||||
},
|
||||
}
|
||||
var created dockerCreateResponse
|
||||
if err := d.do(http.MethodPost, "/containers/create?name="+url.QueryEscape(name), createBody, &created); err != nil {
|
||||
return err
|
||||
}
|
||||
return d.do(http.MethodPost, "/containers/"+url.PathEscape(created.ID)+"/start", nil, nil)
|
||||
}
|
||||
|
||||
func currentContainerID(d *dockerAPI) string {
|
||||
if hostname, err := os.Hostname(); err == nil && hostname != "" {
|
||||
if container, err := d.inspectContainer(hostname); err == nil {
|
||||
return container.ID
|
||||
}
|
||||
}
|
||||
raw, err := os.ReadFile("/proc/self/cgroup")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return updateContainerIDPattern.FindString(string(raw))
|
||||
}
|
||||
|
||||
func findRepoDigest(repoDigests []string, repository string) string {
|
||||
for _, digest := range repoDigests {
|
||||
if strings.Contains(digest, repository+"@sha256:") {
|
||||
return digest
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func digestValue(digest string) string {
|
||||
if _, value, ok := strings.Cut(digest, "@"); ok {
|
||||
return value
|
||||
}
|
||||
return digest
|
||||
}
|
||||
|
||||
func shortID(id string) string {
|
||||
if len(id) > 12 {
|
||||
return id[:12]
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// NewContainerUpdateHelperCmd creates a helper command that runs outside the current container.
|
||||
func NewContainerUpdateHelperCmd() *cobra.Command {
|
||||
var targetID string
|
||||
var image string
|
||||
cmd := &cobra.Command{
|
||||
Use: "container-update-helper",
|
||||
Short: "Replace the running Beszel container with a newer image",
|
||||
Hidden: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if targetID == "" || image == "" {
|
||||
return errors.New("target and image are required")
|
||||
}
|
||||
docker, err := newDockerAPI()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return docker.replaceContainer(targetID, image)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&targetID, "target", "", "target container id")
|
||||
cmd.Flags().StringVar(&image, "image", appUpdateImage, "replacement image")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func (d *dockerAPI) replaceContainer(targetID, image string) error {
|
||||
current, err := d.inspectContainer(targetID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
originalName := strings.TrimPrefix(current.Name, "/")
|
||||
if originalName == "" {
|
||||
originalName = "beszel"
|
||||
}
|
||||
stamp := fmt.Sprint(time.Now().Unix())
|
||||
oldName := originalName + "-old-" + stamp
|
||||
newName := originalName + "-new-" + stamp
|
||||
|
||||
config := cloneMap(current.Config)
|
||||
hostConfig := cloneMap(current.HostConfig)
|
||||
config["Image"] = image
|
||||
|
||||
delete(hostConfig, "AutoRemove")
|
||||
createBody := cloneMap(config)
|
||||
createBody["HostConfig"] = hostConfig
|
||||
createBody["NetworkingConfig"] = map[string]any{"EndpointsConfig": current.NetworkSettings.Networks}
|
||||
|
||||
var created dockerCreateResponse
|
||||
if err := d.do(http.MethodPost, "/containers/create?name="+url.QueryEscape(newName), createBody, &created); err != nil {
|
||||
return err
|
||||
}
|
||||
cleanupNew := true
|
||||
defer func() {
|
||||
if cleanupNew {
|
||||
_ = d.do(http.MethodDelete, "/containers/"+url.PathEscape(created.ID)+"?force=true", nil, nil)
|
||||
}
|
||||
}()
|
||||
|
||||
if err := d.do(http.MethodPost, "/containers/"+url.PathEscape(current.ID)+"/stop?t=10", nil, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := d.do(http.MethodPost, "/containers/"+url.PathEscape(current.ID)+"/rename?name="+url.QueryEscape(oldName), nil, nil); err != nil {
|
||||
_ = d.do(http.MethodPost, "/containers/"+url.PathEscape(current.ID)+"/start", nil, nil)
|
||||
return err
|
||||
}
|
||||
if err := d.do(http.MethodPost, "/containers/"+url.PathEscape(created.ID)+"/rename?name="+url.QueryEscape(originalName), nil, nil); err != nil {
|
||||
_ = d.do(http.MethodPost, "/containers/"+url.PathEscape(current.ID)+"/rename?name="+url.QueryEscape(originalName), nil, nil)
|
||||
_ = d.do(http.MethodPost, "/containers/"+url.PathEscape(current.ID)+"/start", nil, nil)
|
||||
return err
|
||||
}
|
||||
if err := d.do(http.MethodPost, "/containers/"+url.PathEscape(created.ID)+"/start", nil, nil); err != nil {
|
||||
_ = d.do(http.MethodPost, "/containers/"+url.PathEscape(created.ID)+"/rename?name="+url.QueryEscape(newName), nil, nil)
|
||||
_ = d.do(http.MethodPost, "/containers/"+url.PathEscape(current.ID)+"/rename?name="+url.QueryEscape(originalName), nil, nil)
|
||||
_ = d.do(http.MethodPost, "/containers/"+url.PathEscape(current.ID)+"/start", nil, nil)
|
||||
return err
|
||||
}
|
||||
|
||||
cleanupNew = false
|
||||
_ = d.do(http.MethodDelete, "/containers/"+url.PathEscape(current.ID)+"?force=true", nil, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func cloneMap(in map[string]any) map[string]any {
|
||||
out := make(map[string]any, len(in))
|
||||
for key, value := range in {
|
||||
out[key] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package hub
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseBearerChallenge(t *testing.T) {
|
||||
challenge := `Bearer realm="https://ghcr.io/token",service="ghcr.io",scope="repository:dvorinka/beszel:pull"`
|
||||
|
||||
params := parseBearerChallenge(challenge)
|
||||
|
||||
if params["realm"] != "https://ghcr.io/token" {
|
||||
t.Fatalf("realm = %q", params["realm"])
|
||||
}
|
||||
if params["service"] != "ghcr.io" {
|
||||
t.Fatalf("service = %q", params["service"])
|
||||
}
|
||||
if params["scope"] != "repository:dvorinka/beszel:pull" {
|
||||
t.Fatalf("scope = %q", params["scope"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindRepoDigest(t *testing.T) {
|
||||
digests := []string{
|
||||
"ghcr.io/other/image@sha256:111",
|
||||
"ghcr.io/dvorinka/beszel@sha256:222",
|
||||
}
|
||||
|
||||
got := findRepoDigest(digests, "dvorinka/beszel")
|
||||
if got != "ghcr.io/dvorinka/beszel@sha256:222" {
|
||||
t.Fatalf("digest = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDigestValue(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{name: "repo digest", in: "ghcr.io/dvorinka/beszel@sha256:abc", want: "sha256:abc"},
|
||||
{name: "plain digest", in: "sha256:def", want: "sha256:def"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := digestValue(tc.in); got != tc.want {
|
||||
t.Fatalf("digestValue(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user