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

This commit is contained in:
Lemon-miaow committed 2026-09-25 18:42:51 +08:00
1 parent 516c58b543
commit b7fdef7522
10 files changed
+166 -15

No files matched your search

+65
View File
@@ -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
View File
@@ -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)
}
})
}
}
+4 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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
+3 -2
View File
@@ -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",
+3 -2
View File
@@ -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": "配置维护窗口",
+1 -1
View File
@@ -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"];
/**
@@ -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<typeof import("@/lib/api")>();
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(
<MemoryRouter>
<UpdatesPage />
</MemoryRouter>,
);
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();
});
});
+1
View File
@@ -208,6 +208,7 @@ export function UpdatesPage() {
<CardTitle className="text-base font-semibold">{t("updates_set_title")}</CardTitle>
</CardHeader>
<CardContent className="space-y-6 text-sm">
<p className="text-muted-foreground leading-relaxed">{t("updates_window_advisory")}</p>
{/* Unset hint warning */}
{windowStatus === "unset" && (
<p className="text-muted-foreground bg-muted/15 border border-dashed border-border rounded-lg p-4 leading-relaxed">