blob: ff0d673664ed955e91a0fb7ab2ecaf3cb744d00a [file] [log] [blame]
package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io/fs"
"net/http"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"text/template"
"time"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v4/pgxpool"
"github.com/unrolled/secure"
"go.skia.org/infra/go/alogin"
"go.skia.org/infra/go/alogin/proxylogin"
"go.skia.org/infra/go/auditlog"
"go.skia.org/infra/go/baseapp"
"go.skia.org/infra/go/common"
"go.skia.org/infra/go/sql/pool/wrapper/timeout"
"go.skia.org/infra/go/httputils"
"go.skia.org/infra/go/metrics2"
"go.skia.org/infra/go/now"
"go.skia.org/infra/go/roles"
"go.skia.org/infra/go/skerr"
"go.skia.org/infra/go/sklog"
"go.skia.org/infra/machine/go/configs"
"go.skia.org/infra/machine/go/machine"
changeSink "go.skia.org/infra/machine/go/machine/change/sink"
sseChangeSink "go.skia.org/infra/machine/go/machine/change/sink/sse"
httpEventSource "go.skia.org/infra/machine/go/machine/event/source/httpsource"
"go.skia.org/infra/machine/go/machine/pools"
machineProcessor "go.skia.org/infra/machine/go/machine/processor"
machineStore "go.skia.org/infra/machine/go/machine/store"
"go.skia.org/infra/machine/go/machine/store/cdb"
"go.skia.org/infra/machine/go/machineserver/config"
"go.skia.org/infra/machine/go/machineserver/rpc"
)
// The default timeout to use on a context when talking to the database.
const defaultSQLTimeout = time.Minute
var errFailedToGetID = errors.New("failed to get id from URL")
type flags struct {
configFlag string
changeEventSSERPeerPort int
namespace string
labelSelector string
local bool
port string
promPort string
resourcesDir string
}
func (f *flags) Register(fs *flag.FlagSet) {
fs.StringVar(&f.configFlag, "config", "test.json", "The name to the configuration file, such as prod.json or test.json, as found in machine/go/configs.")
fs.IntVar(&f.changeEventSSERPeerPort, "change_event_sser_peer_port", 4000, "The port used to communicate among peers messages that need to be sent over SSE.")
fs.StringVar(&f.namespace, "namespace", "default", "The namespace this application is running under in k8s.")
fs.StringVar(&f.labelSelector, "label_selector", "app=machineserver", "A label selector that finds all peer pods of this application in k8s.")
fs.BoolVar(&f.local, "local", false, "Running locally if true. As opposed to in production.")
fs.StringVar(&f.port, "port", ":8000", "HTTP service address (e.g., ':8000')")
fs.StringVar(&f.promPort, "prom_port", ":20000", "Metrics service address (e.g., ':10110')")
fs.StringVar(&f.resourcesDir, "resources_dir", "", "The directory to find templates, JS, and CSS files. If blank the current directory will be used.")
}
type server struct {
flags *flags
store machineStore.Store
templates *template.Template
loadTemplatesOnce sync.Once
httpEventSource *httpEventSource.HTTPSource
// Change Sinks.
sserChangeSink changeSink.Sink
// Event Sources.
httpSourceCh <-chan machine.Event
sserServer sseChangeSink.SSE
processor machineProcessor.Processor
login alogin.Login
}
func new(args []string) (*server, error) {
ctx := context.Background()
// Register and parse flags.
flags := &flags{}
flagSet := flag.NewFlagSet("machineserver", flag.ExitOnError)
flags.Register(flagSet)
common.InitWithMust(
"machineserver",
common.PrometheusOpt(&flags.promPort),
common.FlagSetOpt(flagSet),
)
var instanceConfig config.InstanceConfig
b, err := fs.ReadFile(configs.Configs, flags.configFlag)
if err != nil {
sklog.Fatalf("read config file %q: %s", flags.configFlag, err)
}
err = json.Unmarshal(b, &instanceConfig)
if err != nil {
sklog.Fatal(err)
}
processor := machineProcessor.New(ctx)
if instanceConfig.ConnectionString == "" {
sklog.Fatal("ConnectionString must be supplied in the instance config")
}
pools, err := pools.New(instanceConfig)
if err != nil {
return nil, skerr.Wrap(err)
}
unwrappedPool, err := pgxpool.Connect(ctx, instanceConfig.ConnectionString)
if err != nil {
return nil, skerr.Wrap(err)
}
db := timeout.New(unwrappedPool)
store, err := cdb.New(ctx, db, pools)
if err != nil {
return nil, skerr.Wrap(err)
}
httpSource, err := httpEventSource.New()
if err != nil {
return nil, skerr.Wrap(err)
}
httpSourceCh, err := httpSource.Start(ctx)
if err != nil {
return nil, skerr.Wrap(err)
}
sserChangeSink, err := sseChangeSink.New(ctx, flags.local, flags.namespace, flags.labelSelector, flags.changeEventSSERPeerPort)
if err != nil {
return nil, skerr.Wrapf(err, "create sser Server")
}
s := &server{
flags: flags,
store: store,
sserChangeSink: sserChangeSink,
login: proxylogin.NewWithDefaults(),
httpEventSource: httpSource,
sserServer: *sserChangeSink,
processor: processor,
httpSourceCh: httpSourceCh,
}
s.loadTemplates()
go s.listenMachineEvents(ctx)
return s, nil
}
// Starts listening for the arrival of machine.Events. This function doesn't
// return unless the context is cancelled.
func (s *server) listenMachineEvents(ctx context.Context) {
storeUpdateFail := metrics2.GetCounter("machineserver_store_update_fail")
sklog.Infof("Start machine.Event listening loop")
for {
select {
case event := <-s.httpSourceCh:
processEventArrival(ctx, s.store, storeUpdateFail, s.processor, event)
case <-ctx.Done():
return
}
}
}
func processEventArrival(ctx context.Context, store machineStore.Store, storeUpdateFail metrics2.Counter, processor machineProcessor.Processor, event machine.Event) {
timeoutCtx, cancel := context.WithTimeout(ctx, defaultSQLTimeout)
defer cancel()
err := store.Update(timeoutCtx, event.Host.Name, func(previous machine.Description) machine.Description {
return processor.Process(timeoutCtx, previous, event)
})
if err != nil {
storeUpdateFail.Inc(1)
sklog.Errorf("Failed to update: %s", err)
}
}
func (s *server) audit(w http.ResponseWriter, r *http.Request, action string, body interface{}) {
auditlog.LogWithUser(r, s.login.LoggedInAs(r).String(), action, body)
}
func (s *server) loadTemplatesImpl() {
s.templates = template.Must(template.New("").Delims("{%", "%}").ParseGlob(
filepath.Join(s.flags.resourcesDir, "*.html"),
))
}
func (s *server) loadTemplates() {
if s.flags.local {
s.loadTemplatesImpl()
}
s.loadTemplatesOnce.Do(s.loadTemplatesImpl)
}
// sendJSONResponse sends a JSON representation of any data structure as an
// HTTP response. If the conversion to JSON has an error, the error is logged.
func sendJSONResponse(data interface{}, w http.ResponseWriter) {
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(data); err != nil {
sklog.Errorf("Failed to write response: %s", err)
}
}
// getID retrieves the value of {id:.+} from URLs. It reports an error on the
// ResponseWrite if none is found.
func getID(w http.ResponseWriter, r *http.Request) (string, error) {
id := strings.TrimSpace(chi.URLParam(r, "id"))
if id == "" {
http.Error(w, "Machine ID must be supplied.", http.StatusBadRequest)
return "", errFailedToGetID
}
return id, nil
}
// sendHTMLResponse renders the given template, passing it the current
// context's CSP nonce. If template rendering fails, it logs an error.
func (s *server) sendHTMLResponse(templateName string, w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
s.loadTemplates() // just to support template changes during dev
if err := s.templates.ExecuteTemplate(w, templateName, map[string]string{
// Look in //machine/pages/BUILD.bazel for where the nonce templates are injected.
"Nonce": secure.CSPNonce(r.Context()),
}); err != nil {
sklog.Errorf("Failed to expand template: %s", err)
}
}
func (s *server) machinesPageHandler(w http.ResponseWriter, r *http.Request) {
s.sendHTMLResponse("index.html", w, r)
}
func (s *server) machinesHandler(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), defaultSQLTimeout)
defer cancel()
descriptions, err := s.store.List(ctx)
if err != nil {
httputils.ReportError(w, err, "Failed to read from datastore", http.StatusInternalServerError)
return
}
sendJSONResponse(descriptions, w)
}
func (s *server) triggerDescriptionUpdateEvent(ctx context.Context, id string) {
if err := s.sserChangeSink.Send(ctx, id); err != nil {
sklog.Errorf("Failed to trigger SSE change event: %s", err)
}
}
// toggleMode is used in machineToggleModeHandler and passed to s.store.Update
// to toggle the Description mode between Available and Maintenance.
func toggleMode(ctx context.Context, user string, in machine.Description) machine.Description {
ret := in.Copy()
ts := now.Now(ctx)
var annotation string
if !ret.InMaintenanceMode() {
ret.MaintenanceMode = fmt.Sprintf("%s %s", user, ts.Format(time.RFC3339))
annotation = "Enabled Maintenance Mode"
} else {
ret.MaintenanceMode = ""
annotation = "Cleared Maintenance Mode."
}
ret.Annotation = machine.Annotation{
User: user,
Message: annotation,
Timestamp: now.Now(ctx),
}
machine.SetSwarmingQuarantinedMessage(&ret)
return ret
}
func (s *server) machineToggleModeHandler(w http.ResponseWriter, r *http.Request) {
id, err := getID(w, r)
if err != nil {
return
}
s.audit(w, r, "toggle-mode", id)
ctx, cancel := context.WithTimeout(r.Context(), defaultSQLTimeout)
defer cancel()
err = s.store.Update(ctx, id, func(in machine.Description) machine.Description {
ret := toggleMode(ctx, string(s.login.LoggedInAs(r)), in)
return ret
})
if err != nil {
httputils.ReportError(w, err, "Failed to update machine.", http.StatusInternalServerError)
return
}
s.triggerDescriptionUpdateEvent(ctx, id)
w.WriteHeader(http.StatusOK)
}
func clearQuarantined(in machine.Description) machine.Description {
ret := in.Copy()
ret.IsQuarantined = false
machine.SetSwarmingQuarantinedMessage(&ret)
return ret
}
func (s *server) machineClearQuarantinedHandler(w http.ResponseWriter, r *http.Request) {
id, err := getID(w, r)
if err != nil {
return
}
s.audit(w, r, "clear-quarantine", id)
ctx, cancel := context.WithTimeout(r.Context(), defaultSQLTimeout)
defer cancel()
if err := s.store.Update(ctx, id, clearQuarantined); err != nil {
httputils.ReportError(w, err, "Failed to update machine.", http.StatusInternalServerError)
return
}
s.triggerDescriptionUpdateEvent(ctx, id)
w.WriteHeader(http.StatusOK)
}
// togglePowerCycle is used in machineTogglePowerCycleHandler and passed to
// s.store.Update to toggle the Description PowerCycle boolean.
func togglePowerCycle(ctx context.Context, id, user string, in machine.Description) machine.Description {
ret := in.Copy()
ret.PowerCycle = !ret.PowerCycle
ret.Annotation = machine.Annotation{
User: user,
Message: fmt.Sprintf("Requested powercycle for %q", id),
Timestamp: now.Now(ctx),
}
return ret
}
func (s *server) machineTogglePowerCycleHandler(w http.ResponseWriter, r *http.Request) {
id, err := getID(w, r)
if err != nil {
return
}
s.audit(w, r, "toggle-powercycle", id)
ctx, cancel := context.WithTimeout(r.Context(), defaultSQLTimeout)
defer cancel()
err = s.store.Update(ctx, id, func(in machine.Description) machine.Description {
return togglePowerCycle(ctx, id, string(s.login.LoggedInAs(r)), in)
})
if err != nil {
httputils.ReportError(w, err, "Failed to update machine.", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
// setAttachedDevice is used in machineSetAttachedDeviceHandler and passed to
// s.store.Update to set the value of Description.AttachedDevice.
func setAttachedDevice(ad machine.AttachedDevice, in machine.Description) machine.Description {
ret := in.Copy()
ret.AttachedDevice = ad
return ret
}
func (s *server) machineSetAttachedDeviceHandler(w http.ResponseWriter, r *http.Request) {
id, err := getID(w, r)
if err != nil {
return
}
var attachedDeviceRequest rpc.SetAttachedDevice
if err := json.NewDecoder(r.Body).Decode(&attachedDeviceRequest); err != nil {
httputils.ReportError(w, err, "Failed to parse incoming note.", http.StatusBadRequest)
return
}
s.audit(w, r, "set-attached-device", attachedDeviceRequest)
ctx, cancel := context.WithTimeout(r.Context(), defaultSQLTimeout)
defer cancel()
err = s.store.Update(ctx, id, func(in machine.Description) machine.Description {
return setAttachedDevice(attachedDeviceRequest.AttachedDevice, in)
})
if err != nil {
httputils.ReportError(w, err, "Failed to update machine.", http.StatusInternalServerError)
return
}
s.triggerDescriptionUpdateEvent(ctx, id)
w.WriteHeader(http.StatusOK)
}
// removeDevice is used in machineRemoveDeviceHandler and passed to
// s.store.Update to clear values in the Description that come from attached
// devices.
func removeDevice(ctx context.Context, id, user string, in machine.Description) machine.Description {
ret := in.Copy()
ret.Dimensions = machine.SwarmingDimensions{}
ret.Annotation = machine.Annotation{
User: user,
Message: fmt.Sprintf("Requested device removal of %q", id),
Timestamp: now.Now(ctx),
}
ret.Temperature = nil
ret.SuppliedDimensions = nil
ret.SSHUserIP = ""
return ret
}
func (s *server) machineRemoveDeviceHandler(w http.ResponseWriter, r *http.Request) {
id, err := getID(w, r)
if err != nil {
return
}
s.audit(w, r, "remove-device", id)
ctx, cancel := context.WithTimeout(r.Context(), defaultSQLTimeout)
defer cancel()
err = s.store.Update(ctx, id, func(in machine.Description) machine.Description {
return removeDevice(ctx, id, string(s.login.LoggedInAs(r)), in)
})
if err != nil {
httputils.ReportError(w, err, "Failed to update machine.", http.StatusInternalServerError)
return
}
s.triggerDescriptionUpdateEvent(ctx, id)
w.WriteHeader(http.StatusOK)
}
func (s *server) machineDeleteMachineHandler(w http.ResponseWriter, r *http.Request) {
id, err := getID(w, r)
if err != nil {
return
}
s.audit(w, r, "delete-machine", id)
ctx, cancel := context.WithTimeout(r.Context(), defaultSQLTimeout)
defer cancel()
if err := s.store.Delete(ctx, id); err != nil {
httputils.ReportError(w, err, "Failed to delete machine.", http.StatusInternalServerError)
return
}
s.triggerDescriptionUpdateEvent(ctx, id)
w.WriteHeader(http.StatusOK)
}
// setNote is used in machineSetNoteHandler and passed to s.store.Update to set
// the Description.Note value.
func setNote(ctx context.Context, user string, note rpc.SetNoteRequest, in machine.Description) machine.Description {
ret := in.Copy()
ret.Note = machine.Annotation{
Message: note.Message,
User: user,
Timestamp: now.Now(ctx),
}
return ret
}
func (s *server) machineSetNoteHandler(w http.ResponseWriter, r *http.Request) {
id, err := getID(w, r)
if err != nil {
return
}
var note rpc.SetNoteRequest
if err := json.NewDecoder(r.Body).Decode(&note); err != nil {
httputils.ReportError(w, err, "Failed to parse incoming note.", http.StatusBadRequest)
return
}
s.audit(w, r, "set-note", note)
ctx, cancel := context.WithTimeout(r.Context(), defaultSQLTimeout)
defer cancel()
err = s.store.Update(ctx, id, func(in machine.Description) machine.Description {
return setNote(ctx, string(s.login.LoggedInAs(r)), note, in)
})
if err != nil {
httputils.ReportError(w, err, "Failed to update machine.", http.StatusInternalServerError)
return
}
s.triggerDescriptionUpdateEvent(ctx, id)
w.WriteHeader(http.StatusOK)
}
// setChromeOSInfo is used in machineSetChromeOSInfoHandler and passed to
// s.store.Update to set Description values used by ChromeOS devices.
func setChromeOSInfo(ctx context.Context, req rpc.SupplyChromeOSRequest, in machine.Description) machine.Description {
ret := in.Copy()
ret.SSHUserIP = req.SSHUserIP
ret.SuppliedDimensions = req.SuppliedDimensions
ret.LastUpdated = now.Now(ctx)
return ret
}
// machineSupplyChromeOSInfoHandler takes in the information needed to connect a given machine with
// a ChromeOS device (via SSH).
func (s *server) machineSupplyChromeOSInfoHandler(w http.ResponseWriter, r *http.Request) {
id, err := getID(w, r)
if err != nil {
return
}
var req rpc.SupplyChromeOSRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httputils.ReportError(w, err, "Failed to parse request.", http.StatusBadRequest)
return
}
if req.SSHUserIP == "" || len(req.SuppliedDimensions) == 0 {
http.Error(w, "Missing fields.", http.StatusBadRequest)
return
}
s.audit(w, r, "supply-dimensions", req)
ctx, cancel := context.WithTimeout(r.Context(), defaultSQLTimeout)
defer cancel()
err = s.store.Update(ctx, id, func(in machine.Description) machine.Description {
return setChromeOSInfo(ctx, req, in)
})
if err != nil {
httputils.ReportError(w, err, "Failed to process dimensions.", http.StatusInternalServerError)
return
}
s.triggerDescriptionUpdateEvent(ctx, id)
w.WriteHeader(http.StatusOK)
}
func (s *server) apiMachineDescriptionHandler(w http.ResponseWriter, r *http.Request) {
id, err := getID(w, r)
if err != nil {
return
}
ctx, cancel := context.WithTimeout(r.Context(), defaultSQLTimeout)
defer cancel()
desc, err := s.store.Get(ctx, id)
if err != nil {
httputils.ReportError(w, err, "Failed to read from datastore", http.StatusInternalServerError)
return
}
sendJSONResponse(desc, w)
}
func (s *server) apiPowerCycleListHandler(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), defaultSQLTimeout)
defer cancel()
toPowerCycle, err := s.store.ListPowerCycle(ctx)
if err != nil {
httputils.ReportError(w, err, "Failed to read from datastore", http.StatusInternalServerError)
return
}
sendJSONResponse(toPowerCycle, w)
}
func setPowerCycleFalse(in machine.Description) machine.Description {
ret := in.Copy()
ret.PowerCycle = false
return ret
}
func (s *server) apiPowerCycleCompleteHandler(w http.ResponseWriter, r *http.Request) {
id, err := getID(w, r)
if err != nil {
return
}
s.audit(w, r, "powercycle-complete", id)
ctx, cancel := context.WithTimeout(r.Context(), defaultSQLTimeout)
defer cancel()
err = s.store.Update(ctx, id, setPowerCycleFalse)
if err != nil {
httputils.ReportError(w, err, "Failed to update machine.", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
func setPowerCycleState(newState machine.PowerCycleState, in machine.Description) machine.Description {
ret := in.Copy()
ret.PowerCycleState = newState
return ret
}
func (s *server) apiPowerCycleStateUpdateHandler(w http.ResponseWriter, r *http.Request) {
var req rpc.UpdatePowerCycleStateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httputils.ReportError(w, err, "Failed to parse request.", http.StatusBadRequest)
return
}
s.audit(w, r, "powercycle-update", req)
ctx, cancel := context.WithTimeout(r.Context(), defaultSQLTimeout)
defer cancel()
for _, updateRequest := range req.Machines {
if _, err := s.store.Get(ctx, updateRequest.MachineID); err != nil {
sklog.Infof("Got powercycle info for a non-existent machine %q: %s", updateRequest.MachineID, err)
continue
}
err := s.store.Update(ctx, updateRequest.MachineID, func(in machine.Description) machine.Description {
return setPowerCycleState(updateRequest.PowerCycleState, in)
})
if err != nil {
httputils.ReportError(w, err, "Failed to update machine.PowerCycleState", http.StatusInternalServerError)
return
}
}
w.WriteHeader(http.StatusOK)
}
func (s *server) loginStatus(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
st := s.login.Status(r)
if s.flags.local {
st.EMail = "barney@example.org"
}
if err := json.NewEncoder(w).Encode(st); err != nil {
httputils.ReportError(w, err, "Failed to encode login status", http.StatusInternalServerError)
}
}
// Wrapper functions for http.Handlers based on the combinations we need of
// GZipping, CSP, and Role enforcement.
func gzip(h http.Handler) http.Handler {
return httputils.GzipRequestResponse(h)
}
func (s *server) editor(h http.Handler) http.Handler {
if !s.flags.local {
return alogin.ForceRoleMiddleware(s.login, roles.Editor)(h)
}
return h
}
func (s *server) secure(h http.Handler) http.Handler {
return baseapp.SecurityMiddleware([]string{"machines.skia.org"}, s.flags.local, nil)(h)
}
func (s *server) secureGzip(h http.Handler) http.Handler {
return s.secure(gzip(h))
}
func (s *server) editorSecureGzip(h http.Handler) http.Handler {
return s.editor(s.secureGzip(h))
}
func (s *server) AddHandlers(r chi.Router) {
r.HandleFunc("/healthz", httputils.ReadyHandleFunc)
// Pages
r.Handle("/", s.secureGzip(http.HandlerFunc(s.machinesPageHandler)))
// Resources
if s.flags.resourcesDir == "" {
_, filename, _, _ := runtime.Caller(1)
s.flags.resourcesDir = filepath.Join(filepath.Dir(filename), "../../dist")
}
r.Handle("/dist/*", http.StripPrefix("/dist/", s.secureGzip(http.HandlerFunc(httputils.MakeResourceHandler(s.flags.resourcesDir)))))
// UI API
r.Post("/_/machine/toggle_mode/{id:.+}", s.editorSecureGzip(http.HandlerFunc(s.machineToggleModeHandler)).ServeHTTP)
r.Post("/_/machine/toggle_powercycle/{id:.+}", s.editorSecureGzip(http.HandlerFunc(s.machineTogglePowerCycleHandler)).ServeHTTP)
r.Post("/_/machine/set_attached_device/{id:.+}", s.editorSecureGzip(http.HandlerFunc(s.machineSetAttachedDeviceHandler)).ServeHTTP)
r.Post("/_/machine/remove_device/{id:.+}", s.editorSecureGzip(http.HandlerFunc(s.machineRemoveDeviceHandler)).ServeHTTP)
r.Post("/_/machine/delete_machine/{id:.+}", s.editorSecureGzip(http.HandlerFunc(s.machineDeleteMachineHandler)).ServeHTTP)
r.Post("/_/machine/set_note/{id:.+}", s.editorSecureGzip(http.HandlerFunc(s.machineSetNoteHandler)).ServeHTTP)
r.Post("/_/machine/supply_chromeos/{id:.+}", s.editorSecureGzip(http.HandlerFunc(s.machineSupplyChromeOSInfoHandler)).ServeHTTP)
r.Post("/_/machine/clear_quarantined/{id:.+}", s.editorSecureGzip(http.HandlerFunc(s.machineClearQuarantinedHandler)).ServeHTTP)
// External APIs
r.Post(rpc.PowerCycleCompleteURL, s.editorSecureGzip(http.HandlerFunc(s.apiPowerCycleCompleteHandler)).ServeHTTP)
r.Post(rpc.PowerCycleStateUpdateURL, s.editorSecureGzip(http.HandlerFunc(s.apiPowerCycleStateUpdateHandler)).ServeHTTP)
r.Post(rpc.MachineEventURL, s.editorSecureGzip(s.httpEventSource).ServeHTTP)
r.Handle(rpc.SSEMachineDescriptionUpdatedURL, s.editor(s.sserServer.GetHandler(context.Background()))) // GZip interferes with SSE.
// Public APIs
r.Get("/_/machines", gzip(http.HandlerFunc(s.machinesHandler)).ServeHTTP)
r.Get(rpc.MachineDescriptionURL, gzip(http.HandlerFunc(s.apiMachineDescriptionHandler)).ServeHTTP)
r.Get(rpc.PowerCycleListURL, gzip(http.HandlerFunc(s.apiPowerCycleListHandler)).ServeHTTP)
r.Get("/loginstatus/", gzip(http.HandlerFunc(s.loginStatus)).ServeHTTP)
}
func main() {
s, err := new(os.Args[1:])
if err != nil {
sklog.Fatal(err)
}
r := chi.NewRouter()
s.AddHandlers(r)
sklog.Infof("Ready to serve at: %q", s.flags.port)
server := &http.Server{
Addr: s.flags.port,
Handler: r,
MaxHeaderBytes: 1 << 20,
}
sklog.Fatal(server.ListenAndServe())
}