diff --git a/cmd/felis/update.go b/cmd/felis/update.go index 36d96d9..3c2c76c 100644 --- a/cmd/felis/update.go +++ b/cmd/felis/update.go @@ -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)) + } +} diff --git a/cmd/felis/update_window_test.go b/cmd/felis/update_window_test.go new file mode 100644 index 0000000..82226e1 --- /dev/null +++ b/cmd/felis/update_window_test.go @@ -0,0 +1,41 @@ +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) + } + }) + } +} diff --git a/docs/deferred-seams.md b/docs/deferred-seams.md index d441a0a..4e2677b 100644 --- a/docs/deferred-seams.md +++ b/docs/deferred-seams.md @@ -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 diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 1cfc254..1401843 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -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 diff --git a/internal/api/handlers_updates.go b/internal/api/handlers_updates.go index 28e5812..7991844 100644 --- a/internal/api/handlers_updates.go +++ b/internal/api/handlers_updates.go @@ -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 diff --git a/panel/src/i18n/resources/en-US/admin.json b/panel/src/i18n/resources/en-US/admin.json index ef460d9..247ccf2 100644 --- a/panel/src/i18n/resources/en-US/admin.json +++ b/panel/src/i18n/resources/en-US/admin.json @@ -90,8 +90,9 @@ "reviewed_at": "Reviewed At", "reject_reason": "Rejection Reason", "updates_title": "Maintenance & Backups", - "updates_subtitle": "Check that the control-plane database backup is fresh, and configure the platform-wide maintenance window. Felis may apply a Scheduled update only inside the window; outside it, updates are notify-only.", - "updates_current_unset": "No maintenance window set. Scheduled updates will degrade to notify-only and will not be applied automatically.", + "updates_subtitle": "Check that the control-plane database backup is fresh, and set the platform-wide maintenance window. Felis never applies an update on its own: `felis update` on the host shows this window and warns before you apply outside it.", + "updates_window_advisory": "The window is advisory. Updates happen only when someone runs the apply commands `felis update` prints; it reads this window and warns when run outside it.", + "updates_current_unset": "No maintenance window set. `felis update` will say so and leave the timing to you.", "updates_start_label": "Start Time", "updates_end_label": "End Time", "updates_set_title": "Configure Maintenance Window", diff --git a/panel/src/i18n/resources/zh-CN/admin.json b/panel/src/i18n/resources/zh-CN/admin.json index 388873d..c942d6e 100644 --- a/panel/src/i18n/resources/zh-CN/admin.json +++ b/panel/src/i18n/resources/zh-CN/admin.json @@ -90,8 +90,9 @@ "reviewed_at": "审核时间", "reject_reason": "驳回理由", "updates_title": "维护与备份", - "updates_subtitle": "查看控制面数据库备份是否新鲜,并配置全局维护窗口。在窗口内 Felis 可以自动应用系统更新;在窗口外,更新降级为仅通知。", - "updates_current_unset": "当前未设置维护窗口。自动更新将降级为仅通知,不会自动执行。", + "updates_subtitle": "查看控制面数据库备份是否新鲜,并设置全局维护窗口。Felis 从不自行应用更新:宿主机上的 `felis update` 会显示这个窗口,在窗口外应用前给出警告。", + "updates_window_advisory": "维护窗口是提示性的。只有有人执行 `felis update` 打印的应用命令时才会更新;该命令会读取这个窗口,在窗口外运行时给出警告。", + "updates_current_unset": "当前未设置维护窗口。`felis update` 会提示这一点,何时应用由你决定。", "updates_start_label": "开始时间", "updates_end_label": "结束时间", "updates_set_title": "配置维护窗口", diff --git a/panel/src/lib/openapi.gen.ts b/panel/src/lib/openapi.gen.ts index dedd39d..9282e13 100644 --- a/panel/src/lib/openapi.gen.ts +++ b/panel/src/lib/openapi.gen.ts @@ -992,7 +992,7 @@ export interface paths { }; /** * Read the SysAdmin-set auto-update maintenance window (admin). - * @description 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 {start:null,end:null}. API+persistence only: nothing consumes the window until the INTEGRATION runner and executors are wired, so setting it changes no behavior yet. + * @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 {start:null,end:null}. API+persistence only: nothing consumes the window until the INTEGRATION runner and executors are wired, so setting it changes no behavior yet. */ get: operations["getUpdateWindow"]; /** diff --git a/panel/src/pages/admin/UpdatesPage.test.tsx b/panel/src/pages/admin/UpdatesPage.test.tsx new file mode 100644 index 0000000..aa24dfc --- /dev/null +++ b/panel/src/pages/admin/UpdatesPage.test.tsx @@ -0,0 +1,41 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import i18next from "i18next"; +import { UpdatesPage } from "./UpdatesPage"; + +const calls = vi.hoisted(() => ({ + getUpdateWindow: vi.fn(), +})); +vi.mock("@/lib/config", () => ({ loadConfig: () => Promise.resolve({}) })); +vi.mock("@/lib/api", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, api: { ...actual.api, ...calls } }; +}); + +beforeEach(() => { + calls.getUpdateWindow.mockReset(); + calls.getUpdateWindow.mockResolvedValue({ start: null, end: null }); +}); +afterEach(() => { + vi.restoreAllMocks(); + return i18next.changeLanguage("en-US"); +}); + +describe("UpdatesPage", () => { + it("says the window is advisory, since nothing applies an update on its own", async () => { + render( + + + , + ); + expect( + await screen.findByText( + "The window is advisory. Updates happen only when someone runs the apply commands `felis update` prints; it reads this window and warns when run outside it.", + ), + ).toBeTruthy(); + expect(screen.getByText("No maintenance window set. `felis update` will say so and leave the timing to you.")).toBeTruthy(); + expect(screen.queryByText(/applied automatically|may apply a Scheduled update/)).toBeNull(); + }); +}); diff --git a/panel/src/pages/admin/UpdatesPage.tsx b/panel/src/pages/admin/UpdatesPage.tsx index 80c9872..db29bca 100644 --- a/panel/src/pages/admin/UpdatesPage.tsx +++ b/panel/src/pages/admin/UpdatesPage.tsx @@ -208,6 +208,7 @@ export function UpdatesPage() { {t("updates_set_title")} +

{t("updates_window_advisory")}

{/* Unset hint warning */} {windowStatus === "unset" && (