Files
Felis/internal/updater/gatherer_host.go
T
flyemoji 2180e77cf5 chore: drop tool-name markers from source comments
Seventeen comments opened with a tag naming the tool that wrote them.
The tag goes and each comment keeps its reasoning, now starting as a
plain sentence. None of the reasoning changes.

The AGENTS.md note in .gitignore drops the story of how the file got
into the tree and keeps the one fact a reader needs: its advice to run
go fmt is destructive on this CRLF working tree.

Comments only; no code, build or test changes.
2026-09-22 12:57:19 +09:00

145 lines
5.9 KiB
Go

package updater
import (
"archive/zip"
"bufio"
"context"
"fmt"
"io"
"path/filepath"
"strings"
"felis.lolicon.best/internal/updates"
)
// This file wires the two gather seams NewSysGatherer deliberately leaves nil, for
// the one caller that can satisfy them without a cluster client: the on-host `felis
// update` CLI. Both reads are answered from the node the command runs on, which is
// exactly where bootstrap.sh installed the things being read.
//
// felis-api is answered from the RUNNING BINARY's own build stamp rather than from
// the control-plane Deployment's image tag. That is not a shortcut around the k8s
// read — it is the identity the user named ("当前版本号通过 felis version 查询"), and
// it is the same artifact: deploy/bootstrap.sh builds the felis image from the same
// checkout it installs /usr/local/bin/felis from, stamping both with one `git
// describe`. Reading the Deployment would answer a slightly different question (what
// is rolled out) and is still the right seam for the in-cluster CronJob path; it is
// left to NewSysGatherer, which keeps returning "not wired" there.
//
// The panel has no version of its own on purpose: it is compiled into the felis
// binary with //go:embed, so "the panel version" IS the felis version. The same is
// true of the plugin jars, which are built from this repo in the same bootstrap run.
// That is why the CLI's --panel and --plugins selectors both resolve here.
// DefaultVelocityJarPath is where deploy/bootstrap.sh installs the proxy. The jar is
// installed under a FIXED name with no version in it (install_velocity does
// `atomic_install_file "$tmp" "${VELOCITY_DIR}/velocity.jar"`), which is why the
// filename extractor alone cannot answer for Velocity and the manifest read below
// exists.
const DefaultVelocityJarPath = "/opt/felis/velocity/velocity.jar"
// NewHostGatherer builds the VersionGatherer for `felis update` running on the node.
// felisVersion is the CLI's own resolved build stamp; velocityJar is the installed
// proxy jar (empty means DefaultVelocityJarPath). k3s and cloudflared keep the
// existing exec seam — they are real binaries on this host and already answer.
func NewHostGatherer(felisVersion, velocityJar string) VersionGatherer {
if velocityJar == "" {
velocityJar = DefaultVelocityJarPath
}
return hostGatherer{
sys: sysGatherer{run: execRunner{}},
felisVersion: felisVersion,
velocityJar: velocityJar,
}
}
// hostGatherer answers the two seams it can satisfy from the local filesystem and
// delegates every other component to the standard system gatherer, so the CLI and
// the (future) in-cluster runner share one dispatch table and cannot drift.
type hostGatherer struct {
sys sysGatherer
felisVersion string
velocityJar string
}
// Current implements VersionGatherer.
func (g hostGatherer) Current(ctx context.Context, spec Spec) (updates.Version, error) {
switch spec.Name {
case "felis-api":
// An unstamped local `go build` reports "dev", which is not a version. Fail
// closed with an actionable message rather than inventing a 0.0.0 that would
// make every release upstream look like an upgrade.
v, err := updates.Parse(g.felisVersion)
if err != nil {
return updates.Version{}, fmt.Errorf("updater: felis build stamp %q is not a version (an unstamped build cannot be compared): %w", g.felisVersion, err)
}
return v, nil
case "velocity":
return velocityJarVersion(g.velocityJar)
default:
return g.sys.Current(ctx, spec)
}
}
// velocityJarVersion reads the installed proxy's version out of the jar itself.
//
// It reads META-INF/MANIFEST.MF's Implementation-Version, which is authoritative
// rather than incidental: Velocity reports its own version at runtime from that
// attribute (getImplementationVersion), so the value here is the same string the
// proxy prints about itself. The filename is only a fallback, and on a Felis node it
// is expected to fail — bootstrap installs the jar as a fixed "velocity.jar" — so it
// exists for the hand-placed "velocity-3.5.1.jar" case, not the normal one.
func velocityJarVersion(path string) (updates.Version, error) {
zr, err := zip.OpenReader(path)
if err != nil {
return updates.Version{}, fmt.Errorf("updater: open velocity jar %s: %w", path, err)
}
defer zr.Close()
raw, err := manifestAttr(&zr.Reader, "Implementation-Version")
if err == nil {
v, perr := updates.Parse(raw)
if perr == nil {
return v, nil
}
err = perr
}
// Fall back to the filename before surfacing the manifest failure, so a jar whose
// manifest is missing or unparseable still answers when the name carries a version.
if v, ferr := versionFromJarName(filepath.Base(path)); ferr == nil {
return v, nil
}
return updates.Version{}, fmt.Errorf("updater: no version in %s manifest or filename: %w", path, err)
}
// manifestAttr returns one attribute value from a jar's META-INF/MANIFEST.MF.
//
// This does not implement the JAR spec's 72-byte line folding (a wrapped
// value continues on the next line after a single leading space). Version values are
// far short of the wrap point, so folding cannot bite here; if this ever reads a long
// attribute, join continuation lines before splitting on ':'.
func manifestAttr(zr *zip.Reader, key string) (string, error) {
f, err := zr.Open("META-INF/MANIFEST.MF")
if err != nil {
return "", fmt.Errorf("updater: jar has no manifest: %w", err)
}
defer f.Close()
// Bound the read: a manifest is a few KiB, and this parses a file that arrived
// over the network in an earlier bootstrap run.
sc := bufio.NewScanner(io.LimitReader(f, 256<<10))
for sc.Scan() {
name, value, ok := strings.Cut(sc.Text(), ":")
if !ok || !strings.EqualFold(strings.TrimSpace(name), key) {
continue
}
if v := strings.TrimSpace(value); v != "" {
return v, nil
}
}
if err := sc.Err(); err != nil {
return "", fmt.Errorf("updater: read manifest: %w", err)
}
return "", fmt.Errorf("updater: manifest has no %s", key)
}