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) }