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
This commit is contained in:
Gabe Kangas 2021-11-30 13:15:18 -08:00 committed by GitHub
parent 71abb3cfb5
commit 83eb9229ad
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 229 additions and 5 deletions

View File

@ -10,6 +10,7 @@
"Mbps", "Mbps",
"nolint", "nolint",
"Owncast", "Owncast",
"ppid",
"preact", "preact",
"RTMP", "RTMP",
"rtmpserverport", "rtmpserverport",
@ -20,4 +21,4 @@
"Warnf", "Warnf",
"Warnln" "Warnln"
] ]
} }

View File

@ -71,7 +71,7 @@ build() {
pushd dist/${NAME} >> /dev/null 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 mv owncast-*-${ARCH} owncast
zip -r -q -8 ../owncast-$VERSION-$NAME.zip . zip -r -q -8 ../owncast-$VERSION-$NAME.zip .

View File

@ -34,6 +34,9 @@ var GitCommit = ""
// BuildPlatform is the optional platform this release was built for. // BuildPlatform is the optional platform this release was built for.
var BuildPlatform = "dev" 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. // GetCommit will return an identifier used for identifying the point in time this build took place.
func GetCommit() string { func GetCommit() string {
if GitCommit == "" { if GitCommit == "" {
@ -53,9 +56,9 @@ const MaxSocketPayloadSize = 2048
// GetReleaseString gets the version string. // GetReleaseString gets the version string.
func GetReleaseString() string { func GetReleaseString() string {
var versionNumber = VersionNumber versionNumber := VersionNumber
var buildPlatform = BuildPlatform buildPlatform := BuildPlatform
var gitCommit = GetCommit() gitCommit := GetCommit()
return fmt.Sprintf("Owncast v%s-%s (%s)", versionNumber, buildPlatform, gitCommit) return fmt.Sprintf("Owncast v%s-%s (%s)", versionNumber, buildPlatform, gitCommit)
} }

View File

@ -0,0 +1,8 @@
//go:build enable_updates
// +build enable_updates
package config
func init() {
EnableAutoUpdate = true
}

203
controllers/admin/update.go Normal file
View File

@ -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
}

View File

@ -163,6 +163,15 @@ func Start() error {
// Create a single access token // Create a single access token
http.HandleFunc("/api/admin/accesstokens/create", middleware.RequireAdminAuth(admin.CreateExternalAPIUser)) 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 // Send a system message to chat
http.HandleFunc("/api/integrations/chat/system", middleware.RequireExternalAPIAccessToken(user.ScopeCanSendSystemMessages, admin.SendSystemMessage)) http.HandleFunc("/api/integrations/chat/system", middleware.RequireExternalAPIAccessToken(user.ScopeCanSendSystemMessages, admin.SendSystemMessage))