From 83eb9229ad308ba47af00d5e3f174de85adfa274 Mon Sep 17 00:00:00 2001 From: Gabe Kangas Date: Tue, 30 Nov 2021 13:15:18 -0800 Subject: [PATCH] Auto updater APIs (#1523) * APIs for querying and executing an update in place. For #924 * Use the process pid to query systemd for status * Use parent pid and invocation ID to guess if running from systemd * Stream cmd output to client + report errors * Update comment to refer to INVOCATION_ID --- .vscode/settings.json | 3 +- build/release/build.sh | 2 +- config/config.go | 9 +- config/updaterConfig_enabled.go | 8 ++ controllers/admin/update.go | 203 ++++++++++++++++++++++++++++++++ router/router.go | 9 ++ 6 files changed, 229 insertions(+), 5 deletions(-) create mode 100644 config/updaterConfig_enabled.go create mode 100644 controllers/admin/update.go diff --git a/.vscode/settings.json b/.vscode/settings.json index 4d363539f..81eec90cf 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -10,6 +10,7 @@ "Mbps", "nolint", "Owncast", + "ppid", "preact", "RTMP", "rtmpserverport", @@ -20,4 +21,4 @@ "Warnf", "Warnln" ] -} \ No newline at end of file +} diff --git a/build/release/build.sh b/build/release/build.sh index a5ecc5883..d042f00c5 100755 --- a/build/release/build.sh +++ b/build/release/build.sh @@ -71,7 +71,7 @@ build() { pushd dist/${NAME} >> /dev/null - CGO_ENABLED=1 ~/go/bin/xgo --branch ${GIT_BRANCH} -ldflags "-s -w -X github.com/owncast/owncast/config.GitCommit=${GIT_COMMIT} -X github.com/owncast/owncast/config.BuildVersion=${VERSION} -X github.com/owncast/owncast/config.BuildPlatform=${NAME}" -targets "${OS}/${ARCH}" github.com/owncast/owncast + CGO_ENABLED=1 ~/go/bin/xgo --branch ${GIT_BRANCH} -ldflags "-s -w -X github.com/owncast/owncast/config.GitCommit=${GIT_COMMIT} -X github.com/owncast/owncast/config.BuildVersion=${VERSION} -X github.com/owncast/owncast/config.BuildPlatform=${NAME}" -tags enable_updates -targets "${OS}/${ARCH}" github.com/owncast/owncast mv owncast-*-${ARCH} owncast zip -r -q -8 ../owncast-$VERSION-$NAME.zip . diff --git a/config/config.go b/config/config.go index 7445bfe6c..df17cb0be 100644 --- a/config/config.go +++ b/config/config.go @@ -34,6 +34,9 @@ var GitCommit = "" // BuildPlatform is the optional platform this release was built for. var BuildPlatform = "dev" +// EnableAutoUpdate will explicitly enable in-place auto-updates via the admin. +var EnableAutoUpdate = false + // GetCommit will return an identifier used for identifying the point in time this build took place. func GetCommit() string { if GitCommit == "" { @@ -53,9 +56,9 @@ const MaxSocketPayloadSize = 2048 // GetReleaseString gets the version string. func GetReleaseString() string { - var versionNumber = VersionNumber - var buildPlatform = BuildPlatform - var gitCommit = GetCommit() + versionNumber := VersionNumber + buildPlatform := BuildPlatform + gitCommit := GetCommit() return fmt.Sprintf("Owncast v%s-%s (%s)", versionNumber, buildPlatform, gitCommit) } diff --git a/config/updaterConfig_enabled.go b/config/updaterConfig_enabled.go new file mode 100644 index 000000000..5ede95638 --- /dev/null +++ b/config/updaterConfig_enabled.go @@ -0,0 +1,8 @@ +//go:build enable_updates +// +build enable_updates + +package config + +func init() { + EnableAutoUpdate = true +} diff --git a/controllers/admin/update.go b/controllers/admin/update.go new file mode 100644 index 000000000..b42bf2c4c --- /dev/null +++ b/controllers/admin/update.go @@ -0,0 +1,203 @@ +package admin + +import ( + "fmt" + "io" + "io/ioutil" + "net/http" + "os" + "os/exec" + "strconv" + "strings" + + "github.com/owncast/owncast/config" + "github.com/owncast/owncast/controllers" + log "github.com/sirupsen/logrus" +) + +/* +The auto-update relies on some guesses and hacks to determine how the binary +is being run. + +It determines if is running under systemd by asking systemd about the PID +and checking the parent pid or INVOCATION_ID property is set for it. + +It also determines if the binary is running under a container by figuring out +the container ID as a fallback to refuse an in-place update within a container. + +In general it's disabled for everyone and the features are enabled only if +specific conditions are met. + +1. Cannot be run inside a container. +2. Cannot be run from source (aka platform is "dev") +3. Must be run under systemd to support auto-restart. +*/ + +// AutoUpdateOptions will return what auto update options are available. +func AutoUpdateOptions(w http.ResponseWriter, r *http.Request) { + type autoUpdateOptionsResponse struct { + SupportsUpdate bool `json:"supportsUpdate"` + CanRestart bool `json:"canRestart"` + } + + updateOptions := autoUpdateOptionsResponse{ + SupportsUpdate: false, + CanRestart: false, + } + + // Nothing is supported when running under "dev" or the feature is + // explicitly disabled. + if config.BuildPlatform == "dev" || !config.EnableAutoUpdate { + controllers.WriteResponse(w, updateOptions) + return + } + + // If we are not in a container then we can update in place. + if getContainerID() == "" { + updateOptions.SupportsUpdate = true + } + + updateOptions.CanRestart = isRunningUnderSystemD() + + controllers.WriteResponse(w, updateOptions) +} + +// AutoUpdateStart will begin the auto update process. +func AutoUpdateStart(w http.ResponseWriter, r *http.Request) { + // We return the console output directly to the client. + w.Header().Set("Content-Type", "text/plain") + + // Download the installer and save it to a temp file. + updater, err := downloadInstaller() + if err != nil { + log.Errorln(err) + controllers.WriteSimpleResponse(w, false, "failed to download and run installer") + return + } + + fw := flushWriter{w: w} + if f, ok := w.(http.Flusher); ok { + fw.f = f + } + + // Run the installer. + cmd := exec.Command("bash", updater) + cmd.Env = append(os.Environ(), "NO_COLOR=true") + cmd.Stdout = &fw + cmd.Stderr = &fw + + if err := cmd.Run(); err != nil { + log.Debugln(err) + if _, err := w.Write([]byte("Unable to complete update: " + err.Error())); err != nil { + log.Errorln(err) + } + return + } +} + +// AutoUpdateForceQuit will force quit the service. +func AutoUpdateForceQuit(w http.ResponseWriter, r *http.Request) { + log.Warnln("Owncast is exiting due to request.") + go func() { + os.Exit(0) + }() + controllers.WriteSimpleResponse(w, true, "forcing quit") +} + +func downloadInstaller() (string, error) { + installer := "https://owncast.online/install.sh" + out, err := os.CreateTemp(os.TempDir(), "updater.sh") + if err != nil { + log.Errorln(err) + return "", err + } + defer out.Close() + + // Get the installer script + resp, err := http.Get(installer) + if err != nil { + return "", err + } + defer resp.Body.Close() + + // Write the installer to file + _, err = io.Copy(out, resp.Body) + if err != nil { + return "", err + } + + return out.Name(), nil +} + +// Check to see if owncast is listed as a running service under systemd. +func isRunningUnderSystemD() bool { + // Our current PID + ppid := os.Getppid() + + // A randomized, unique 128-bit ID identifying each runtime cycle of the unit. + invocationID, hasInvocationID := os.LookupEnv("INVOCATION_ID") + + // systemd's pid should be 1, so if our process' parent pid is 1 + // then we are running under systemd. + return ppid == 1 || (hasInvocationID && invocationID != "") +} + +// Taken from https://stackoverflow.com/questions/23513045/how-to-check-if-a-process-is-running-inside-docker-container +func getContainerID() string { + pid := os.Getppid() + cgroupPath := fmt.Sprintf("/proc/%s/cgroup", strconv.Itoa(pid)) + containerID := "" + content, err := ioutil.ReadFile(cgroupPath) //nolint:gosec + if err != nil { + return containerID + } + lines := strings.Split(string(content), "\n") + for _, line := range lines { + field := strings.Split(line, ":") + if len(field) < 3 { + continue + } + cgroupPath := field[2] + if len(cgroupPath) < 64 { + continue + } + // Non-systemd Docker + // 5:net_prio,net_cls:/docker/de630f22746b9c06c412858f26ca286c6cdfed086d3b302998aa403d9dcedc42 + // 3:net_cls:/kubepods/burstable/pod5f399c1a-f9fc-11e8-bf65-246e9659ebfc/9170559b8aadd07d99978d9460cf8d1c71552f3c64fefc7e9906ab3fb7e18f69 + pos := strings.LastIndex(cgroupPath, "/") + if pos > 0 { + idLen := len(cgroupPath) - pos - 1 + if idLen == 64 { + // docker id + containerID = cgroupPath[pos+1 : pos+1+64] + return containerID + } + } + // systemd Docker + // 5:net_cls:/system.slice/docker-afd862d2ed48ef5dc0ce8f1863e4475894e331098c9a512789233ca9ca06fc62.scope + dockerStr := "docker-" + pos = strings.Index(cgroupPath, dockerStr) + if pos > 0 { + posScope := strings.Index(cgroupPath, ".scope") + idLen := posScope - pos - len(dockerStr) + if posScope > 0 && idLen == 64 { + containerID = cgroupPath[pos+len(dockerStr) : pos+len(dockerStr)+64] + return containerID + } + } + } + return containerID +} + +type flushWriter struct { + f http.Flusher + w io.Writer +} + +func (fw *flushWriter) Write(p []byte) (n int, err error) { + n, err = fw.w.Write(p) + if fw.f != nil { + fw.f.Flush() + } + return +} diff --git a/router/router.go b/router/router.go index 4612c728b..686104c42 100644 --- a/router/router.go +++ b/router/router.go @@ -163,6 +163,15 @@ func Start() error { // Create a single access token http.HandleFunc("/api/admin/accesstokens/create", middleware.RequireAdminAuth(admin.CreateExternalAPIUser)) + // Return the auto-update features that are supported for this instance. + http.HandleFunc("/api/admin/update/options", middleware.RequireAdminAuth(admin.AutoUpdateOptions)) + + // Begin the auto update + http.HandleFunc("/api/admin/update/start", middleware.RequireAdminAuth(admin.AutoUpdateStart)) + + // Force quit the service to restart it + http.HandleFunc("/api/admin/update/forcequit", middleware.RequireAdminAuth(admin.AutoUpdateForceQuit)) + // Send a system message to chat http.HandleFunc("/api/integrations/chat/system", middleware.RequireExternalAPIAccessToken(user.ScopeCanSendSystemMessages, admin.SendSystemMessage))