package control import ( "bytes" "context" "encoding/json" "fmt" "io" "log" "net" "net/http" "os" "path/filepath" "strings" "time" ) type upgradePayload struct { UpgradeID string `json:"upgradeId"` URL string `json:"url"` } func (c *Client) doUpgrade(payload json.RawMessage) { var p upgradePayload if err := json.Unmarshal(payload, &p); err != nil || p.URL == "" || p.UpgradeID == "" { c.sendUpgradeAck("", false, "ongeldig upgrade-verzoek") return } if err := c.runUpgrade(p.URL); err != nil { log.Printf("UPGRADE failed: %v", err) c.sendUpgradeAck(p.UpgradeID, false, err.Error()) return } log.Printf("UPGRADE ok, herstarten (upgradeId=%s)", p.UpgradeID) c.sendUpgradeAck(p.UpgradeID, true, "") time.Sleep(500 * time.Millisecond) c.doRestart() } func (c *Client) sendUpgradeAck(upgradeID string, ok bool, errMsg string) { ack := controlMessage("UPGRADE_ACK", map[string]interface{}{ "upgradeId": upgradeID, "ok": ok, "version": c.version, "error": errMsg, }) if err := c.write(ack); err != nil { log.Printf("failed to send UPGRADE_ACK: %v", err) } } func (c *Client) runUpgrade(downloadURL string) error { dataDir := strings.TrimSpace(os.Getenv("MEDIA_NODE_DATA")) if dataDir == "" { dataDir = "/var/lib/media-node" } stagingDir := filepath.Join(dataDir, "upgrade") stagingFile := filepath.Join(stagingDir, "media-node") if err := os.MkdirAll(stagingDir, 0o755); err != nil { return fmt.Errorf("staging map: %w", err) } if err := downloadFile(downloadURL, stagingFile); err != nil { return err } if err := os.Chmod(stagingFile, 0o755); err != nil { return fmt.Errorf("chmod staging: %w", err) } if _, err := os.Stat("/var/run/docker.sock"); err != nil { return fmt.Errorf("docker.sock niet beschikbaar — gebruik handmatig upgrade-script") } containerName := strings.TrimSpace(os.Getenv("MEDIA_NODE_CONTAINER_NAME")) if containerName == "" { containerName = "media-node" } binHostDir, dataHostDir, err := dockerContainerBindDirs(containerName) if err != nil { return err } stagingHost := filepath.Join(dataHostDir, "upgrade", "media-node") shellCmd := "cp /staging/media-node /target/media-node && chmod +x /target/media-node" binds := []string{ binHostDir + ":/target:rw", filepath.Dir(stagingHost) + ":/staging:ro", } if err := dockerRunOnce("alpine:3.20", shellCmd, binds); err != nil { return fmt.Errorf("binary installeren: %w", err) } return nil } func downloadFile(url, dest string) error { log.Printf("UPGRADE: download %s", url) tmp := dest + ".download" client := &http.Client{Timeout: 5 * time.Minute} res, err := client.Get(url) if err != nil { return fmt.Errorf("download: %w", err) } defer res.Body.Close() if res.StatusCode >= 300 { return fmt.Errorf("download HTTP %d", res.StatusCode) } out, err := os.Create(tmp) if err != nil { return fmt.Errorf("schrijven staging: %w", err) } if _, err := io.Copy(out, res.Body); err != nil { out.Close() _ = os.Remove(tmp) return fmt.Errorf("download schrijven: %w", err) } if err := out.Close(); err != nil { _ = os.Remove(tmp) return err } if err := os.Rename(tmp, dest); err != nil { _ = os.Remove(tmp) return fmt.Errorf("staging afronden: %w", err) } return nil } type dockerMount struct { Source string `json:"Source"` Destination string `json:"Destination"` } type dockerInspect struct { Mounts []dockerMount `json:"Mounts"` } func dockerContainerBindDirs(containerName string) (binHostDir, dataHostDir string, err error) { body, err := dockerAPIRequest(http.MethodGet, "/containers/"+containerName+"/json", nil) if err != nil { return "", "", fmt.Errorf("container inspect: %w", err) } var info dockerInspect if err := json.Unmarshal(body, &info); err != nil { return "", "", fmt.Errorf("inspect parse: %w", err) } for _, m := range info.Mounts { switch m.Destination { case "/usr/local/bin/media-node": binHostDir = filepath.Dir(m.Source) case "/var/lib/media-node": dataHostDir = m.Source } } if binHostDir == "" { return "", "", fmt.Errorf("binary-mount niet gevonden op container %s", containerName) } if dataHostDir == "" { return "", "", fmt.Errorf("data-mount niet gevonden op container %s", containerName) } return binHostDir, dataHostDir, nil } func dockerRunOnce(image, shellCmd string, binds []string) error { createBody := map[string]interface{}{ "Image": image, "Cmd": []string{"sh", "-c", shellCmd}, "HostConfig": map[string]interface{}{ "Binds": binds, "AutoRemove": true, }, } raw, err := json.Marshal(createBody) if err != nil { return err } created, err := dockerAPIRequest(http.MethodPost, "/containers/create", raw) if err != nil { return fmt.Errorf("container create: %w", err) } var createdResp struct { ID string `json:"Id"` } if err := json.Unmarshal(created, &createdResp); err != nil || createdResp.ID == "" { return fmt.Errorf("container create parse: %w", err) } if _, err := dockerAPIRequest(http.MethodPost, "/containers/"+createdResp.ID+"/start", nil); err != nil { return fmt.Errorf("container start: %w", err) } waitCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) defer cancel() for { select { case <-waitCtx.Done(): return fmt.Errorf("container wait timeout") default: } body, err := dockerAPIRequest(http.MethodGet, "/containers/"+createdResp.ID+"/json", nil) if err != nil { return err } var state struct { State struct { Status string `json:"Status"` ExitCode int `json:"ExitCode"` Running bool `json:"Running"` FinishedAt string `json:"FinishedAt"` } `json:"State"` } if err := json.Unmarshal(body, &state); err != nil { return err } if state.State.Running { time.Sleep(200 * time.Millisecond) continue } if state.State.ExitCode != 0 { return fmt.Errorf("install-container exit %d", state.State.ExitCode) } return nil } } func dockerAPIRequest(method, path string, body []byte) ([]byte, error) { httpc := http.Client{ Transport: &http.Transport{ DialContext: func(_ context.Context, _, _ string) (net.Conn, error) { return net.Dial("unix", "/var/run/docker.sock") }, }, Timeout: 2 * time.Minute, } var reqBody io.Reader if body != nil { reqBody = bytes.NewReader(body) } req, err := http.NewRequest(method, "http://localhost"+path, reqBody) if err != nil { return nil, err } if body != nil { req.Header.Set("Content-Type", "application/json") } res, err := httpc.Do(req) if err != nil { return nil, err } defer res.Body.Close() out, err := io.ReadAll(res.Body) if err != nil { return nil, err } if res.StatusCode >= 300 { return nil, fmt.Errorf("docker API %s %s: %d %s", method, path, res.StatusCode, strings.TrimSpace(string(out))) } return out, nil }