mirror of
https://github.com/fleetdm/fleet
synced 2026-04-21 13:37:30 +00:00
Add cache option for software packages to skip re-downloading unchanged content (#42216)
**Related issue:** Ref #34797 Ref #42675 ## Problem When a software installer spec has no `hash_sha256`, Fleet re-downloads the package, re-extracts metadata, and re-upserts the DB on every GitOps run, even if the upstream file hasn't changed. For deployments with 50+ URL-only packages across multiple teams, this wastes bandwidth and processing time on every run. ## Solution By default, use etags to avoid unnecessary downloads: 1. First run: Fleet downloads the package normally and stores the server's ETag header 2. Subsequent runs: Fleet sends a conditional GET with `If-None-Match`. If the server returns 304 Not Modified, Fleet skips the download, metadata extraction, S3 upload, and DB upsert entirely Opt-out with `always_download:true`, meaning packages continue to be downloaded and re-processed on every run, same as today. No UI changes needed. ```yaml url: https://nvidia.gpcloudservice.com/global-protect/getmsi.esp?version=64&platform=windows always_download: true install_script: path: install.ps1 ``` ### Why conditional GET instead of HEAD Fleet team [analysis of 276 maintained apps](https://github.com/fleetdm/fleet/pull/42216#issuecomment-4105430061) showed 7 apps where HEAD requests fail (405, 403, timeout) but GET works for all. Conditional GET eliminates that failure class: if the server doesn't support conditional requests, it returns 200 with the full body, same as today. ### Why opt-in 5 of 276 apps (1.8%) have stale ETags (content changes but ETag stays the same), caused by CDN caching artifacts (CloudFront, Cloudflare, nginx inode-based ETags). The `cache` key lets users opt in per package for URLs where they've verified ETag behavior is correct. Validation rejects `always_download: true` when hash_sha256` is set ## Changes - New YAML field: `cache` (bool, package-level) - New migration: `http_etag` VARCHAR(512) column (explicit `utf8mb4_unicode_ci` collation) + composite index `(global_or_team_id, url(255))` on `software_installers` - New datastore method: `GetInstallerByTeamAndURL` - `downloadURLFn` accepts optional `If-None-Match` header, returns 304 as `(resp, nil, nil)` with `http.NoBody` - ETag validated per RFC 7232 (ASCII printable only, no control chars, max 512 bytes) at both write and read time - Cache skipped for `.ipa` packages (multi-platform extraInstallers) - TempFileReader and HTTP response leak prevention on download retry - Docs updated in `yaml-files.md` ## What doesn't change - Packages with `hash_sha256`: existing hash-based skip, untouched - FMA packages: FMA version cache, untouched - Packages with `always_download: true`: identical to current behavior - Fleet UI: no changes ## Test plan Automated testing: - [x] 16 unit tests for `validETag` - [x] 8 unit tests for conditional GET behavior (304, 200, 403, 500, weak ETag, S3 multipart, no ETag) - [x] MySQL integration test for `GetInstallerByTeamAndURL` - [x] All 23 existing `TestSoftwareInstallers` datastore tests pass - [x] All existing service tests pass Manual testing: - [x] E2E: 86 packages across 6 CDN patterns, second apply shows 51 conditional hits (304) - [x] @sgress454 used a local fileserver tool to test w/ a new instance and dummy packages <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * ETag-based conditional downloads to skip unchanged remote installer files. * New always_download flag to force full re-downloads. * **Tests** * Added integration and unit tests covering conditional GETs, ETag validation, retries, edge cases, and payload behavior. * **Chores** * Persist HTTP ETag and related metadata; DB migration and index to speed installer lookups. * Added installer lookup by team+URL to support conditional download flow. * **Bug Fix** * Rejects using always_download together with an explicit SHA256 in uploads. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Scott Gress <scott@fleetdm.com> Co-authored-by: Scott Gress <scott@pigandcow.com> Co-authored-by: Ian Littman <iansltx@gmail.com>
This commit is contained in:
parent
b18195ba19
commit
aff440236e
18 changed files with 865 additions and 97 deletions
2
changes/42675-conditional-download-cache
Normal file
2
changes/42675-conditional-download-cache
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
- Added conditional HTTP downloads using ETag headers for software in GitOps, skipping re-download when content hasn't changed.
|
||||
- Added `always_download` option for software in GitOps to bypass the new conditional download feature.
|
||||
|
|
@ -2849,6 +2849,9 @@ func TestGitOpsFullGlobalAndTeam(t *testing.T) {
|
|||
ds.GetTeamsWithInstallerByHashFunc = func(ctx context.Context, sha256, url string) (map[uint][]*fleet.ExistingSoftwareInstaller, error) {
|
||||
return map[uint][]*fleet.ExistingSoftwareInstaller{}, nil
|
||||
}
|
||||
ds.GetInstallerByTeamAndURLFunc = func(ctx context.Context, teamID uint, url string) (*fleet.ExistingSoftwareInstaller, error) {
|
||||
return nil, nil
|
||||
}
|
||||
ds.GetSoftwareCategoryIDsFunc = func(ctx context.Context, names []string) ([]uint, error) {
|
||||
return []uint{}, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -212,6 +212,9 @@ func setupEmptyGitOpsMocks(ds *mock.Store) {
|
|||
ds.GetTeamsWithInstallerByHashFunc = func(ctx context.Context, sha256, url string) (map[uint][]*fleet.ExistingSoftwareInstaller, error) {
|
||||
return map[uint][]*fleet.ExistingSoftwareInstaller{}, nil
|
||||
}
|
||||
ds.GetInstallerByTeamAndURLFunc = func(ctx context.Context, teamID uint, url string) (*fleet.ExistingSoftwareInstaller, error) {
|
||||
return nil, nil
|
||||
}
|
||||
ds.DeleteIconsAssociatedWithTitlesWithoutInstallersFunc = func(ctx context.Context, teamID uint) error {
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -142,6 +142,9 @@ func TestGitOpsTeamSoftwareInstallers(t *testing.T) {
|
|||
ds.GetTeamsWithInstallerByHashFunc = func(ctx context.Context, sha256, url string) (map[uint][]*fleet.ExistingSoftwareInstaller, error) {
|
||||
return map[uint][]*fleet.ExistingSoftwareInstaller{}, nil
|
||||
}
|
||||
ds.GetInstallerByTeamAndURLFunc = func(ctx context.Context, teamID uint, url string) (*fleet.ExistingSoftwareInstaller, error) {
|
||||
return nil, nil
|
||||
}
|
||||
ds.GetSoftwareCategoryIDsFunc = func(ctx context.Context, names []string) ([]uint, error) {
|
||||
return []uint{}, nil
|
||||
}
|
||||
|
|
@ -191,6 +194,9 @@ func TestGitOpsTeamSoftwareInstallersQueryEnv(t *testing.T) {
|
|||
ds.GetTeamsWithInstallerByHashFunc = func(ctx context.Context, sha256, url string) (map[uint][]*fleet.ExistingSoftwareInstaller, error) {
|
||||
return map[uint][]*fleet.ExistingSoftwareInstaller{}, nil
|
||||
}
|
||||
ds.GetInstallerByTeamAndURLFunc = func(ctx context.Context, teamID uint, url string) (*fleet.ExistingSoftwareInstaller, error) {
|
||||
return nil, nil
|
||||
}
|
||||
ds.GetSoftwareCategoryIDsFunc = func(ctx context.Context, names []string) ([]uint, error) {
|
||||
return []uint{}, nil
|
||||
}
|
||||
|
|
@ -443,6 +449,9 @@ func TestGitOpsNoTeamSoftwareInstallers(t *testing.T) {
|
|||
ds.GetTeamsWithInstallerByHashFunc = func(ctx context.Context, sha256, url string) (map[uint][]*fleet.ExistingSoftwareInstaller, error) {
|
||||
return map[uint][]*fleet.ExistingSoftwareInstaller{}, nil
|
||||
}
|
||||
ds.GetInstallerByTeamAndURLFunc = func(ctx context.Context, teamID uint, url string) (*fleet.ExistingSoftwareInstaller, error) {
|
||||
return nil, nil
|
||||
}
|
||||
ds.GetSoftwareCategoryIDsFunc = func(ctx context.Context, names []string) ([]uint, error) {
|
||||
return []uint{}, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2105,6 +2105,12 @@ func (svc *Service) BatchSetSoftwareInstallers(
|
|||
"Couldn't edit software. One or more software packages is missing url or hash_sha256 fields.",
|
||||
)
|
||||
}
|
||||
if payload.AlwaysDownload && payload.SHA256 != "" {
|
||||
return "", fleet.NewInvalidArgumentError(
|
||||
"software",
|
||||
"Couldn't edit software. The 'always_download' option cannot be used with 'hash_sha256'.",
|
||||
)
|
||||
}
|
||||
if len(payload.URL) > fleet.SoftwareInstallerURLMaxLength {
|
||||
return "", fleet.NewInvalidArgumentError(
|
||||
"software.url",
|
||||
|
|
@ -2269,6 +2275,78 @@ const (
|
|||
batchSetFailedPrefix = "failed:"
|
||||
)
|
||||
|
||||
// downloadInstallerURL downloads an installer from a URL. If ifNoneMatch is
|
||||
// non-empty, the request includes an If-None-Match header for conditional GET.
|
||||
//
|
||||
// On 304 Not Modified, returns (resp, nil, nil): resp has StatusCode 304 and a
|
||||
// closed body, tfr is nil. Callers MUST check resp.StatusCode before using tfr.
|
||||
func downloadInstallerURL(ctx context.Context, downloadURL string, ifNoneMatch string, maxInstallerSize int64) (*http.Response, *fleet.TempFileReader, error) {
|
||||
client := fleethttp.NewClient()
|
||||
client.Transport = fleethttp.NewSizeLimitTransport(maxInstallerSize)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("creating request for URL %q: %w", downloadURL, err)
|
||||
}
|
||||
if ifNoneMatch != "" {
|
||||
req.Header.Set("If-None-Match", ifNoneMatch)
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
var maxBytesErr *http.MaxBytesError
|
||||
if errors.Is(err, fleethttp.ErrMaxSizeExceeded) || errors.As(err, &maxBytesErr) {
|
||||
return nil, nil, fleet.NewInvalidArgumentError(
|
||||
"software.url",
|
||||
fmt.Sprintf("Couldn't edit software. URL (%q). The maximum file size is %s", downloadURL, installersize.Human(maxInstallerSize)),
|
||||
)
|
||||
}
|
||||
|
||||
return nil, nil, fmt.Errorf("performing request for URL %q: %w", downloadURL, err)
|
||||
}
|
||||
|
||||
// 304 Not Modified: content unchanged, return response with no body.
|
||||
// Set Body to http.NoBody after closing so downstream Close() calls are safe.
|
||||
if resp.StatusCode == http.StatusNotModified {
|
||||
resp.Body.Close()
|
||||
resp.Body = http.NoBody
|
||||
return resp, nil, nil
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return nil, nil, fleet.NewInvalidArgumentError(
|
||||
"software.url",
|
||||
fmt.Sprintf("Couldn't edit software. URL (%q) returned \"Not Found\". Please make sure that URLs are reachable from your Fleet server.", downloadURL),
|
||||
)
|
||||
}
|
||||
|
||||
// Allow all 2xx and 3xx status codes in this pass.
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, nil, fleet.NewInvalidArgumentError(
|
||||
"software.url",
|
||||
fmt.Sprintf("Couldn't edit software. URL (%q) received response status code %d.", downloadURL, resp.StatusCode),
|
||||
)
|
||||
}
|
||||
|
||||
tfr, err := fleet.NewTempFileReader(resp.Body, nil)
|
||||
if err != nil {
|
||||
// the max size error can be received either at client.Do or here when
|
||||
// reading the body if it's caught via a limited body reader.
|
||||
var maxBytesErr *http.MaxBytesError
|
||||
if errors.Is(err, fleethttp.ErrMaxSizeExceeded) || errors.As(err, &maxBytesErr) {
|
||||
return nil, nil, fleet.NewInvalidArgumentError(
|
||||
"software.url",
|
||||
fmt.Sprintf("Couldn't edit software. URL (%q). The maximum file size is %s", downloadURL, installersize.Human(maxInstallerSize)),
|
||||
)
|
||||
}
|
||||
return nil, nil, fmt.Errorf("reading installer %q contents: %w", downloadURL, err)
|
||||
}
|
||||
|
||||
return resp, tfr, nil
|
||||
}
|
||||
|
||||
func (svc *Service) softwareBatchUpload(
|
||||
requestUUID string,
|
||||
teamID *uint,
|
||||
|
|
@ -2326,59 +2404,31 @@ func (svc *Service) softwareBatchUpload(
|
|||
defer close(done)
|
||||
|
||||
maxInstallerSize := svc.config.Server.MaxInstallerSizeBytes
|
||||
downloadURLFn := func(ctx context.Context, url string) (*http.Response, *fleet.TempFileReader, error) {
|
||||
client := fleethttp.NewClient()
|
||||
client.Transport = fleethttp.NewSizeLimitTransport(maxInstallerSize)
|
||||
downloadURLFn := func(ctx context.Context, downloadURL string, ifNoneMatch string) (*http.Response, *fleet.TempFileReader, error) {
|
||||
return downloadInstallerURL(ctx, downloadURL, ifNoneMatch, maxInstallerSize)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("creating request for URL %q: %w", url, err)
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
var maxBytesErr *http.MaxBytesError
|
||||
if errors.Is(err, fleethttp.ErrMaxSizeExceeded) || errors.As(err, &maxBytesErr) {
|
||||
return nil, nil, fleet.NewInvalidArgumentError(
|
||||
"software.url",
|
||||
fmt.Sprintf("Couldn't edit software. URL (%q). The maximum file size is %s", url, installersize.Human(maxInstallerSize)),
|
||||
)
|
||||
// retryDownload wraps downloadURLFn with the standard retry policy.
|
||||
// Note: a 304 response returns nil error and is treated as success (not retried).
|
||||
retryDownload := func(ctx context.Context, downloadURL, ifNoneMatch string) (*http.Response, *fleet.TempFileReader, error) {
|
||||
var resp *http.Response
|
||||
var tfr *fleet.TempFileReader
|
||||
err := retry.Do(func() error {
|
||||
// Close resources from a previous attempt to avoid leaking
|
||||
// file descriptors, temp files, and HTTP connections.
|
||||
if tfr != nil {
|
||||
tfr.Close()
|
||||
tfr = nil
|
||||
}
|
||||
|
||||
return nil, nil, fmt.Errorf("performing request for URL %q: %w", url, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return nil, nil, fleet.NewInvalidArgumentError(
|
||||
"software.url",
|
||||
fmt.Sprintf("Couldn't edit software. URL (%q) returned \"Not Found\". Please make sure that URLs are reachable from your Fleet server.", url),
|
||||
)
|
||||
}
|
||||
|
||||
// Allow all 2xx and 3xx status codes in this pass.
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, nil, fleet.NewInvalidArgumentError(
|
||||
"software.url",
|
||||
fmt.Sprintf("Couldn't edit software. URL (%q) received response status code %d.", url, resp.StatusCode),
|
||||
)
|
||||
}
|
||||
|
||||
tfr, err := fleet.NewTempFileReader(resp.Body, nil)
|
||||
if err != nil {
|
||||
// the max size error can be received either at client.Do or here when
|
||||
// reading the body if it's caught via a limited body reader.
|
||||
var maxBytesErr *http.MaxBytesError
|
||||
if errors.Is(err, fleethttp.ErrMaxSizeExceeded) || errors.As(err, &maxBytesErr) {
|
||||
return nil, nil, fleet.NewInvalidArgumentError(
|
||||
"software.url",
|
||||
fmt.Sprintf("Couldn't edit software. URL (%q). The maximum file size is %s", url, installersize.Human(maxInstallerSize)),
|
||||
)
|
||||
if resp != nil && resp.Body != nil {
|
||||
resp.Body.Close()
|
||||
resp = nil
|
||||
}
|
||||
return nil, nil, fmt.Errorf("reading installer %q contents: %w", url, err)
|
||||
}
|
||||
|
||||
return resp, tfr, nil
|
||||
var retryErr error
|
||||
resp, tfr, retryErr = downloadURLFn(ctx, downloadURL, ifNoneMatch)
|
||||
return retryErr
|
||||
}, retry.WithMaxAttempts(fleet.BatchDownloadMaxRetries), retry.WithInterval(fleet.BatchSoftwareInstallerRetryInterval()))
|
||||
return resp, tfr, err
|
||||
}
|
||||
|
||||
var manualAgentInstall bool
|
||||
|
|
@ -2444,6 +2494,7 @@ func (svc *Service) softwareBatchUpload(
|
|||
Categories: p.Categories,
|
||||
DisplayName: p.DisplayName,
|
||||
RollbackVersion: p.RollbackVersion,
|
||||
AlwaysDownload: p.AlwaysDownload,
|
||||
}
|
||||
|
||||
var extraInstallers []*fleet.UploadSoftwareInstallerPayload
|
||||
|
|
@ -2597,40 +2648,101 @@ func (svc *Service) softwareBatchUpload(
|
|||
installer.UninstallScript = ""
|
||||
installer.PreInstallQuery = ""
|
||||
} else {
|
||||
var resp *http.Response
|
||||
err = retry.Do(func() error {
|
||||
var retryErr error
|
||||
resp, tfr, retryErr = downloadURLFn(ctx, p.URL)
|
||||
if retryErr != nil {
|
||||
return retryErr
|
||||
// Conditional GET (default behavior, disabled by always_download: true).
|
||||
// Look up existing installer by URL for its ETag, only when
|
||||
// we're about to download (avoids wasted DB queries).
|
||||
var existingForCache *fleet.ExistingSoftwareInstaller
|
||||
var ifNoneMatch string
|
||||
if !p.AlwaysDownload && p.SHA256 == "" && p.URL != "" {
|
||||
existing, lookupErr := svc.ds.GetInstallerByTeamAndURL(ctx, tmID, p.URL)
|
||||
if lookupErr != nil {
|
||||
svc.logger.WarnContext(ctx, "conditional download lookup failed, will download normally", "url", p.URL, "err", lookupErr)
|
||||
} else if existing != nil && existing.StorageID != "" &&
|
||||
existing.HTTPETag != nil && *existing.HTTPETag != "" &&
|
||||
existing.Extension != "ipa" && // skip conditional download for .ipa (multi-platform extraInstallers)
|
||||
validETag(*existing.HTTPETag) { // re-validate before use as defense-in-depth
|
||||
existingForCache = existing
|
||||
ifNoneMatch = *existing.HTTPETag
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}, retry.WithMaxAttempts(fleet.BatchDownloadMaxRetries), retry.WithInterval(fleet.BatchSoftwareInstallerRetryInterval()))
|
||||
resp, tfr, err := retryDownload(ctx, p.URL, ifNoneMatch)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
installer.InstallerFile = tfr
|
||||
toBeClosedTFRs[i] = tfr
|
||||
// Handle 304 Not Modified (conditional download with matching ETag).
|
||||
// TRUST ASSUMPTION: conditional download trusts the origin server's
|
||||
// ETag as a content fingerprint, so we reuse the cached installer
|
||||
// bytes and metadata (filename, version, extension, etc.) without
|
||||
// re-extraction. Flow continues past the download-specific code so
|
||||
// that script fields from the user's GitOps config still pass
|
||||
// through the shared normalization/validation below.
|
||||
var cacheHit bool
|
||||
if resp != nil && resp.StatusCode == http.StatusNotModified && existingForCache != nil {
|
||||
bytesExist, existErr := svc.softwareInstallStore.Exists(ctx, existingForCache.StorageID)
|
||||
if existErr == nil && bytesExist {
|
||||
fillSoftwareInstallerPayloadFromExisting(installer, existingForCache, existingForCache.StorageID)
|
||||
installer.HTTPETag = existingForCache.HTTPETag
|
||||
// Propagate the existing hash so FMA hydration below
|
||||
// doesn't try to recompute it from the (nil) file
|
||||
// reader when the manifest uses noCheckHash.
|
||||
if p.MaintainedApp != nil {
|
||||
p.MaintainedApp.SHA256 = existingForCache.StorageID
|
||||
}
|
||||
cacheHit = true
|
||||
} else {
|
||||
svc.logger.WarnContext(ctx, "304 received but installer bytes missing, re-downloading", "url", p.URL)
|
||||
resp, tfr, err = retryDownload(ctx, p.URL, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp != nil && resp.StatusCode == http.StatusNotModified {
|
||||
return fmt.Errorf("server returned 304 on unconditional re-download of %q", p.URL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
filename := maintained_apps.FilenameFromResponse(resp)
|
||||
installer.Filename = filename
|
||||
if !cacheHit {
|
||||
// Protocol violation guards: downloadURLFn never returns nil resp
|
||||
// on success, but guard defensively for server misbehavior.
|
||||
if resp == nil || tfr == nil {
|
||||
statusCode := 0
|
||||
if resp != nil {
|
||||
statusCode = resp.StatusCode
|
||||
}
|
||||
return fmt.Errorf("download of %q returned no body (status %d)", p.URL, statusCode)
|
||||
}
|
||||
|
||||
// For script packages (.sh and .ps1) and in-house apps (.ipa), clear
|
||||
// unsupported fields early. Determine extension from filename to
|
||||
// validate before metadata extraction.
|
||||
ext := strings.ToLower(filepath.Ext(filename))
|
||||
ext = strings.TrimPrefix(ext, ".")
|
||||
if fleet.IsScriptPackage(ext) {
|
||||
installer.PostInstallScript = ""
|
||||
installer.UninstallScript = ""
|
||||
installer.PreInstallQuery = ""
|
||||
} else if ext == "ipa" {
|
||||
installer.InstallScript = ""
|
||||
installer.PostInstallScript = ""
|
||||
installer.UninstallScript = ""
|
||||
installer.PreInstallQuery = ""
|
||||
installer.InstallerFile = tfr
|
||||
toBeClosedTFRs[i] = tfr
|
||||
|
||||
filename := maintained_apps.FilenameFromResponse(resp)
|
||||
installer.Filename = filename
|
||||
|
||||
// Always capture ETag from download response so it's available
|
||||
// immediately if always_download is later disabled.
|
||||
if etag := resp.Header.Get("ETag"); etag != "" && validETag(etag) {
|
||||
installer.HTTPETag = &etag
|
||||
} else {
|
||||
svc.logger.DebugContext(ctx, "no usable ETag from server for conditional download", "url", p.URL, "etag", resp.Header.Get("ETag"))
|
||||
}
|
||||
|
||||
// For script packages (.sh and .ps1) and in-house apps (.ipa),
|
||||
// clear unsupported fields early. Determine extension from
|
||||
// filename to validate before metadata extraction.
|
||||
ext := strings.ToLower(filepath.Ext(filename))
|
||||
ext = strings.TrimPrefix(ext, ".")
|
||||
if fleet.IsScriptPackage(ext) {
|
||||
installer.PostInstallScript = ""
|
||||
installer.UninstallScript = ""
|
||||
installer.PreInstallQuery = ""
|
||||
} else if ext == "ipa" {
|
||||
installer.InstallScript = ""
|
||||
installer.PostInstallScript = ""
|
||||
installer.UninstallScript = ""
|
||||
installer.PreInstallQuery = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2869,6 +2981,38 @@ func fillSoftwareInstallerPayloadFromExisting(payload *fleet.UploadSoftwareInsta
|
|||
payload.PackageIDs = existing.PackageIDs
|
||||
}
|
||||
|
||||
// validETag checks if an ETag value is a strong ETag per RFC 7232
|
||||
// section 2.3: a quoted opaque-tag without the weak validator prefix.
|
||||
// Weak ETags (W/"...") are rejected because they indicate semantic
|
||||
// equivalence rather than byte-for-byte identity, which is insufficient
|
||||
// for validating cached binary installers.
|
||||
// The opaque-tag body must consist of RFC 7232 etagc characters
|
||||
// (%x21 / %x23-7E), which excludes control chars, spaces, inner DQUOTEs,
|
||||
// and DEL. We reject obs-text (>0x7F) for defense-in-depth. Values over
|
||||
// 512 bytes are rejected.
|
||||
func validETag(etag string) bool {
|
||||
if len(etag) > 512 {
|
||||
return false
|
||||
}
|
||||
// Reject weak ETags — they don't guarantee byte-identical content.
|
||||
if strings.HasPrefix(etag, "W/") {
|
||||
return false
|
||||
}
|
||||
e := etag
|
||||
if len(e) < 2 || e[0] != '"' || e[len(e)-1] != '"' {
|
||||
return false
|
||||
}
|
||||
for i := 1; i < len(e)-1; i++ {
|
||||
c := e[i]
|
||||
// RFC 7232 etagc = %x21 / %x23-7E / obs-text. Reject obs-text
|
||||
// (>0x7F) for defense-in-depth.
|
||||
if c != 0x21 && (c < 0x23 || c > 0x7E) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (svc *Service) GetBatchSetSoftwareInstallersResult(ctx context.Context, tmName string, requestUUID string, dryRun bool) (string, string, []fleet.SoftwarePackageResponse, error) {
|
||||
// We've already authorized in the POST /api/latest/fleet/software/batch,
|
||||
// but adding it here so we don't need to worry about a special case endpoint.
|
||||
|
|
|
|||
|
|
@ -1019,6 +1019,156 @@ func TestSelfServiceInstallSoftwareTitleFailsOnPersonallyEnrolledDevices(t *test
|
|||
}
|
||||
}
|
||||
|
||||
func TestConditionalGETBehavior(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
content := []byte("#!/bin/bash\necho 'test'\n")
|
||||
etag := fmt.Sprintf(`"%x"`, sha256.Sum256(content))
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
ifNoneMatch string
|
||||
handler http.HandlerFunc
|
||||
expectStatus int
|
||||
expectBodyNil bool
|
||||
expectErr bool
|
||||
}{
|
||||
{
|
||||
name: "no If-None-Match, normal 200 response",
|
||||
ifNoneMatch: "",
|
||||
handler: func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Empty(t, r.Header.Get("If-None-Match"))
|
||||
w.Header().Set("ETag", etag)
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="app.sh"`)
|
||||
_, _ = w.Write(content)
|
||||
},
|
||||
expectStatus: 200,
|
||||
expectBodyNil: false,
|
||||
},
|
||||
{
|
||||
name: "If-None-Match sent, server returns 304",
|
||||
ifNoneMatch: etag,
|
||||
handler: func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, etag, r.Header.Get("If-None-Match"))
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
},
|
||||
expectStatus: 304,
|
||||
expectBodyNil: true,
|
||||
},
|
||||
{
|
||||
name: "If-None-Match sent, server returns 200 (ETag changed)",
|
||||
ifNoneMatch: `"old-etag"`,
|
||||
handler: func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, `"old-etag"`, r.Header.Get("If-None-Match"))
|
||||
w.Header().Set("ETag", etag)
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="app.sh"`)
|
||||
_, _ = w.Write(content)
|
||||
},
|
||||
expectStatus: 200,
|
||||
expectBodyNil: false,
|
||||
},
|
||||
{
|
||||
name: "If-None-Match sent, server returns 403",
|
||||
ifNoneMatch: etag,
|
||||
handler: func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
},
|
||||
expectStatus: 0,
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
name: "If-None-Match sent, server returns 500",
|
||||
ifNoneMatch: etag,
|
||||
handler: func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
},
|
||||
expectStatus: 0,
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
name: "If-None-Match with S3 multipart ETag",
|
||||
ifNoneMatch: `"8fabd6dcf50afffcafbd5c1dbc5f49a4-20"`,
|
||||
handler: func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, `"8fabd6dcf50afffcafbd5c1dbc5f49a4-20"`, r.Header.Get("If-None-Match"))
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
},
|
||||
expectStatus: 304,
|
||||
expectBodyNil: true,
|
||||
},
|
||||
{
|
||||
name: "server returns no ETag, normal download",
|
||||
ifNoneMatch: "",
|
||||
handler: func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="app.sh"`)
|
||||
_, _ = w.Write(content)
|
||||
},
|
||||
expectStatus: 200,
|
||||
expectBodyNil: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
srv := httptest.NewServer(tt.handler)
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
const maxSize = 512 * 1024 * 1024 // 512 MiB, generous for test payloads
|
||||
resp, tfr, err := downloadInstallerURL(t.Context(), srv.URL+"/test.sh", tt.ifNoneMatch, maxSize)
|
||||
if tt.expectErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, tt.expectStatus, resp.StatusCode)
|
||||
if tt.expectBodyNil {
|
||||
assert.Nil(t, tfr)
|
||||
} else {
|
||||
require.NotNil(t, tfr)
|
||||
t.Cleanup(func() { tfr.Close() })
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidETag(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
valid bool
|
||||
}{
|
||||
{"strong ETag", `"abc123"`, true},
|
||||
{"weak ETag rejected", `W/"abc123"`, false},
|
||||
{"empty quotes", `""`, true},
|
||||
{"S3 multipart", `"8fabd6dcf50afffcafbd5c1dbc5f49a4-20"`, true},
|
||||
{"unquoted", `abc123`, false},
|
||||
{"single quote", `"`, false},
|
||||
{"empty string", ``, false},
|
||||
{"missing closing quote", `"abc`, false},
|
||||
{"control char (newline)", "\"abc\n\"", false},
|
||||
{"control char (carriage return)", "\"abc\r\"", false},
|
||||
{"control char (null)", "\"abc\x00\"", false},
|
||||
{"DEL character", "\"abc\x7f\"", false},
|
||||
{"tab rejected per RFC 7232", "\"abc\t123\"", false},
|
||||
{"inner double-quote rejected", `"abc"def"`, false},
|
||||
{"inner space rejected per RFC 7232", `"abc def"`, false},
|
||||
{"weak prefix unquoted inner", `W/abc123`, false},
|
||||
{"oversized (>512)", `"` + strings.Repeat("a", 512) + `"`, false},
|
||||
{"exactly 511 bytes", `"` + strings.Repeat("a", 509) + `"`, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
assert.Equal(t, tt.valid, validETag(tt.input))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetInstallScript(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
package tables
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
func init() {
|
||||
MigrationClient.AddMigration(Up_20260410173222, Down_20260410173222)
|
||||
}
|
||||
|
||||
func Up_20260410173222(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`ALTER TABLE software_installers ADD COLUMN http_etag VARCHAR(512) COLLATE utf8mb4_unicode_ci DEFAULT NULL`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to add http_etag column to software_installers: %w", err)
|
||||
}
|
||||
// Index prefix url(255) is a MySQL limitation for InnoDB key length.
|
||||
// URLs longer than 255 bytes are still matched correctly (full row comparison)
|
||||
// but with reduced index selectivity.
|
||||
_, err = tx.Exec(`CREATE INDEX idx_software_installers_team_url ON software_installers (global_or_team_id, url(255))`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to add team+url index to software_installers: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Down_20260410173222 is a no-op. Fleet convention: down migrations return nil
|
||||
// because forward-only migrations are safer than attempting rollback DDL.
|
||||
func Down_20260410173222(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -2331,10 +2331,12 @@ INSERT INTO software_installers (
|
|||
install_during_setup,
|
||||
fleet_maintained_app_id,
|
||||
is_active,
|
||||
http_etag,
|
||||
patch_query
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
|
||||
(SELECT name FROM users WHERE id = ?), (SELECT email FROM users WHERE id = ?), ?, ?, COALESCE(?, false), ?, ?, ?
|
||||
(SELECT name FROM users WHERE id = ?), (SELECT email FROM users WHERE id = ?), ?, ?, COALESCE(?, false), ?, ?,
|
||||
?, ?
|
||||
)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
install_script_content_id = VALUES(install_script_content_id),
|
||||
|
|
@ -2354,6 +2356,7 @@ ON DUPLICATE KEY UPDATE
|
|||
url = VALUES(url),
|
||||
install_during_setup = COALESCE(?, install_during_setup),
|
||||
is_active = VALUES(is_active),
|
||||
http_etag = VALUES(http_etag),
|
||||
patch_query = VALUES(patch_query)
|
||||
`
|
||||
|
||||
|
|
@ -2778,6 +2781,7 @@ WHERE
|
|||
isActive = 1
|
||||
}
|
||||
|
||||
// Args match insertNewOrEditedInstaller column order.
|
||||
args := []interface{}{
|
||||
tmID,
|
||||
globalOrTeamID,
|
||||
|
|
@ -2794,15 +2798,16 @@ WHERE
|
|||
installer.UpgradeCode,
|
||||
titleID,
|
||||
installer.UserID,
|
||||
installer.UserID,
|
||||
installer.UserID,
|
||||
installer.UserID, // user_name subselect
|
||||
installer.UserID, // user_email subselect
|
||||
installer.URL,
|
||||
strings.Join(installer.PackageIDs, ","),
|
||||
installer.InstallDuringSetup,
|
||||
installer.FleetMaintainedAppID,
|
||||
isActive,
|
||||
installer.HTTPETag,
|
||||
installer.PatchQuery,
|
||||
installer.InstallDuringSetup,
|
||||
installer.InstallDuringSetup, // ON DUPLICATE KEY
|
||||
}
|
||||
// For FMA installers, skip the insert if this exact version is already cached
|
||||
// for this team+title. This prevents duplicate rows from repeated batch sets
|
||||
|
|
@ -3547,15 +3552,17 @@ func (ds *Datastore) GetTeamsWithInstallerByHash(ctx context.Context, sha256, ur
|
|||
stmt := `
|
||||
SELECT
|
||||
si.id AS installer_id,
|
||||
si.team_id AS team_id,
|
||||
si.filename AS filename,
|
||||
si.extension AS extension,
|
||||
si.version AS version,
|
||||
si.platform AS platform,
|
||||
st.source AS source,
|
||||
st.bundle_identifier AS bundle_identifier,
|
||||
si.team_id,
|
||||
si.storage_id,
|
||||
si.http_etag,
|
||||
si.filename,
|
||||
si.extension,
|
||||
si.version,
|
||||
si.platform,
|
||||
st.source,
|
||||
st.bundle_identifier,
|
||||
st.name AS title,
|
||||
si.package_ids AS package_ids
|
||||
si.package_ids
|
||||
FROM
|
||||
software_installers si
|
||||
JOIN software_titles st ON si.title_id = st.id
|
||||
|
|
@ -3566,13 +3573,15 @@ UNION ALL
|
|||
|
||||
SELECT
|
||||
iha.id AS installer_id,
|
||||
iha.team_id AS team_id,
|
||||
iha.filename AS filename,
|
||||
iha.team_id,
|
||||
iha.storage_id,
|
||||
NULL AS http_etag,
|
||||
iha.filename,
|
||||
'ipa' AS extension,
|
||||
iha.version AS version,
|
||||
iha.platform AS platform,
|
||||
st.source AS source,
|
||||
st.bundle_identifier AS bundle_identifier,
|
||||
iha.version,
|
||||
iha.platform,
|
||||
st.source,
|
||||
st.bundle_identifier,
|
||||
st.name AS title,
|
||||
'' AS package_ids
|
||||
FROM
|
||||
|
|
@ -3615,6 +3624,45 @@ WHERE
|
|||
return byTeam, nil
|
||||
}
|
||||
|
||||
func (ds *Datastore) GetInstallerByTeamAndURL(ctx context.Context, teamID uint, url string) (*fleet.ExistingSoftwareInstaller, error) {
|
||||
stmt := `
|
||||
SELECT
|
||||
si.id AS installer_id,
|
||||
si.team_id AS team_id,
|
||||
si.storage_id AS storage_id,
|
||||
si.filename AS filename,
|
||||
si.extension AS extension,
|
||||
si.version AS version,
|
||||
si.platform AS platform,
|
||||
st.source AS source,
|
||||
st.bundle_identifier AS bundle_identifier,
|
||||
st.name AS title,
|
||||
si.package_ids AS package_ids,
|
||||
si.http_etag AS http_etag
|
||||
FROM
|
||||
software_installers si
|
||||
JOIN software_titles st ON si.title_id = st.id
|
||||
WHERE
|
||||
si.global_or_team_id = ? AND si.url = ? AND si.is_active = 1
|
||||
ORDER BY si.id DESC
|
||||
LIMIT 1
|
||||
`
|
||||
var installer fleet.ExistingSoftwareInstaller
|
||||
// Use reader: the installer was written in a previous GitOps run. On rapid sequential
|
||||
// runs, the replica may be slightly stale, which results in a cache miss (full download)
|
||||
// rather than incorrect behavior. This is an acceptable trade-off vs loading the writer.
|
||||
if err := sqlx.GetContext(ctx, ds.reader(ctx), &installer, stmt, teamID, url); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, ctxerr.Wrap(ctx, err, "get installer by team and URL")
|
||||
}
|
||||
if installer.PackageIDList != "" {
|
||||
installer.PackageIDs = strings.Split(installer.PackageIDList, ",")
|
||||
}
|
||||
return &installer, nil
|
||||
}
|
||||
|
||||
func (ds *Datastore) checkSoftwareConflictsByIdentifier(ctx context.Context, payload *fleet.UploadSoftwareInstallerPayload) error {
|
||||
// if this is an in-house app, check if an installer exists
|
||||
if payload.Extension == "ipa" {
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ func TestSoftwareInstallers(t *testing.T) {
|
|||
{"AddSoftwareTitleToMatchingSoftware", testAddSoftwareTitleToMatchingSoftware},
|
||||
{"FleetMaintainedAppInstallerUpdates", testFleetMaintainedAppInstallerUpdates},
|
||||
{"RepointCustomPackagePolicyToNewInstaller", testRepointPolicyToNewInstaller},
|
||||
{"GetInstallerByTeamAndURL", testGetInstallerByTeamAndURL},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
|
|
@ -3450,6 +3451,7 @@ func testGetTeamsWithInstallerByHash(t *testing.T, ds *Datastore) {
|
|||
require.Equal(t, "pkg", i.Extension)
|
||||
require.Equal(t, "1.0", i.Version)
|
||||
require.Equal(t, "darwin", i.Platform)
|
||||
require.Equal(t, hash1, i.StorageID)
|
||||
}
|
||||
|
||||
installers, err = ds.GetTeamsWithInstallerByHash(ctx, hash2, "https://example.com/2")
|
||||
|
|
@ -3473,6 +3475,8 @@ func testGetTeamsWithInstallerByHash(t *testing.T, ds *Datastore) {
|
|||
var foundPlatforms []string
|
||||
for _, inst := range installers[team1.ID] {
|
||||
foundPlatforms = append(foundPlatforms, inst.Platform)
|
||||
require.Equal(t, "inhouse", inst.StorageID)
|
||||
require.Nil(t, inst.HTTPETag) // in-house apps don't have ETags
|
||||
}
|
||||
require.ElementsMatch(t, []string{"ios", "ipados"}, foundPlatforms)
|
||||
|
||||
|
|
@ -4606,3 +4610,127 @@ func testRepointPolicyToNewInstaller(t *testing.T, ds *Datastore) {
|
|||
require.Equal(t, metadata.InstallerID, *policyAfterUpdate.SoftwareInstallerID)
|
||||
})
|
||||
}
|
||||
|
||||
func testGetInstallerByTeamAndURL(t *testing.T, ds *Datastore) {
|
||||
ctx := context.Background()
|
||||
user := test.NewUser(t, ds, "Alice", "alice@example.com", true)
|
||||
team1, err := ds.NewTeam(ctx, &fleet.Team{Name: "team 1"})
|
||||
require.NoError(t, err)
|
||||
team2, err := ds.NewTeam(ctx, &fleet.Team{Name: "team 2"})
|
||||
require.NoError(t, err)
|
||||
|
||||
tfr, err := fleet.NewTempFileReader(strings.NewReader("hello"), t.TempDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
etag := `"abc123"`
|
||||
|
||||
err = ds.BatchSetSoftwareInstallers(ctx, &team1.ID, []*fleet.UploadSoftwareInstallerPayload{
|
||||
{
|
||||
InstallerFile: tfr,
|
||||
BundleIdentifier: "com.example.app",
|
||||
Extension: "pkg",
|
||||
StorageID: "hash1",
|
||||
Filename: "app.pkg",
|
||||
Title: "App",
|
||||
Version: "1.0",
|
||||
Source: "apps",
|
||||
UserID: user.ID,
|
||||
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||
TeamID: &team1.ID,
|
||||
Platform: "darwin",
|
||||
URL: "https://example.com/app/latest",
|
||||
HTTPETag: &etag,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Correct team and URL returns the installer with ETag
|
||||
existing, err := ds.GetInstallerByTeamAndURL(ctx, team1.ID, "https://example.com/app/latest")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, existing)
|
||||
assert.Equal(t, "hash1", existing.StorageID)
|
||||
assert.Equal(t, "app.pkg", existing.Filename)
|
||||
require.NotNil(t, existing.HTTPETag)
|
||||
assert.Equal(t, etag, *existing.HTTPETag)
|
||||
|
||||
// Wrong team returns nil
|
||||
existing, err = ds.GetInstallerByTeamAndURL(ctx, team2.ID, "https://example.com/app/latest")
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, existing)
|
||||
|
||||
// Wrong URL returns nil
|
||||
existing, err = ds.GetInstallerByTeamAndURL(ctx, team1.ID, "https://example.com/other")
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, existing)
|
||||
|
||||
// URL with query params (GlobalProtect pattern)
|
||||
err = ds.BatchSetSoftwareInstallers(ctx, &team1.ID, []*fleet.UploadSoftwareInstallerPayload{
|
||||
{
|
||||
InstallerFile: tfr,
|
||||
BundleIdentifier: "com.example.gp",
|
||||
Extension: "msi",
|
||||
StorageID: "hash2",
|
||||
Filename: "gp.msi",
|
||||
Title: "GlobalProtect",
|
||||
Version: "1.0",
|
||||
Source: "programs",
|
||||
UserID: user.ID,
|
||||
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||
TeamID: &team1.ID,
|
||||
Platform: "windows",
|
||||
URL: "https://example.com/gp?version=64&platform=windows",
|
||||
HTTPETag: &etag,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
existing, err = ds.GetInstallerByTeamAndURL(ctx, team1.ID, "https://example.com/gp?version=64&platform=windows")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, existing)
|
||||
assert.Equal(t, "hash2", existing.StorageID)
|
||||
|
||||
// Simulate an FMA rollback: two rows for the same (team, URL), where the
|
||||
// inactive row has a higher id than the active row. The lookup must return
|
||||
// the active row even though ORDER BY id DESC would otherwise pick the
|
||||
// inactive one.
|
||||
rollbackURL := "https://example.com/rollback"
|
||||
err = ds.BatchSetSoftwareInstallers(ctx, &team2.ID, []*fleet.UploadSoftwareInstallerPayload{
|
||||
{
|
||||
InstallerFile: tfr,
|
||||
BundleIdentifier: "com.example.rb",
|
||||
Extension: "pkg",
|
||||
StorageID: "active_hash",
|
||||
Filename: "rb.pkg",
|
||||
Title: "Rollback",
|
||||
Version: "1.0",
|
||||
Source: "apps",
|
||||
UserID: user.ID,
|
||||
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||
TeamID: &team2.ID,
|
||||
Platform: "darwin",
|
||||
URL: rollbackURL,
|
||||
HTTPETag: &etag,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
inactiveETag := `"inactive-etag"`
|
||||
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
||||
_, err := q.ExecContext(ctx, `
|
||||
INSERT INTO software_installers
|
||||
(team_id, global_or_team_id, storage_id, filename, extension, version, platform, title_id,
|
||||
install_script_content_id, uninstall_script_content_id, is_active, url, package_ids, patch_query, http_etag)
|
||||
SELECT team_id, global_or_team_id, 'inactive_hash', filename, extension, 'old_version', platform, title_id,
|
||||
install_script_content_id, uninstall_script_content_id, 0, url, package_ids, patch_query, ?
|
||||
FROM software_installers WHERE team_id = ? AND url = ?
|
||||
`, inactiveETag, team2.ID, rollbackURL)
|
||||
return err
|
||||
})
|
||||
|
||||
existing, err = ds.GetInstallerByTeamAndURL(ctx, team2.ID, rollbackURL)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, existing)
|
||||
assert.Equal(t, "active_hash", existing.StorageID, "must return the active installer, not the inactive duplicate")
|
||||
require.NotNil(t, existing.HTTPETag)
|
||||
assert.Equal(t, etag, *existing.HTTPETag)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2402,6 +2402,11 @@ type Datastore interface {
|
|||
// metadata by the installer's hash.
|
||||
GetTeamsWithInstallerByHash(ctx context.Context, sha256, url string) (map[uint][]*ExistingSoftwareInstaller, error)
|
||||
|
||||
// GetInstallerByTeamAndURL looks up an existing software installer by team
|
||||
// and URL. Returns the most recently inserted installer matching the team and
|
||||
// URL, including its storage_id (SHA256) and http_etag for conditional downloads.
|
||||
GetInstallerByTeamAndURL(ctx context.Context, teamID uint, url string) (*ExistingSoftwareInstaller, error)
|
||||
|
||||
// TeamIDsWithSetupExperienceIdPEnabled returns the list of team IDs that
|
||||
// have the setup experience IdP (End user authentication) enabled. It uses
|
||||
// id 0 to represent "No team", should IdP be enabled for that team.
|
||||
|
|
|
|||
|
|
@ -626,6 +626,8 @@ type SoftwareInstallerPayload struct {
|
|||
|
||||
IconPath string `json:"-"`
|
||||
IconHash string `json:"-"`
|
||||
// AlwaysDownload disables conditional HTTP downloads using ETag headers.
|
||||
AlwaysDownload bool `json:"always_download"`
|
||||
}
|
||||
|
||||
type HostLockWipeStatus struct {
|
||||
|
|
|
|||
|
|
@ -542,7 +542,13 @@ type UploadSoftwareInstallerPayload struct {
|
|||
// automatically created when a software installer is added to Fleet. This field should be set
|
||||
// after software installer creation if AutomaticInstall is true.
|
||||
AddedAutomaticInstallPolicy *Policy
|
||||
PatchQuery string
|
||||
// AlwaysDownload disables conditional HTTP downloads using ETag. When false
|
||||
// (the default), the download request includes If-None-Match with the stored ETag.
|
||||
AlwaysDownload bool
|
||||
// HTTPETag stores the ETag from the last download response, used for
|
||||
// conditional GET requests when AlwaysDownload is false.
|
||||
HTTPETag *string
|
||||
PatchQuery string
|
||||
}
|
||||
|
||||
func (p UploadSoftwareInstallerPayload) UniqueIdentifier() string {
|
||||
|
|
@ -587,6 +593,8 @@ type ExistingSoftwareInstaller struct {
|
|||
Title string `db:"title"`
|
||||
PackageIDList string `db:"package_ids"`
|
||||
PackageIDs []string
|
||||
StorageID string `db:"storage_id"`
|
||||
HTTPETag *string `db:"http_etag"`
|
||||
}
|
||||
|
||||
type UpdateSoftwareInstallerPayload struct {
|
||||
|
|
@ -817,6 +825,11 @@ type SoftwarePackageSpec struct {
|
|||
SHA256 string `json:"hash_sha256"`
|
||||
Categories []string `json:"categories"`
|
||||
DisplayName string `json:"display_name,omitempty"`
|
||||
// AlwaysDownload disables conditional HTTP downloads using ETag headers.
|
||||
// When false (the default), Fleet sends If-None-Match with the stored ETag
|
||||
// on subsequent downloads. If the server returns 304 Not Modified, the
|
||||
// download is skipped entirely.
|
||||
AlwaysDownload bool `json:"always_download"`
|
||||
}
|
||||
|
||||
func (spec SoftwarePackageSpec) ResolveSoftwarePackagePaths(baseDir string) SoftwarePackageSpec {
|
||||
|
|
|
|||
|
|
@ -1531,6 +1531,8 @@ type GetHostAwaitingConfigurationFunc func(ctx context.Context, hostUUID string)
|
|||
|
||||
type GetTeamsWithInstallerByHashFunc func(ctx context.Context, sha256 string, url string) (map[uint][]*fleet.ExistingSoftwareInstaller, error)
|
||||
|
||||
type GetInstallerByTeamAndURLFunc func(ctx context.Context, teamID uint, url string) (*fleet.ExistingSoftwareInstaller, error)
|
||||
|
||||
type TeamIDsWithSetupExperienceIdPEnabledFunc func(ctx context.Context) ([]uint, error)
|
||||
|
||||
type ListSetupExperienceResultsByHostUUIDFunc func(ctx context.Context, hostUUID string, teamID uint) ([]*fleet.SetupExperienceStatusResult, error)
|
||||
|
|
@ -4122,6 +4124,9 @@ type DataStore struct {
|
|||
GetTeamsWithInstallerByHashFunc GetTeamsWithInstallerByHashFunc
|
||||
GetTeamsWithInstallerByHashFuncInvoked bool
|
||||
|
||||
GetInstallerByTeamAndURLFunc GetInstallerByTeamAndURLFunc
|
||||
GetInstallerByTeamAndURLFuncInvoked bool
|
||||
|
||||
TeamIDsWithSetupExperienceIdPEnabledFunc TeamIDsWithSetupExperienceIdPEnabledFunc
|
||||
TeamIDsWithSetupExperienceIdPEnabledFuncInvoked bool
|
||||
|
||||
|
|
@ -9895,6 +9900,13 @@ func (s *DataStore) GetTeamsWithInstallerByHash(ctx context.Context, sha256 stri
|
|||
return s.GetTeamsWithInstallerByHashFunc(ctx, sha256, url)
|
||||
}
|
||||
|
||||
func (s *DataStore) GetInstallerByTeamAndURL(ctx context.Context, teamID uint, url string) (*fleet.ExistingSoftwareInstaller, error) {
|
||||
s.mu.Lock()
|
||||
s.GetInstallerByTeamAndURLFuncInvoked = true
|
||||
s.mu.Unlock()
|
||||
return s.GetInstallerByTeamAndURLFunc(ctx, teamID, url)
|
||||
}
|
||||
|
||||
func (s *DataStore) TeamIDsWithSetupExperienceIdPEnabled(ctx context.Context) ([]uint, error) {
|
||||
s.mu.Lock()
|
||||
s.TeamIDsWithSetupExperienceIdPEnabledFuncInvoked = true
|
||||
|
|
|
|||
|
|
@ -1174,6 +1174,10 @@ func validateTeamOrNoTeamMacOSSetupSoftware(teamName string, macOSSetupSoftware
|
|||
func buildSoftwarePackagesPayload(specs []fleet.SoftwarePackageSpec, installDuringSetupKeys map[fleet.MacOSSetupSoftware]struct{}) ([]fleet.SoftwareInstallerPayload, error) {
|
||||
softwarePayloads := make([]fleet.SoftwareInstallerPayload, len(specs))
|
||||
for i, si := range specs {
|
||||
if si.AlwaysDownload && si.SHA256 != "" {
|
||||
return nil, errors.New("Couldn't edit software. The 'always_download' option cannot be used with 'hash_sha256'.")
|
||||
}
|
||||
|
||||
var qc string
|
||||
var err error
|
||||
|
||||
|
|
@ -1304,6 +1308,7 @@ func buildSoftwarePackagesPayload(specs []fleet.SoftwarePackageSpec, installDuri
|
|||
DisplayName: si.DisplayName,
|
||||
IconPath: si.Icon.Path,
|
||||
IconHash: iconHash,
|
||||
AlwaysDownload: si.AlwaysDownload,
|
||||
}
|
||||
|
||||
if si.Slug != nil {
|
||||
|
|
|
|||
|
|
@ -14459,6 +14459,212 @@ func (s *integrationEnterpriseTestSuite) TestBatchSetSoftwareInstallersSideEffec
|
|||
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/install/%s/results", installUUID), nil, http.StatusNotFound, &installDetailsResp)
|
||||
}
|
||||
|
||||
func (s *integrationEnterpriseTestSuite) TestBatchSetSoftwareInstallersConditionalDownload() {
|
||||
t := s.T()
|
||||
|
||||
tm, err := s.ds.NewTeam(context.Background(), &fleet.Team{
|
||||
Name: t.Name(),
|
||||
Description: "desc",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
requestCount int
|
||||
ifNoneMatchSet = []string{}
|
||||
etag = `"ruby-deb-v1"`
|
||||
trailer = "" // appended to the body to force a different content hash
|
||||
)
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mu.Lock()
|
||||
requestCount++
|
||||
ifNoneMatchSet = append(ifNoneMatchSet, r.Header.Get("If-None-Match"))
|
||||
currentETag := etag
|
||||
currentTrailer := trailer
|
||||
mu.Unlock()
|
||||
|
||||
// Honor the conditional request: if the client sends our current
|
||||
// ETag, return 304 with no body.
|
||||
if r.Header.Get("If-None-Match") == currentETag {
|
||||
w.Header().Set("ETag", currentETag)
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
|
||||
file, err := os.Open(filepath.Join("testdata", "software-installers", "ruby.deb"))
|
||||
if !assert.NoError(t, err) {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
w.Header().Set("Content-Type", "application/vnd.debian.binary-package")
|
||||
w.Header().Set("ETag", currentETag)
|
||||
_, err = io.Copy(w, file)
|
||||
assert.NoError(t, err)
|
||||
_, err = w.Write([]byte(currentTrailer))
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
srv := httptest.NewServer(handler)
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
// First run: full download, Fleet should capture the ETag and storage_id.
|
||||
softwareToInstall := []*fleet.SoftwareInstallerPayload{{URL: srv.URL}}
|
||||
var batchResponse batchSetSoftwareInstallersResponse
|
||||
s.DoJSON("POST", "/api/latest/fleet/software/batch",
|
||||
batchSetSoftwareInstallersRequest{Software: softwareToInstall},
|
||||
http.StatusAccepted, &batchResponse, "team_name", tm.Name)
|
||||
packages := waitBatchSetSoftwareInstallersCompleted(t, &s.withServer, tm.Name, batchResponse.RequestUUID)
|
||||
require.Len(t, packages, 1)
|
||||
firstHash := packages[0].HashSHA256
|
||||
require.NotEmpty(t, firstHash)
|
||||
|
||||
titlesResp := listSoftwareTitlesResponse{}
|
||||
s.DoJSON("GET", "/api/v1/fleet/software/titles", nil, http.StatusOK, &titlesResp,
|
||||
"available_for_install", "true", "team_id", fmt.Sprint(tm.ID))
|
||||
require.Len(t, titlesResp.SoftwareTitles, 1)
|
||||
titleID := titlesResp.SoftwareTitles[0].ID
|
||||
|
||||
titleResp := getSoftwareTitleResponse{}
|
||||
s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/software/titles/%d", titleID), nil, http.StatusOK, &titleResp,
|
||||
"team_id", fmt.Sprint(tm.ID))
|
||||
firstUploadedAt := titleResp.SoftwareTitle.SoftwarePackage.UploadedAt
|
||||
|
||||
mu.Lock()
|
||||
require.Equal(t, 1, requestCount, "expected exactly one HTTP request on first run")
|
||||
require.Empty(t, ifNoneMatchSet[0], "first run must not send If-None-Match")
|
||||
mu.Unlock()
|
||||
|
||||
// Second run: same URL, no changes. The server will return 304 and Fleet
|
||||
// should reuse the cached installer (uploaded_at must not change).
|
||||
s.DoJSON("POST", "/api/latest/fleet/software/batch",
|
||||
batchSetSoftwareInstallersRequest{Software: softwareToInstall},
|
||||
http.StatusAccepted, &batchResponse, "team_name", tm.Name)
|
||||
packages = waitBatchSetSoftwareInstallersCompleted(t, &s.withServer, tm.Name, batchResponse.RequestUUID)
|
||||
require.Len(t, packages, 1)
|
||||
require.Equal(t, firstHash, packages[0].HashSHA256, "hash must match on cache hit")
|
||||
|
||||
titleResp = getSoftwareTitleResponse{}
|
||||
s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/software/titles/%d", titleID), nil, http.StatusOK, &titleResp,
|
||||
"team_id", fmt.Sprint(tm.ID))
|
||||
require.Equal(t, firstUploadedAt, titleResp.SoftwareTitle.SoftwarePackage.UploadedAt,
|
||||
"uploaded_at must not change on 304 cache hit")
|
||||
|
||||
mu.Lock()
|
||||
require.Equal(t, 2, requestCount, "expected one additional HTTP request on second run")
|
||||
require.Equal(t, etag, ifNoneMatchSet[1], "second run must send If-None-Match with stored ETag")
|
||||
mu.Unlock()
|
||||
|
||||
// Third run: server will again respond 304, but the user has supplied an
|
||||
// install_script that fails validation (exceeds SavedScriptMaxRuneLen).
|
||||
// The 304 fast-path must still run script validation and reject the batch.
|
||||
oversizeScript := strings.Repeat("a", fleet.SavedScriptMaxRuneLen+1)
|
||||
withBadScript := []*fleet.SoftwareInstallerPayload{{URL: srv.URL, InstallScript: oversizeScript}}
|
||||
s.DoJSON("POST", "/api/latest/fleet/software/batch",
|
||||
batchSetSoftwareInstallersRequest{Software: withBadScript},
|
||||
http.StatusAccepted, &batchResponse, "team_name", tm.Name)
|
||||
msg := waitBatchSetSoftwareInstallersFailed(t, &s.withServer, tm.Name, batchResponse.RequestUUID)
|
||||
require.Contains(t, msg, "install script")
|
||||
|
||||
// Fourth run: always_download bypasses conditional download, so the server
|
||||
// should receive a request with no If-None-Match header and return the full
|
||||
// body.
|
||||
withAlwaysDownload := []*fleet.SoftwareInstallerPayload{{URL: srv.URL, AlwaysDownload: true}}
|
||||
s.DoJSON("POST", "/api/latest/fleet/software/batch",
|
||||
batchSetSoftwareInstallersRequest{Software: withAlwaysDownload},
|
||||
http.StatusAccepted, &batchResponse, "team_name", tm.Name)
|
||||
_ = waitBatchSetSoftwareInstallersCompleted(t, &s.withServer, tm.Name, batchResponse.RequestUUID)
|
||||
|
||||
mu.Lock()
|
||||
require.GreaterOrEqual(t, requestCount, 4, "always_download must issue an HTTP request")
|
||||
require.Empty(t, ifNoneMatchSet[len(ifNoneMatchSet)-1], "always_download must not send If-None-Match")
|
||||
mu.Unlock()
|
||||
|
||||
// Fifth run: flip always_download back off. The ETag captured during the
|
||||
// previous always_download run must still be usable, so this run should
|
||||
// immediately go back to sending If-None-Match and receiving 304 — no
|
||||
// "warm-up" download required.
|
||||
titleResp = getSoftwareTitleResponse{}
|
||||
s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/software/titles/%d", titleID), nil, http.StatusOK, &titleResp,
|
||||
"team_id", fmt.Sprint(tm.ID))
|
||||
uploadedAtBeforeConditional := titleResp.SoftwareTitle.SoftwarePackage.UploadedAt
|
||||
|
||||
mu.Lock()
|
||||
countBeforeConditional := requestCount
|
||||
mu.Unlock()
|
||||
|
||||
s.DoJSON("POST", "/api/latest/fleet/software/batch",
|
||||
batchSetSoftwareInstallersRequest{Software: softwareToInstall},
|
||||
http.StatusAccepted, &batchResponse, "team_name", tm.Name)
|
||||
packages = waitBatchSetSoftwareInstallersCompleted(t, &s.withServer, tm.Name, batchResponse.RequestUUID)
|
||||
require.Len(t, packages, 1)
|
||||
|
||||
titleResp = getSoftwareTitleResponse{}
|
||||
s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/software/titles/%d", titleID), nil, http.StatusOK, &titleResp,
|
||||
"team_id", fmt.Sprint(tm.ID))
|
||||
require.Equal(t, uploadedAtBeforeConditional, titleResp.SoftwareTitle.SoftwarePackage.UploadedAt,
|
||||
"uploaded_at must not change when ETag from prior always_download run is reused")
|
||||
|
||||
mu.Lock()
|
||||
require.Equal(t, countBeforeConditional+1, requestCount, "expected one HTTP request after re-enabling conditional download")
|
||||
require.Equal(t, `"ruby-deb-v1"`, ifNoneMatchSet[countBeforeConditional],
|
||||
"must send the ETag captured during the previous always_download run")
|
||||
mu.Unlock()
|
||||
|
||||
// Sixth run: upstream content has changed — new ETag and modified body.
|
||||
// Fleet still sends the stored ETag (v1) as If-None-Match; the server
|
||||
// returns 200 with the new bytes and new ETag. Fleet must re-upload, bump
|
||||
// uploaded_at, and persist the new ETag.
|
||||
const newETag = `"ruby-deb-v2"`
|
||||
mu.Lock()
|
||||
etag = newETag
|
||||
trailer = " " // changes the body hash
|
||||
countBeforeContentChange := requestCount
|
||||
mu.Unlock()
|
||||
|
||||
titleResp = getSoftwareTitleResponse{}
|
||||
s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/software/titles/%d", titleID), nil, http.StatusOK, &titleResp,
|
||||
"team_id", fmt.Sprint(tm.ID))
|
||||
uploadedAtBeforeContentChange := titleResp.SoftwareTitle.SoftwarePackage.UploadedAt
|
||||
hashBeforeContentChange := titleResp.SoftwareTitle.SoftwarePackage.StorageID
|
||||
|
||||
s.DoJSON("POST", "/api/latest/fleet/software/batch",
|
||||
batchSetSoftwareInstallersRequest{Software: softwareToInstall},
|
||||
http.StatusAccepted, &batchResponse, "team_name", tm.Name)
|
||||
packages = waitBatchSetSoftwareInstallersCompleted(t, &s.withServer, tm.Name, batchResponse.RequestUUID)
|
||||
require.Len(t, packages, 1)
|
||||
require.NotEqual(t, hashBeforeContentChange, packages[0].HashSHA256, "hash must change when upstream content changes")
|
||||
|
||||
titleResp = getSoftwareTitleResponse{}
|
||||
s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/software/titles/%d", titleID), nil, http.StatusOK, &titleResp,
|
||||
"team_id", fmt.Sprint(tm.ID))
|
||||
require.NotEqual(t, uploadedAtBeforeContentChange, titleResp.SoftwareTitle.SoftwarePackage.UploadedAt,
|
||||
"uploaded_at must advance when bytes are re-downloaded")
|
||||
|
||||
mu.Lock()
|
||||
require.Equal(t, countBeforeContentChange+1, requestCount, "expected one HTTP request on sixth run")
|
||||
require.Equal(t, `"ruby-deb-v1"`, ifNoneMatchSet[countBeforeContentChange],
|
||||
"sixth run must send the previously stored ETag (v1) so the server can detect the mismatch")
|
||||
mu.Unlock()
|
||||
|
||||
// Seventh run: no further content change. Fleet should now be sending the
|
||||
// new ETag (v2) it captured from run 6, and the server should reply 304.
|
||||
mu.Lock()
|
||||
countBeforeFinalRun := requestCount
|
||||
mu.Unlock()
|
||||
|
||||
s.DoJSON("POST", "/api/latest/fleet/software/batch",
|
||||
batchSetSoftwareInstallersRequest{Software: softwareToInstall},
|
||||
http.StatusAccepted, &batchResponse, "team_name", tm.Name)
|
||||
packages = waitBatchSetSoftwareInstallersCompleted(t, &s.withServer, tm.Name, batchResponse.RequestUUID)
|
||||
require.Len(t, packages, 1)
|
||||
|
||||
mu.Lock()
|
||||
require.Equal(t, countBeforeFinalRun+1, requestCount, "expected one HTTP request on seventh run")
|
||||
require.Equal(t, newETag, ifNoneMatchSet[countBeforeFinalRun],
|
||||
"seventh run must send the new ETag captured from the content-change run")
|
||||
mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *integrationEnterpriseTestSuite) TestBatchSetSoftwareInstallersWithPoliciesAssociated() {
|
||||
ctx := context.Background()
|
||||
t := s.T()
|
||||
|
|
|
|||
|
|
@ -627,6 +627,10 @@ func TestSoftwareInstallerUploadRetries(t *testing.T) {
|
|||
return map[uint][]*fleet.ExistingSoftwareInstaller{}, nil
|
||||
}
|
||||
|
||||
ds.GetInstallerByTeamAndURLFunc = func(ctx context.Context, teamID uint, url string) (*fleet.ExistingSoftwareInstaller, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
ds.GetSoftwareInstallersFunc = func(ctx context.Context, tmID uint) ([]fleet.SoftwarePackageResponse, error) {
|
||||
return []fleet.SoftwarePackageResponse{}, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -119,6 +119,7 @@ github.com/fleetdm/fleet/v4/server/fleet/SoftwarePackageSpec ReferencedYamlPath
|
|||
github.com/fleetdm/fleet/v4/server/fleet/SoftwarePackageSpec SHA256 string
|
||||
github.com/fleetdm/fleet/v4/server/fleet/SoftwarePackageSpec Categories []string
|
||||
github.com/fleetdm/fleet/v4/server/fleet/SoftwarePackageSpec DisplayName string
|
||||
github.com/fleetdm/fleet/v4/server/fleet/SoftwarePackageSpec AlwaysDownload bool
|
||||
github.com/fleetdm/fleet/v4/server/fleet/SoftwareSpec FleetMaintainedApps optjson.Slice[github.com/fleetdm/fleet/v4/server/fleet.MaintainedAppSpec]
|
||||
github.com/fleetdm/fleet/v4/pkg/optjson/Slice[github.com/fleetdm/fleet/v4/server/fleet.MaintainedAppSpec] Set bool
|
||||
github.com/fleetdm/fleet/v4/pkg/optjson/Slice[github.com/fleetdm/fleet/v4/server/fleet.MaintainedAppSpec] Valid bool
|
||||
|
|
|
|||
Loading…
Reference in a new issue