owncast/controllers/admin/update.go
Gabe Kangas 83eb9229ad
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
2021-11-30 13:15:18 -08:00

203 lines
5.3 KiB
Go

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
}