428 lines
16 KiB
Go
428 lines
16 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"path"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/fleetdm/fleet/v4/pkg/file"
|
|
"github.com/fleetdm/fleet/v4/pkg/fleethttp"
|
|
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
|
|
"github.com/fleetdm/fleet/v4/server/dev_mode"
|
|
"github.com/fleetdm/fleet/v4/server/fleet"
|
|
maintained_apps "github.com/fleetdm/fleet/v4/server/mdm/maintainedapps"
|
|
)
|
|
|
|
// AutoUpdateFleetMaintainedApps walks every active Fleet-maintained app
|
|
// installer and, where its pin state allows, downloads the newest published
|
|
// version into the team's cache and advances the active installer to it. When
|
|
// softwareInstallStore is nil (e.g. the installer store isn't configured) it
|
|
// degrades to promote-only: it advances among versions already cached but
|
|
// fetches nothing from upstream. A failure on one app is logged and skipped so a
|
|
// single bad row can't stall the whole run.
|
|
func AutoUpdateFleetMaintainedApps(ctx context.Context, ds fleet.Datastore, softwareInstallStore fleet.SoftwareInstallerStore, logger *slog.Logger) error {
|
|
candidates, err := ds.ListFleetMaintainedAppActiveInstallers(ctx)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "listing active fleet-maintained app installers")
|
|
}
|
|
|
|
// One HTTP client and one manifest cache for the whole run, so each distinct
|
|
// manifest is fetched at most once regardless of how many teams use the app.
|
|
var client *http.Client
|
|
if softwareInstallStore != nil {
|
|
timeout := maintained_apps.InstallerTimeout
|
|
if v := dev_mode.Env("FLEET_DEV_MAINTAINED_APPS_INSTALLER_TIMEOUT"); v != "" {
|
|
if d, err := time.ParseDuration(v); err == nil {
|
|
timeout = d
|
|
}
|
|
}
|
|
client = fleethttp.NewClient(fleethttp.WithTimeout(timeout))
|
|
}
|
|
manifests := map[string]*manifestEntry{}
|
|
|
|
for _, c := range candidates {
|
|
if err := autoUpdateOneFleetMaintainedApp(ctx, ds, softwareInstallStore, client, logger, c, manifests); err != nil {
|
|
logger.ErrorContext(ctx, "auto-updating fleet-maintained app",
|
|
"title_id", c.TitleID, "team_id", teamIDForLog(c.TeamID), "slug", c.Slug, "err", err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func autoUpdateOneFleetMaintainedApp(
|
|
ctx context.Context,
|
|
ds fleet.Datastore,
|
|
store fleet.SoftwareInstallerStore,
|
|
client *http.Client,
|
|
logger *slog.Logger,
|
|
c fleet.FMAAutoUpdateCandidate,
|
|
manifests map[string]*manifestEntry,
|
|
) error {
|
|
pinned, err := ds.GetPinnedVersion(ctx, c.TeamID, c.TitleID)
|
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
|
return ctxerr.Wrap(ctx, err, "getting pinned version")
|
|
}
|
|
pin := ""
|
|
if pinned != nil {
|
|
pin = *pinned
|
|
}
|
|
|
|
// A literal pin never advances and never pre-caches: the version is already
|
|
// cached and nothing newer is wanted (re-pinning is handled on-demand).
|
|
if pin != "" && !strings.HasPrefix(pin, "^") {
|
|
return nil
|
|
}
|
|
|
|
// Download the latest published version when the store is configured and the
|
|
// pin would actually promote to it. Download failures are isolated: we log and
|
|
// still try to promote among whatever is already cached.
|
|
if store != nil && client != nil {
|
|
if err := downloadNewVersionIfEligible(ctx, ds, store, logger, c, pin, client, manifests); err != nil {
|
|
logger.ErrorContext(ctx, "downloading new fleet-maintained app version",
|
|
"title_id", c.TitleID, "team_id", teamIDForLog(c.TeamID), "slug", c.Slug, "err", err)
|
|
}
|
|
}
|
|
|
|
return promoteFleetMaintainedApp(ctx, ds, logger, c, pin)
|
|
}
|
|
|
|
// promoteFleetMaintainedApp advances the active installer to the newest cached
|
|
// version the pin allows, without ever rewriting the pin (a nil PinnedVersion
|
|
// leaves it untouched, so an admin pin changed between this read and write is
|
|
// never clobbered).
|
|
func promoteFleetMaintainedApp(ctx context.Context, ds fleet.Datastore, logger *slog.Logger, c fleet.FMAAutoUpdateCandidate, pin string) error {
|
|
// Cached versions, semver-sorted newest-first.
|
|
versions, err := ds.GetFleetMaintainedVersionsByTitleID(ctx, c.TeamID, c.TitleID, true)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "getting cached versions")
|
|
}
|
|
if len(versions) == 0 {
|
|
return nil
|
|
}
|
|
|
|
target, ok := selectAutoUpdateTarget(versions, pin)
|
|
if !ok || target.ID == c.InstallerID {
|
|
// No newer eligible version, or already on it.
|
|
return nil
|
|
}
|
|
|
|
payload := &fleet.UpdateSoftwareInstallerPayload{
|
|
TeamID: c.TeamID,
|
|
TitleID: c.TitleID,
|
|
}
|
|
// SetFleetMaintainedAppActiveInstaller atomically flips the active installer,
|
|
// re-points policies, and redirects installs frozen on the old version to the
|
|
// new one, so a host never installs a version other than the one displayed.
|
|
if err := ds.SetFleetMaintainedAppActiveInstaller(ctx, payload, target.ID); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "setting active installer")
|
|
}
|
|
|
|
logger.InfoContext(ctx, "advanced fleet-maintained app to newer cached version",
|
|
"title_id", c.TitleID, "team_id", teamIDForLog(c.TeamID), "slug", c.Slug,
|
|
"from", c.Version, "to", target.Version, "pin", pin)
|
|
return nil
|
|
}
|
|
|
|
// manifestEntry memoizes the hydrated latest manifest (or its fetch error) for a
|
|
// slug across the run, so N teams sharing an app fetch the manifest once.
|
|
type manifestEntry struct {
|
|
app *fleet.MaintainedApp
|
|
err error
|
|
}
|
|
|
|
// downloadNewVersionIfEligible fetches the latest manifest, and if the pin would
|
|
// promote to it and it isn't already cached, downloads the installer and caches
|
|
// it (inactive) for the team. Promotion happens separately, after this returns,
|
|
// so the bytes are always stored before the active flag flips.
|
|
func downloadNewVersionIfEligible(
|
|
ctx context.Context,
|
|
ds fleet.Datastore,
|
|
store fleet.SoftwareInstallerStore,
|
|
logger *slog.Logger,
|
|
c fleet.FMAAutoUpdateCandidate,
|
|
pin string,
|
|
client *http.Client,
|
|
manifests map[string]*manifestEntry,
|
|
) error {
|
|
app, err := hydrateLatestManifest(ctx, ds, c, manifests)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// For a concrete manifest version the eligibility gates can run up front. For a
|
|
// "latest" manifest the real version isn't known until the installer is
|
|
// extracted, so the caret-major and cache checks are deferred until after that.
|
|
isLatest := app.Version == "latest"
|
|
if !isLatest {
|
|
// Caret pin: caching a version the pin can never promote to wastes a slot.
|
|
// Empty pin always takes latest; literal pins were filtered out by the caller.
|
|
if pin != "" && !versionMatchesMajor(app.Version, strings.TrimPrefix(pin, "^")) {
|
|
return nil
|
|
}
|
|
versionExists, _, err := ds.HasFMAInstallerVersion(ctx, c.TeamID, c.FleetMaintainedAppID, app.Version)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "checking cached version")
|
|
}
|
|
if versionExists {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// Byte dedup: when the expected hash is known and already in the store (another
|
|
// team cached the same version), skip the HTTP download and reuse the bytes.
|
|
storageID := app.SHA256
|
|
needBytes := true
|
|
if app.SHA256 != noCheckHash && !isLatest {
|
|
exists, err := store.Exists(ctx, app.SHA256)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "checking installer store")
|
|
}
|
|
needBytes = !exists
|
|
}
|
|
|
|
version := app.Version
|
|
filename := ""
|
|
upgradeCode := app.UpgradeCode
|
|
var packageIDs []string
|
|
var tfr *fleet.TempFileReader
|
|
if needBytes {
|
|
tfr, filename, err = maintained_apps.DownloadInstaller(ctx, app.InstallerURL, client)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "downloading app installer")
|
|
}
|
|
defer tfr.Close()
|
|
|
|
gotHash, err := file.SHA256FromTempFileReader(tfr)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "calculating SHA256 hash")
|
|
}
|
|
if app.SHA256 != noCheckHash {
|
|
if gotHash != app.SHA256 {
|
|
return ctxerr.New(ctx, "mismatch in maintained app SHA256 hash")
|
|
}
|
|
} else {
|
|
storageID = gotHash
|
|
}
|
|
|
|
// Extract the concrete version (for "latest") and the package IDs / upgrade
|
|
// code needed to substitute the uninstall script. Best-effort except when
|
|
// it's the only way to resolve a "latest" version. Rewind unconditionally —
|
|
// extraction consumes the reader and the bytes are stored below.
|
|
meta, metaErr := file.ExtractInstallerMetadata(tfr)
|
|
if err := tfr.Rewind(); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "resetting installer file reader")
|
|
}
|
|
if metaErr != nil {
|
|
if isLatest {
|
|
return ctxerr.Wrap(ctx, metaErr, "extracting installer metadata")
|
|
}
|
|
logger.WarnContext(ctx, "extracting fleet-maintained app installer metadata", "slug", c.Slug, "err", metaErr)
|
|
} else {
|
|
if isLatest {
|
|
version = meta.Version
|
|
}
|
|
packageIDs = meta.PackageIDs
|
|
if meta.UpgradeCode != "" {
|
|
upgradeCode = meta.UpgradeCode
|
|
}
|
|
}
|
|
} else {
|
|
// Bytes already cached (possibly only on an inactive row after a rollback):
|
|
// recover the package IDs and upgrade code from any installer with the same
|
|
// content hash so the uninstall script can still be substituted.
|
|
pids, ucode, err := ds.GetSoftwareInstallerMetadataByStorageID(ctx, storageID)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "recovering cached installer metadata")
|
|
}
|
|
packageIDs = pids
|
|
if ucode != "" {
|
|
upgradeCode = ucode
|
|
}
|
|
}
|
|
|
|
// Apply the deferred gates now that the concrete version is known ("latest").
|
|
if isLatest && pin != "" && !versionMatchesMajor(version, strings.TrimPrefix(pin, "^")) {
|
|
return nil
|
|
}
|
|
if version != app.Version {
|
|
versionExists, _, err := ds.HasFMAInstallerVersion(ctx, c.TeamID, c.FleetMaintainedAppID, version)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "checking cached version")
|
|
}
|
|
if versionExists {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
if filename == "" { // bytes were reused; derive a filename from the URL
|
|
if u, err := url.Parse(app.InstallerURL); err == nil {
|
|
filename = path.Base(u.Path)
|
|
}
|
|
}
|
|
|
|
payload := &fleet.UploadSoftwareInstallerPayload{
|
|
TeamID: c.TeamID,
|
|
Version: version,
|
|
Filename: filename,
|
|
Extension: strings.TrimLeft(filepath.Ext(filename), "."),
|
|
StorageID: storageID,
|
|
URL: app.InstallerURL,
|
|
UpgradeCode: upgradeCode,
|
|
PatchQuery: app.PatchQuery,
|
|
AppOpenQuery: app.AppOpenQuery,
|
|
InstallScript: app.InstallScript,
|
|
UninstallScript: app.UninstallScript,
|
|
PackageIDs: packageIDs,
|
|
InstallerFile: tfr,
|
|
}
|
|
|
|
// Preserve admin-customized scripts across auto-updates. The active installer
|
|
// (still the previous version here; promotion happens later) is the one to
|
|
// carry forward from. Detect customization per-script by comparing against the
|
|
// manifest, first neutralizing the parts that legitimately change between
|
|
// versions so a routine version bump isn't mistaken for an edit: the install
|
|
// script hardcodes the versioned installer filename, and the uninstall script
|
|
// is version-specific after $PACKAGE_ID / $UPGRADE_CODE substitution.
|
|
active, err := ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, c.TeamID, c.TitleID, true)
|
|
if err != nil && !fleet.IsNotFound(err) {
|
|
return ctxerr.Wrap(ctx, err, "getting active installer to preserve custom scripts")
|
|
}
|
|
if active != nil {
|
|
// Compare with the old and new filenames replaced by a common placeholder
|
|
// (active.Name is the filename column); otherwise the filename difference
|
|
// alone reads as an admin edit and the stale script is kept against the
|
|
// newly downloaded installer. FMAs whose script embeds a name unrelated to
|
|
// the stored installer filename (e.g. a versioned pkg inside a dmg) aren't
|
|
// neutralized and fall back to preserving, as before.
|
|
activeInstall := normalizeInstallerFilename(strings.TrimSpace(active.InstallScript), active.Name)
|
|
manifestInstall := normalizeInstallerFilename(strings.TrimSpace(app.InstallScript), payload.Filename)
|
|
if activeInstall != manifestInstall {
|
|
payload.InstallScript = active.InstallScript
|
|
}
|
|
defaultUninstall := &fleet.UploadSoftwareInstallerPayload{
|
|
UninstallScript: app.UninstallScript,
|
|
PackageIDs: active.PackageIDs(),
|
|
UpgradeCode: active.UpgradeCode,
|
|
Extension: active.Extension,
|
|
}
|
|
if err := preProcessUninstallScript(defaultUninstall); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "computing manifest uninstall script for comparison")
|
|
}
|
|
if strings.TrimSpace(active.UninstallScript) != strings.TrimSpace(defaultUninstall.UninstallScript) {
|
|
payload.UninstallScript = active.UninstallScript
|
|
}
|
|
}
|
|
|
|
// Substitute $PACKAGE_ID / $UPGRADE_CODE in the uninstall script, matching the
|
|
// GitOps materialization path (no-op when there are no package IDs).
|
|
if err := preProcessUninstallScript(payload); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "processing uninstall script")
|
|
}
|
|
// Refuse to persist a row whose uninstall script still has unsubstituted
|
|
// template variables (e.g. metadata extraction failed and preProcess silently
|
|
// no-op'd): promoting it would record uninstalls as succeeding while the app
|
|
// stays installed. Skip this candidate; the next run retries.
|
|
if file.PackageIDRegex.MatchString(payload.UninstallScript) || file.UpgradeCodeRegex.MatchString(payload.UninstallScript) {
|
|
return ctxerr.Errorf(ctx, "uninstall script for %q still has unsubstituted template variables; skipping cache", c.Slug)
|
|
}
|
|
|
|
// Store the bytes before creating the DB row, so a Put failure can't leave a
|
|
// row pointing at installer bytes that aren't in the store — which the caller
|
|
// would then promote the active installer to.
|
|
if needBytes && tfr != nil {
|
|
exists, err := store.Exists(ctx, storageID)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "checking installer store")
|
|
}
|
|
if !exists {
|
|
if err := store.Put(ctx, storageID, tfr); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "storing installer")
|
|
}
|
|
}
|
|
}
|
|
|
|
if _, err := ds.InsertFleetMaintainedAppVersion(ctx, c.InstallerID, payload); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "caching new fleet-maintained app version")
|
|
}
|
|
|
|
logger.InfoContext(ctx, "cached new fleet-maintained app version",
|
|
"title_id", c.TitleID, "team_id", teamIDForLog(c.TeamID), "slug", c.Slug,
|
|
"from", c.Version, "to", version, "pin", pin)
|
|
return nil
|
|
}
|
|
|
|
func hydrateLatestManifest(ctx context.Context, ds fleet.Datastore, c fleet.FMAAutoUpdateCandidate, manifests map[string]*manifestEntry) (*fleet.MaintainedApp, error) {
|
|
if e, ok := manifests[c.Slug]; ok {
|
|
return e.app, e.err
|
|
}
|
|
app, err := func() (*fleet.MaintainedApp, error) {
|
|
skeleton, err := ds.GetMaintainedAppByID(ctx, c.FleetMaintainedAppID, c.TeamID)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "getting maintained app by id")
|
|
}
|
|
// version "" fetches the latest from the remote manifest; teamID/cache are unused.
|
|
hydrated, err := maintained_apps.Hydrate(ctx, skeleton, "", c.TeamID, nil)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "hydrating app from manifest")
|
|
}
|
|
return hydrated, nil
|
|
}()
|
|
manifests[c.Slug] = &manifestEntry{app: app, err: err}
|
|
return app, err
|
|
}
|
|
|
|
// selectAutoUpdateTarget picks the cached version the pin allows the cron to
|
|
// advance to. versions must be semver-sorted newest-first. An empty pin means
|
|
// Latest (newest). A caret pin returns the newest version within its major, or
|
|
// ok=false when no cached version satisfies the major (so the cron skips rather
|
|
// than crossing into another major, unlike the on-demand PATCH path).
|
|
func selectAutoUpdateTarget(versions []fleet.FleetMaintainedVersion, pin string) (fleet.FleetMaintainedVersion, bool) {
|
|
if pin == "" {
|
|
return versions[0], true
|
|
}
|
|
// Caret pin: parsePinnedVersion already validated the shape on write.
|
|
major := strings.TrimPrefix(pin, "^")
|
|
for _, v := range versions {
|
|
if versionMatchesMajor(v.Version, major) {
|
|
return v, true
|
|
}
|
|
}
|
|
return fleet.FleetMaintainedVersion{}, false
|
|
}
|
|
|
|
// teamIDForLog renders an optional team ID for structured logs; slog prints a
|
|
// *uint as its address, so the value (or "none") is surfaced here instead.
|
|
func teamIDForLog(p *uint) any {
|
|
if p == nil {
|
|
return "none"
|
|
}
|
|
return *p
|
|
}
|
|
|
|
// normalizeInstallerFilename replaces the version-specific installer filename in
|
|
// a generated FMA install script with a fixed placeholder, so two scripts that
|
|
// differ only because the installer filename changed between versions compare
|
|
// equal. A missing filename leaves the script unchanged.
|
|
func normalizeInstallerFilename(script, filename string) string {
|
|
if filename == "" {
|
|
return script
|
|
}
|
|
const placeholder = "__FLEET_INSTALLER_FILE__"
|
|
// Replace only where the filename is the installer path argument. A short URL
|
|
// basename (e.g. "dmg") would otherwise rewrite free-floating occurrences such
|
|
// as /tmp/dmg_mount_XXXXXX.
|
|
script = strings.ReplaceAll(script, `"$TMPDIR/`+filename+`"`, `"$TMPDIR/`+placeholder+`"`)
|
|
// The unquoted (choices) form is always followed by " -target", so bound the
|
|
// match with the trailing space; otherwise a filename would prefix-match a
|
|
// longer path that merely starts with it.
|
|
script = strings.ReplaceAll(script, `"$TMPDIR"/`+filename+" ", `"$TMPDIR"/`+placeholder+" ")
|
|
return script
|
|
}
|