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:
Dan Tsekhanskiy 2026-04-14 14:01:33 -04:00 committed by GitHub
parent b18195ba19
commit aff440236e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 865 additions and 97 deletions

View 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.

View file

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

View file

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

View file

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

View file

@ -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.

View file

@ -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()

View file

@ -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

View file

@ -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" {

View file

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

View file

@ -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.

View file

@ -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 {

View file

@ -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 {

View file

@ -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

View file

@ -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 {

View file

@ -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()

View file

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

View file

@ -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