From 912d129f145d49b075df9455c446b50774072c6d Mon Sep 17 00:00:00 2001 From: Lemon-miaow Date: Sat, 26 Sep 2026 12:17:00 +0800 Subject: [PATCH] =?UTF-8?q?feat(updater):=20=E4=BB=8E=20felis-postgres=20?= =?UTF-8?q?=E5=AE=B9=E5=99=A8=E8=AF=BB=E5=8F=96=20PostgreSQL=20=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E5=B9=B6=E6=94=B9=E4=B8=BA=E9=9A=8F=E5=8F=91=E5=B8=83?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E9=95=9C=E5=83=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/felis/migrate.go | 3 +- cmd/felis/update.go | 10 +++-- cmd/felis/update_test.go | 8 ++-- internal/updater/gatherer_host.go | 36 ++++++++++++--- internal/updater/gatherer_host_test.go | 61 +++++++++++++++++++------- internal/updater/postgres.go | 10 ++--- internal/updater/topology.go | 6 ++- 7 files changed, 94 insertions(+), 40 deletions(-) diff --git a/cmd/felis/migrate.go b/cmd/felis/migrate.go index e0a7edb..9d33180 100644 --- a/cmd/felis/migrate.go +++ b/cmd/felis/migrate.go @@ -18,7 +18,8 @@ import ( // database that already holds a schema and has migrations pending is bundled // first (internal/dbbackup, label pre-migrate). A failed snapshot stops the // upgrade; -no-backup is the explicit way past it, e.g. for an external -// database whose server is newer than the host's pg_dump. +// database (no [database] deployment, so the host's own pg_dump runs) whose +// server is newer than that pg_dump. func cmdMigrate(args []string, stdout, stderr io.Writer) int { fs := flag.NewFlagSet("migrate", flag.ContinueOnError) fs.SetOutput(stderr) diff --git a/cmd/felis/update.go b/cmd/felis/update.go index b85f95f..f94f6a2 100644 --- a/cmd/felis/update.go +++ b/cmd/felis/update.go @@ -128,8 +128,9 @@ var updateTargets = []updateTarget{ selector: "postgres", help: "select PostgreSQL", component: "postgresql", - note: "PostgreSQL comes from the distribution's packages, so a minor release is a package update followed by a restart (a few seconds without the API). A new major needs pg_upgrade first: docs/operations.md §4", - command: "sudo dnf upgrade 'postgresql*' || sudo apt-get install --only-upgrade 'postgresql*'; sudo systemctl restart postgresql", + note: "PostgreSQL runs as the felis-postgres Deployment from the image the Felis release pins by digest; a newer minor reaches the host with a release that moves the pin, and the installer re-run restarts the database on it (a few seconds without the API). A new major is a dump and restore: docs/operations.md §4", + command: installerRerun, + installer: true, }, { selector: "mc", @@ -149,8 +150,9 @@ var updateTargets = []updateTarget{ // reinstall/repair case. // // It never applies anything and never mutates the node, so unlike setup/breakGlass -// it needs no root. The versions it reads come from this host: k3s, cloudflared and -// PostgreSQL answer `--version`, Velocity's version is read out of the installed jar's +// it needs no root, apart from PostgreSQL's version, which the felis-postgres container +// answers through the cluster's admin kubeconfig. The versions it reads come from this +// host: k3s, cloudflared and PostgreSQL answer `--version`, Velocity's version is read out of the installed jar's // manifest, the JRE's out of its release file, and felis-api's is this binary's own // build stamp — the same value `felis version` prints, which is what the user asked // to be the source of truth. diff --git a/cmd/felis/update_test.go b/cmd/felis/update_test.go index 40a0029..083e4bc 100644 --- a/cmd/felis/update_test.go +++ b/cmd/felis/update_test.go @@ -215,8 +215,8 @@ func TestUpdateSelectorsAreFlags(t *testing.T) { } } -// k3s and cloudflared move only when the re-run is told to; PostgreSQL is the package -// manager's, so its guidance carries no installer trailer. +// k3s and cloudflared move only when the re-run is told to; the release pins the JRE +// build and the PostgreSQL image, so their guidance is the plain re-run. func TestApplyGuidanceForHostDependencies(t *testing.T) { notify := func(c string) updater.Result { return planResult([]updates.Action{{Component: c, Kind: updates.ActionNotify, LatestKnown: true}}) @@ -232,8 +232,8 @@ func TestApplyGuidanceForHostDependencies(t *testing.T) { t.Errorf("--jre guidance is the plain installer re-run:\n%s", jre) } pg := renderApplyGuidance(notify("postgresql"), map[string]bool{"postgres": true}, false) - if !strings.Contains(pg, "apt-get install --only-upgrade") || strings.Contains(pg, "Re-running the installer") { - t.Errorf("--postgres guidance is the package manager, without the installer trailer:\n%s", pg) + if !strings.Contains(pg, "| sudo bash") || strings.Contains(pg, "FELIS_UPGRADE_DEPS") || strings.Contains(pg, "apt-get") || strings.Contains(pg, "systemctl") { + t.Errorf("--postgres guidance is the plain installer re-run that moves the image pin:\n%s", pg) } } diff --git a/internal/updater/gatherer_host.go b/internal/updater/gatherer_host.go index 9ec2779..452efaa 100644 --- a/internal/updater/gatherer_host.go +++ b/internal/updater/gatherer_host.go @@ -10,6 +10,7 @@ import ( "path/filepath" "strings" + "felis.lolicon.best/internal/platform" "felis.lolicon.best/internal/updates" ) @@ -86,18 +87,39 @@ func (g hostGatherer) Current(ctx context.Context, spec Spec) (updates.Version, case "jre": return jreReleaseVersion(g.jreRelease) case "postgresql": - // The server binary is on PATH on the dnf family; Debian and Ubuntu keep it - // under /usr/lib/postgresql//bin and put only the client on PATH, which - // the distribution ships at the same version. - if v, err := g.sys.cliVersion(ctx, "postgres"); err == nil { - return v, nil - } - return g.sys.cliVersion(ctx, "psql") + return g.postgresVersion(ctx) default: return g.sys.Current(ctx, spec) } } +// postgresVersionArgv asks the server binary in the felis-postgres Deployment +// (internal/platform/postgres.go) for its version. The database runs from the image +// the release pins, so the answer comes from the container: a distribution package +// the move into k3s left installed on the host answers with a version nothing runs. +var postgresVersionArgv = []string{ + "kubectl", "exec", "-n", platform.DefaultControlNamespace, "deploy/" + platform.PostgresName, + "-c", platform.PostgresContainer, "--", "postgres", "--version", +} + +func (g hostGatherer) postgresVersion(ctx context.Context) (updates.Version, error) { + if g.sys.run == nil { + return updates.Version{}, fmt.Errorf("updater: command runner not wired for %s", platform.PostgresName) + } + out, err := g.sys.run.output(ctx, "k3s", postgresVersionArgv...) + if err != nil { + if msg := truncate(string(out), 200); msg != "" { + err = fmt.Errorf("%w: %s", err, msg) + } + return updates.Version{}, fmt.Errorf("updater: ask %s for its version: %w", platform.PostgresName, err) + } + v, err := versionFromCLI(string(out)) + if err != nil { + return updates.Version{}, fmt.Errorf("updater: %s: %w", platform.PostgresName, err) + } + return v, nil +} + // jreReleaseVersion reads the runtime's version from its release file. Temurin writes // SEMANTIC_VERSION="25.0.4.1+1"; JAVA_VERSION="25.0.4.1" is the fallback every JDK // build writes. JAVA_RUNTIME_VERSION is avoided: its "-LTS" tail reads as a prerelease. diff --git a/internal/updater/gatherer_host_test.go b/internal/updater/gatherer_host_test.go index 438fe81..fc4119e 100644 --- a/internal/updater/gatherer_host_test.go +++ b/internal/updater/gatherer_host_test.go @@ -3,8 +3,11 @@ package updater import ( "archive/zip" "context" + "errors" + "fmt" "os" "path/filepath" + "strings" "testing" "felis.lolicon.best/internal/updates" @@ -137,23 +140,47 @@ func TestJREReleaseVersion(t *testing.T) { } } -// PostgreSQL answers from the server binary where it is on PATH, else from the client. -func TestHostGathererPostgres(t *testing.T) { - for name, out := range map[string]map[string][]byte{ - "server on PATH": {"postgres": []byte("postgres (PostgreSQL) 13.23\n"), "psql": []byte("psql (PostgreSQL) 12.1\n")}, - "client only": {"psql": []byte("psql (PostgreSQL) 13.23 (Ubuntu 13.23-1.pgdg24.04+1)\n")}, - } { - g := hostGatherer{sys: sysGatherer{run: fakeCmd{out: out}}} - v, err := g.Current(context.Background(), Spec{Name: "postgresql"}) - if err != nil { - t.Fatalf("%s: %v", name, err) - } - if v.String() != "13.23" { - t.Errorf("%s: version = %s, want 13.23", name, v) - } +// argvCmd answers one exact command line and records what it was asked. +type argvCmd struct { + want string + out []byte + err error + got *[]string +} + +func (a argvCmd) output(_ context.Context, name string, args ...string) ([]byte, error) { + line := strings.Join(append([]string{name}, args...), " ") + *a.got = append(*a.got, line) + if line != a.want { + return nil, fmt.Errorf("unexpected command %q", line) } - g := hostGatherer{sys: sysGatherer{run: fakeCmd{out: map[string][]byte{}}}} - if _, err := g.Current(context.Background(), Spec{Name: "postgresql"}); err == nil { - t.Error("no postgres and no psql must be an error") + return a.out, a.err +} + +// PostgreSQL answers from the server binary in the felis-postgres container, never +// from a host package the move into k3s left installed. +func TestHostGathererPostgres(t *testing.T) { + const exec = "k3s kubectl exec -n felis deploy/felis-postgres -c postgres -- postgres --version" + var got []string + g := hostGatherer{sys: sysGatherer{run: argvCmd{want: exec, out: []byte("postgres (PostgreSQL) 18.6 (Debian 18.6-1.pgdg13+1)\n"), got: &got}}} + v, err := g.Current(context.Background(), Spec{Name: "postgresql"}) + if err != nil { + t.Fatalf("Current: %v (ran %q)", err, got) + } + if v.String() != "18.6" { + t.Errorf("version = %s, want 18.6", v) + } + if len(got) != 1 { + t.Errorf("ran %q, want exactly the exec into the database pod", got) + } + + got = nil + down := hostGatherer{sys: sysGatherer{run: argvCmd{want: exec, out: []byte("error: no running pod\n"), err: errors.New("exit status 1"), got: &got}}} + _, err = down.Current(context.Background(), Spec{Name: "postgresql"}) + if err == nil || !strings.Contains(err.Error(), "no running pod") { + t.Errorf("a database pod that cannot answer = %v, want an error carrying kubectl's reason", err) + } + if len(got) != 1 { + t.Errorf("ran %q after the pod failed, want no fallback to host binaries", got) } } diff --git a/internal/updater/postgres.go b/internal/updater/postgres.go index 31ae797..7a99385 100644 --- a/internal/updater/postgres.go +++ b/internal/updater/postgres.go @@ -17,12 +17,12 @@ import ( // major, e.g. {"major":"13","latestMinor":"23","supported":false,"eolDate":"2025-11-13"}. const postgresFeedURL = "https://www.postgresql.org/versions.json" -// postgresFeed discovers the newest minor release of the host's PostgreSQL major. +// postgresFeed discovers the newest minor release of the running PostgreSQL major. // -// Felis installs PostgreSQL from the distribution, so the major is whatever the -// distribution ships and moving it is a pg_upgrade the operator plans. The report -// therefore compares within the major (a minor release is a package update), and a -// major that is past its end of life is surfaced separately as a note. +// Felis runs PostgreSQL from an image the release pins, and moving to a new major is a +// dump and restore the operator plans. The report therefore compares within the major +// (a minor release is a new pin), and a major that is past its end of life is surfaced +// separately as a note. type postgresFeed struct { url string userAgent string diff --git a/internal/updater/topology.go b/internal/updater/topology.go index ab2b4fb..e371875 100644 --- a/internal/updater/topology.go +++ b/internal/updater/topology.go @@ -44,8 +44,10 @@ type Spec struct { // come from PaperMC (Fill v3), not GitHub. // - jre — the Temurin runtime Velocity runs on. The installer pins its patch // build, so a newer build reaches a host through a Felis release: Notify only. -// - postgresql — the control-plane database, from the distribution's packages. -// Notify only; a minor release is a package update, a major one a pg_upgrade. +// - postgresql — the control-plane database, the felis-postgres Deployment running +// the image the installer pins by digest. Notify only: a newer minor reaches a +// host through a Felis release that moves the pin, a major one through a dump and +// restore. // // Minecraft is deliberately ABSENT: every MC server is Pinned and is appended to the // plan at runtime from the live fleet (integration), never force-tracked here.