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.
145 lines
5.9 KiB
Go
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)
|
|
}
|