package updater import ( "archive/zip" "bufio" "context" "fmt" "io" "os" "path/filepath" "strings" "felis.lolicon.best/internal/platform" "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" // DefaultJREReleasePath is the release file of the runtime deploy/bootstrap.sh // installs for Velocity (install_jre unpacks Temurin into /opt/felis/jre). const DefaultJREReleasePath = "/opt/felis/jre/release" // 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, jreRelease: DefaultJREReleasePath, } } // 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 jreRelease 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) case "jre": return jreReleaseVersion(g.jreRelease) case "postgresql": 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. func jreReleaseVersion(path string) (updates.Version, error) { raw, err := os.ReadFile(path) if err != nil { return updates.Version{}, fmt.Errorf("updater: read JRE release file: %w", err) } fields := map[string]string{} for _, line := range strings.Split(string(raw), "\n") { k, v, ok := strings.Cut(line, "=") if ok { fields[strings.TrimSpace(k)] = strings.Trim(strings.TrimSpace(v), `"`) } } for _, key := range []string{"SEMANTIC_VERSION", "JAVA_VERSION"} { if fields[key] == "" { continue } if v, err := updates.Parse(fields[key]); err == nil { return v, nil } } return updates.Version{}, fmt.Errorf("updater: no SEMANTIC_VERSION or JAVA_VERSION in %s", path) } // 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) }