diff --git a/src/webui/internal/handlers/dashboard.go b/src/webui/internal/handlers/dashboard.go index 7a8d558b2..6c85ea61c 100644 --- a/src/webui/internal/handlers/dashboard.go +++ b/src/webui/internal/handlers/dashboard.go @@ -159,9 +159,6 @@ type systemConfigWrapper struct { } type systemConfig struct { - Hostname string `json:"hostname"` - Contact string `json:"contact"` - Location string `json:"location"` Software swConfig `json:"infix-system:software"` Schedules struct { Schedule []scheduleEntry `json:"schedule"` @@ -464,28 +461,42 @@ func (h *DashboardHandler) Index(w http.ResponseWriter, r *http.Request) { // The RESTCONF client's own 10 s timeout still bounds each call. ctx := context.WithoutCancel(r.Context()) var ( - state systemStateWrapper - hw hardwareWrapper - sysConf systemConfigWrapper + state systemStateWrapper + hw hardwareWrapper + sysConf systemConfigWrapper + ident struct { + System struct { + Hostname string `json:"hostname"` + Contact string `json:"contact"` + Location string `json:"location"` + } `json:"ietf-system:system"` + } ifaces interfacesWrapper routes ribWrapper stateErr, hwErr, confErr error wg sync.WaitGroup ) - wg.Add(5) + wg.Add(6) go func() { defer wg.Done() stateErr = h.RC.Get(ctx, "/data/ietf-system:system-state", &state) }() + // The operational view resolves hostname templates like "%m". + go func() { + defer wg.Done() + if err := h.RC.Get(ctx, "/data/ietf-system:system", &ident); err != nil { + log.Printf("restconf system identity: %v", err) + } + }() go func() { defer wg.Done() hwErr = h.RC.Get(ctx, "/data/ietf-hardware:hardware", &hw) }() go func() { defer wg.Done() - // Running, not /data: statd serves operational data for the system - // container and that answer hides running config it does not emit. + // Running, not /data, for the update card: the operational view + // hides config under the system container that statd does not emit. confErr = h.RC.Get(ctx, "/ds/ietf-datastores:running/ietf-system:system", &sysConf) }() // Connectivity/Addresses cards are best-effort: a failure here logs but @@ -614,12 +625,13 @@ func (h *DashboardHandler) Index(w http.ResponseWriter, r *http.Request) { disambiguateVitals(data.KeyVitals) + data.Hostname = ident.System.Hostname + data.Contact = ident.System.Contact + data.Location = ident.System.Location + if confErr != nil { log.Printf("restconf system config: %v", confErr) } else { - data.Hostname = sysConf.System.Hostname - data.Contact = sysConf.System.Contact - data.Location = sysConf.System.Location u := newUpdateEntry(sysConf.System.Software, sysConf.System.Schedules.Schedule, state.SystemState.Software.Update) data.Update = &u diff --git a/src/webui/internal/handlers/software_test.go b/src/webui/internal/handlers/software_test.go index 3b2c3915e..afeea996c 100644 --- a/src/webui/internal/handlers/software_test.go +++ b/src/webui/internal/handlers/software_test.go @@ -1,6 +1,11 @@ package handlers -import "testing" +import ( + "bytes" + "io" + "os" + "testing" +) func TestOtherSlots(t *testing.T) { tests := []struct { @@ -40,3 +45,36 @@ func TestOtherSlots(t *testing.T) { }) } } + +type memPart struct{ *bytes.Reader } + +func (memPart) Close() error { return nil } + +func TestKeepUpload(t *testing.T) { + t.Setenv("TMPDIR", t.TempDir()) + + spilled, err := os.CreateTemp("", "multipart-") + if err != nil { + t.Fatal(err) + } + spilled.WriteString("bundle") + spilled.Seek(0, io.SeekStart) + path, err := keepUpload(spilled) + if err != nil { + t.Fatal(err) + } + if _, err := os.Stat(spilled.Name()); !os.IsNotExist(err) { + t.Errorf("spilled part still present, was copied rather than renamed") + } + if b, _ := os.ReadFile(path); string(b) != "bundle" { + t.Errorf("kept bundle = %q", b) + } + + path, err = keepUpload(memPart{bytes.NewReader([]byte("small"))}) + if err != nil { + t.Fatal(err) + } + if b, _ := os.ReadFile(path); string(b) != "small" { + t.Errorf("kept small bundle = %q", b) + } +} diff --git a/src/webui/internal/handlers/system.go b/src/webui/internal/handlers/system.go index 36236a6f8..ae1171558 100644 --- a/src/webui/internal/handlers/system.go +++ b/src/webui/internal/handlers/system.go @@ -11,6 +11,7 @@ import ( "html/template" "io" "log" + "mime/multipart" "net/http" "os" "os/exec" @@ -687,6 +688,17 @@ func (h *SystemHandler) SoftwareUpload(w http.ResponseWriter, r *http.Request) { return } + // nginx spools the whole upload before handing it over, and reading + // a bundle into the temp file takes longer than the server's 15 s + // read and write timeouts on a slow card. Push both out. + rc := http.NewResponseController(w) + if err := rc.SetReadDeadline(time.Now().Add(10 * time.Minute)); err != nil { + log.Printf("software upload: extend read deadline: %v", err) + } + if err := rc.SetWriteDeadline(time.Now().Add(10 * time.Minute)); err != nil { + log.Printf("software upload: extend write deadline: %v", err) + } + // 1 MiB in-RAM threshold; larger parts spill to $TMPDIR (/var/tmp on // the target, eMMC-backed) instead of the RAM-backed /tmp. if err := r.ParseMultipartForm(1 << 20); err != nil { @@ -706,22 +718,12 @@ func (h *SystemHandler) SoftwareUpload(w http.ResponseWriter, r *http.Request) { } defer file.Close() - tmp, err := os.CreateTemp("", "webui-bundle-*.pkg") + tmpPath, err := keepUpload(file) if err != nil { - log.Printf("software upload: create temp: %v", err) - http.Error(w, "internal error", http.StatusInternalServerError) - return - } - tmpPath := tmp.Name() - - if _, err := io.Copy(tmp, file); err != nil { - tmp.Close() - os.Remove(tmpPath) - log.Printf("software upload: write: %v", err) + log.Printf("software upload: keep bundle: %v", err) http.Error(w, "failed to save bundle", http.StatusInternalServerError) return } - tmp.Close() body := map[string]map[string]string{ "infix-system:input": {"url": tmpPath}, @@ -737,6 +739,37 @@ func (h *SystemHandler) SoftwareUpload(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, target) } +// keepUpload moves the uploaded part to a file of its own that outlives +// the request. A part larger than the in-RAM threshold already sits in a +// temp file, which is renamed rather than copied, sparing the card a +// second write of the whole bundle. Smaller parts are written out. +func keepUpload(file multipart.File) (string, error) { + tmp, err := os.CreateTemp("", "webui-bundle-*.pkg") + if err != nil { + return "", err + } + tmpPath := tmp.Name() + tmp.Close() + + if spilled, ok := file.(*os.File); ok { + if err := os.Rename(spilled.Name(), tmpPath); err == nil { + return tmpPath, nil + } + } + + tmp, err = os.OpenFile(tmpPath, os.O_WRONLY|os.O_TRUNC, 0600) + if err != nil { + os.Remove(tmpPath) + return "", err + } + defer tmp.Close() + if _, err := io.Copy(tmp, file); err != nil { + os.Remove(tmpPath) + return "", err + } + return tmpPath, nil +} + // runInstall fires the install-bundle RPC and then waits for RAUC to finish // before deleting the uploaded .pkg. The 30-minute outer timeout is the // safety net for a stuck RAUC.