feat(update): felis update 读取维护窗口并报告当前是否在窗口内,窗口外应用前警告,面板与文档写明窗口为提示性
This commit is contained in:
10 files changed
+166
-15
No files matched your search
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "配置维护窗口",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user