Unverified Commit b7fdef75 authored by Lemon-miaow's avatar Lemon-miaow
Browse files

feat(update): felis update 读取维护窗口并报告当前是否在窗口内,窗口外应用前警告,面板与文档写明窗口为提示性

parent 516c58b5
Loading
Loading
Loading
Loading
+65 −0
Changes for cmd/felis/update.go: 65 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -2,6 +2,8 @@ package main

import (
	"context"
	"encoding/json"
	"errors"
	"flag"
	"fmt"
	"io"
@@ -9,6 +11,9 @@ import (
	"strings"
	"time"

	"github.com/jackc/pgx/v5"

	"felis.lolicon.best/internal/config"
	"felis.lolicon.best/internal/updater"
	"felis.lolicon.best/internal/updates"
)
@@ -159,6 +164,7 @@ func cmdUpdate(args []string, stdout, stderr io.Writer) int {
	all := fs.Bool("all", false, "select every component above")
	force := fs.Bool("force", false, "print the apply command for a selected component even when it is already up to date")
	velocityJar := fs.String("velocity-jar", updater.DefaultVelocityJarPath, "path to the installed Velocity jar to read the current version from")
	cfgPath := fs.String("config", "/etc/felis/felis.toml", "path to felis.toml, read for the maintenance window the panel stores")
	if err := fs.Parse(args); err != nil {
		return 2
	}
@@ -192,9 +198,15 @@ func cmdUpdate(args []string, stdout, stderr io.Writer) int {
		return 1
	}

	now := time.Now()
	win, winErr := readUpdateWindow(ctx, *cfgPath)
	fmt.Fprint(stdout, renderWindowLine(win, winErr, now))
	fmt.Fprint(stdout, renderUpdateReport(res, selected))
	fmt.Fprint(stdout, renderNotes(src.Notes(), selected))
	if len(selected) > 0 {
		if winErr == nil && !win.Start.IsZero() && !win.Contains(now) {
			fmt.Fprint(stdout, "Warning: this is outside the maintenance window; the commands below take effect as soon as you run them.\n")
		}
		fmt.Fprint(stdout, renderApplyGuidance(res, selected, *force))
	}
	return 0
@@ -375,3 +387,56 @@ func isReleaseTag(v updates.Version) bool {
	_, err := updates.Parse(s)
	return err == nil
}

// updateWindowTimeout bounds the maintenance-window read, so an unreachable
// database costs the report a line and never the report itself.
const updateWindowTimeout = 3 * time.Second

// readUpdateWindow reads the maintenance window the panel stores
// (platform_settings "update_window"). Felis applies nothing on its own: this
// command is the window's consumer, showing it and warning before an apply
// outside it. A missing row is an unset window.
func readUpdateWindow(ctx context.Context, cfgPath string) (updates.Window, error) {
	cfg, err := config.Load(cfgPath)
	if err != nil {
		return updates.Window{}, err
	}
	ctx, cancel := context.WithTimeout(ctx, updateWindowTimeout)
	defer cancel()
	conn, err := pgx.Connect(ctx, cfg.Database.URL)
	if err != nil {
		return updates.Window{}, err
	}
	defer conn.Close(context.Background())
	var raw []byte
	err = conn.QueryRow(ctx, `SELECT value FROM platform_settings WHERE key = 'update_window'`).Scan(&raw)
	if errors.Is(err, pgx.ErrNoRows) {
		return updates.Window{}, nil
	}
	if err != nil {
		return updates.Window{}, err
	}
	var w updates.Window
	if err := json.Unmarshal(raw, &w); err != nil {
		return updates.Window{}, fmt.Errorf("stored window: %w", err)
	}
	return w, nil
}

// renderWindowLine is the report's first line: where now sits against the
// maintenance window.
func renderWindowLine(w updates.Window, err error, now time.Time) string {
	const layout = "2006-01-02 15:04 MST"
	switch {
	case err != nil:
		return fmt.Sprintf("Maintenance window: unknown (%v).\n", err)
	case w.Start.IsZero() || w.End.IsZero():
		return "Maintenance window: not set; apply whenever suits you.\n"
	case w.Contains(now):
		return fmt.Sprintf("Maintenance window: open now, until %s.\n", w.End.Local().Format(layout))
	case now.Before(w.Start):
		return fmt.Sprintf("Maintenance window: opens %s, until %s. Felis applies nothing on its own; run the apply commands inside it.\n", w.Start.Local().Format(layout), w.End.Local().Format(layout))
	default:
		return fmt.Sprintf("Maintenance window: ended %s; set a new one in the panel before applying.\n", w.End.Local().Format(layout))
	}
}
+41 −0
Changes for cmd/felis/update_window_test.go: 41 added lines, 0 removed lines.
Original line number Diff line number Diff line
package main

import (
	"errors"
	"testing"
	"time"

	"felis.lolicon.best/internal/updates"
)

func TestRenderWindowLinePlacesNowAgainstTheWindow(t *testing.T) {
	prev := time.Local
	time.Local = time.FixedZone("CST", 8*3600)
	t.Cleanup(func() { time.Local = prev })

	w := updates.Window{
		Start: time.Date(2026, 9, 26, 18, 0, 0, 0, time.UTC),
		End:   time.Date(2026, 9, 26, 20, 0, 0, 0, time.UTC),
	}
	cases := []struct {
		name string
		w    updates.Window
		err  error
		now  time.Time
		want string
	}{
		{"unreadable", updates.Window{}, errors.New("connection refused"), w.Start, "Maintenance window: unknown (connection refused).\n"},
		{"unset", updates.Window{}, nil, w.Start, "Maintenance window: not set; apply whenever suits you.\n"},
		{"half set", updates.Window{Start: w.Start}, nil, w.Start, "Maintenance window: not set; apply whenever suits you.\n"},
		{"at the opening instant", w, nil, w.Start, "Maintenance window: open now, until 2026-09-27 04:00 CST.\n"},
		{"before", w, nil, w.Start.Add(-time.Minute), "Maintenance window: opens 2026-09-27 02:00 CST, until 2026-09-27 04:00 CST. Felis applies nothing on its own; run the apply commands inside it.\n"},
		{"at the closing instant", w, nil, w.End, "Maintenance window: ended 2026-09-27 04:00 CST; set a new one in the panel before applying.\n"},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			if got := renderWindowLine(tc.w, tc.err, tc.now); got != tc.want {
				t.Fatalf("got  %q\nwant %q", got, tc.want)
			}
		})
	}
}
+4 −5
Changes for docs/deferred-seams.md: 4 added lines, 5 removed lines.
Original line number Diff line number Diff line
@@ -37,11 +37,10 @@ A grep across `*.md` and `*.go` returns both sets; only the Go ones are seams.
  control-plane Deployment image, and the Velocity jar inspection. Both are answered
  on the host path (see "Built" below), so this gap is specific to a caller that has
  a cluster client instead of the node.
- `internal/api/handlers_updates.go:21,28` — the maintenance window persists and the
  API serves it, but the in-cluster CronJob that would hand a real window to a runner
  does not exist. `felis update` runs with a zero window, under which every
  `Scheduled` component degrades to a notify, so no path can currently claim an
  apply is under way.
- `internal/api/handlers_updates.go` — the maintenance window is advisory: no
  in-cluster runner applies updates. `felis update` reads the stored window, prints
  where now sits against it and warns before an apply outside it; the runner itself
  still runs with a zero window, so no path can claim an apply is under way.
- `internal/submit/blobstore.go` — CLOSED 2026-09-22. The uploads PVC still cannot
  cross namespaces, so the transport went through the API instead of a mount: the
  derived context ref is now the internal-face URL
+3 −0
Changes for docs/openapi.yaml: 3 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -3138,6 +3138,9 @@ paths:
      operationId: getUpdateWindow
      summary: Read the SysAdmin-set auto-update maintenance window (admin).
      description: >-
        Advisory: Felis applies no update on its own. `felis update` on the
        host reads this window, reports where now sits against it, and warns
        before an apply outside it.
        The single platform-wide maintenance window during which Felis may apply a
        Scheduled component's update to itself (decision core internal/updates). An
        unset window — never set, or explicitly cleared — reads back as
+4 −5
Changes for internal/api/handlers_updates.go: 4 added lines, 5 removed lines.
Original line number Diff line number Diff line
@@ -23,11 +23,10 @@ import (
// A future need for per-component windows would layer keys on top; this is the
// platform default.
//
// This slice is API + PERSISTENCE ONLY. Nothing consumes the stored window yet:
// the runner, the ReleaseSource/Notifier/Applier executors and the scheduler
// CronJob are all still INTEGRATION-ONLY (task #38 remainder). Setting a window
// today changes no behavior until those land — it is the durable input they will
// read. The Panel UI that drives these routes is out of scope (hands-off-frontend).
// Felis applies no update on its own, so the window is advisory. Its consumer
// is `felis update` on the host (cmd/felis/update.go readUpdateWindow), which
// reads this row, prints where now sits against it, and warns before an apply
// outside it. The panel's Updates page says the same.

// updateWindowKey is the platform_settings key holding the maintenance window as
// JSON {"start","end"} (RFC3339, or null when unset). It reuses the generic
Loading