Add backend changes for continuous automations on policies (#45999)

Resolves #45149 and #45150.

- [X] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.

- [X] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented (using placeholders for values in statements), JS
inline code is prevented especially for url redirects, and untrusted
data interpolated into shell scripts/commands is validated against shell
metacharacters.

## Testing

- [X] Added/updated automated tests
- [x] QA'd all new/changed functionality manually

## Database migrations

- [X] Checked schema for all modified table for columns that will
auto-update timestamps during migration.
- [X] Confirmed that updating the timestamps is acceptable, and will not
cause unwanted side effects.
- [X] Ensured the correct collation is explicitly set for character
columns (`COLLATE utf8mb4_unicode_ci`).

- [x] Verified that the setting is exported via `fleetctl
generate-gitops`
- [X] Verified the setting is documented in a separate PR to [the GitOps
documentation](https://github.com/fleetdm/fleet/blob/main/docs/Configuration/yaml-files.md#L485)
- [x] Verified that the setting is cleared on the server if it is not
supplied in a YAML file (or that it is documented as being optional)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added team policy setting continuous_automations_enabled (default:
false) to re-run software/script automations on every failing
evaluation; exposed in APIs and GitOps YAML. Disallowed for "All fleets"
and requires a premium license.

* **Tests**
* Added integration tests for CRUD, GitOps, and re-queuing behavior
validating continuous automations.

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/fleetdm/fleet/pull/45999?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack)

<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Lucas Manuel Rodriguez
2026-05-26 21:11:18 -03:00
committed by GitHub
parent 0a8e9fc328
commit 5b2427d187
25 changed files with 2095 additions and 724 deletions
@@ -0,0 +1,2 @@
- Added `continuous_automations_enabled` to team policies. When enabled, software and script automations run on every failing policy result instead of only on the host's first failure or a pass→fail transition.
- Surfaced `continuous_automations_enabled` in GitOps YAML (read and generated by `fleetctl generate-gitops`).
+1
View File
@@ -74,6 +74,7 @@ func TestRunApiCommand(t *testing.T) {
"calendar_events_enabled": false,
"conditional_access_enabled": false,
"type": "dynamic",
"continuous_automations_enabled": false,
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z",
"passing_host_count": 0,
+8 -7
View File
@@ -1591,13 +1591,14 @@ func (cmd *GenerateGitopsCommand) generatePolicies(teamId *uint, filePath string
result := make([]map[string]interface{}, len(policies))
for i, policy := range policies {
policySpec := map[string]interface{}{
jsonFieldName(t, "Name"): policy.Name,
jsonFieldName(t, "Description"): policy.Description,
jsonFieldName(t, "Resolution"): policy.Resolution,
jsonFieldName(t, "Platform"): policy.Platform,
jsonFieldName(t, "Critical"): policy.Critical,
jsonFieldName(t, "CalendarEventsEnabled"): policy.CalendarEventsEnabled,
jsonFieldName(t, "ConditionalAccessEnabled"): policy.ConditionalAccessEnabled,
jsonFieldName(t, "Name"): policy.Name,
jsonFieldName(t, "Description"): policy.Description,
jsonFieldName(t, "Resolution"): policy.Resolution,
jsonFieldName(t, "Platform"): policy.Platform,
jsonFieldName(t, "Critical"): policy.Critical,
jsonFieldName(t, "CalendarEventsEnabled"): policy.CalendarEventsEnabled,
jsonFieldName(t, "ConditionalAccessEnabled"): policy.ConditionalAccessEnabled,
jsonFieldName(t, "ContinuousAutomationsEnabled"): policy.ContinuousAutomationsEnabled,
}
if policy.Type == fleet.PolicyTypeDynamic {
@@ -68,6 +68,7 @@
"author_name": "Alice",
"calendar_events_enabled": true,
"conditional_access_enabled": false,
"continuous_automations_enabled": false,
"created_at": "0001-01-01T00:00:00Z",
"critical": false,
"description": "Some description",
@@ -88,6 +89,7 @@
"author_name": "Alice",
"calendar_events_enabled": false,
"conditional_access_enabled": false,
"continuous_automations_enabled": false,
"created_at": "0001-01-01T00:00:00Z",
"critical": false,
"description": "",
@@ -66,6 +66,7 @@ spec:
author_name: Alice
calendar_events_enabled: true
conditional_access_enabled: false
continuous_automations_enabled: false
created_at: "0001-01-01T00:00:00Z"
critical: false
description: Some description
@@ -84,6 +85,7 @@ spec:
author_name: Alice
calendar_events_enabled: false
conditional_access_enabled: false
continuous_automations_enabled: false
created_at: "0001-01-01T00:00:00Z"
critical: false
description: ""
@@ -1,5 +1,6 @@
- calendar_events_enabled: false
conditional_access_enabled: true
continuous_automations_enabled: false
critical: false
description: This is a global policy
install_software:
@@ -15,6 +16,7 @@
webhooks_and_tickets_enabled: false
- calendar_events_enabled: false
conditional_access_enabled: false
continuous_automations_enabled: false
critical: false
description: This is a global policy with include_all scope
labels_include_all:
@@ -1,4 +1,5 @@
- calendar_events_enabled: false
continuous_automations_enabled: false
critical: false
description: This is a team policy
name: Team Policy
@@ -12,6 +13,7 @@
conditional_access_bypass_enabled: true
webhooks_and_tickets_enabled: false
- calendar_events_enabled: false
continuous_automations_enabled: false
critical: false
description: This is a team patch policy
name: Team patch policy
@@ -25,6 +27,7 @@
webhooks_and_tickets_enabled: false
- calendar_events_enabled: false
conditional_access_enabled: false
continuous_automations_enabled: false
critical: false
description: This is a team policy with VPP app automation
install_software:
@@ -185,6 +185,7 @@ org_settings:
policies:
- calendar_events_enabled: false
conditional_access_enabled: true
continuous_automations_enabled: false
critical: false
description: This is a global policy
install_software:
@@ -200,6 +201,7 @@ policies:
webhooks_and_tickets_enabled: false
- calendar_events_enabled: false
conditional_access_enabled: false
continuous_automations_enabled: false
critical: false
description: This is a global policy with include_all scope
name: Global Policy Include All
@@ -181,6 +181,7 @@ org_settings:
policies:
- calendar_events_enabled: false
conditional_access_enabled: true
continuous_automations_enabled: false
critical: false
description: This is a global policy
install_software:
@@ -196,6 +197,7 @@ policies:
webhooks_and_tickets_enabled: false
- calendar_events_enabled: false
conditional_access_enabled: false
continuous_automations_enabled: false
critical: false
description: This is a global policy with include_all scope
labels_include_all:
@@ -53,6 +53,7 @@ name: "Team A 👍"
policies:
- calendar_events_enabled: false
conditional_access_enabled: true
continuous_automations_enabled: false
critical: false
description: This is a team policy
name: Team Policy
@@ -63,6 +64,7 @@ policies:
webhooks_and_tickets_enabled: true
- calendar_events_enabled: false
conditional_access_enabled: true
continuous_automations_enabled: false
critical: false
description: This is a team patch policy
fleet_maintained_app_slug: foo/darwin
@@ -73,6 +75,7 @@ policies:
webhooks_and_tickets_enabled: true
- calendar_events_enabled: false
conditional_access_enabled: false
continuous_automations_enabled: false
critical: false
description: This is a team policy with VPP app automation
install_software:
@@ -85,6 +88,7 @@ policies:
webhooks_and_tickets_enabled: true
- calendar_events_enabled: false
conditional_access_enabled: false
continuous_automations_enabled: false
critical: false
description: This is a team policy with FMA install automation
install_software:
@@ -97,6 +101,7 @@ policies:
webhooks_and_tickets_enabled: false
- calendar_events_enabled: false
conditional_access_enabled: false
continuous_automations_enabled: false
critical: false
description: This is a team policy with custom package install automation
install_software:
@@ -36,6 +36,7 @@ name: Unassigned
policies:
- calendar_events_enabled: false
conditional_access_enabled: true
continuous_automations_enabled: false
critical: false
description: This is a team policy
name: Team Policy
@@ -46,6 +47,7 @@ policies:
webhooks_and_tickets_enabled: true
- calendar_events_enabled: false
conditional_access_enabled: true
continuous_automations_enabled: false
critical: false
description: This is a team patch policy
fleet_maintained_app_slug: foo/darwin
@@ -56,6 +58,7 @@ policies:
webhooks_and_tickets_enabled: true
- calendar_events_enabled: false
conditional_access_enabled: false
continuous_automations_enabled: false
critical: false
description: This is a team policy with VPP app automation
install_software:
@@ -0,0 +1,28 @@
package tables
import (
"database/sql"
"fmt"
)
func init() {
MigrationClient.AddMigration(Up_20260522195237, Down_20260522195237)
}
func Up_20260522195237(tx *sql.Tx) error {
if columnExists(tx, "policies", "continuous_automations_enabled") {
return nil
}
if _, err := tx.Exec(`
ALTER TABLE policies
ADD COLUMN continuous_automations_enabled TINYINT(1) NOT NULL DEFAULT 0,
ALGORITHM=INSTANT
`); err != nil {
return fmt.Errorf("add continuous_automations_enabled to policies: %w", err)
}
return nil
}
func Down_20260522195237(tx *sql.Tx) error {
return nil
}
@@ -0,0 +1,42 @@
package tables
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestUp_20260522195237(t *testing.T) {
db := applyUpToPrev(t)
policy1 := execNoErrLastID(
t, db, "INSERT INTO policies (name, query, description, checksum) VALUES (?,?,?,?)",
"policy1", "", "", "checksum1",
)
applyNext(t, db)
var policyCheck []struct {
ID int64 `db:"id"`
ContinuousAutomationsEnabled bool `db:"continuous_automations_enabled"`
}
err := db.SelectContext(context.Background(), &policyCheck, `SELECT id, continuous_automations_enabled FROM policies WHERE id = ?`, policy1)
require.NoError(t, err)
require.Len(t, policyCheck, 1)
assert.Equal(t, policy1, policyCheck[0].ID)
assert.False(t, policyCheck[0].ContinuousAutomationsEnabled)
policy2 := execNoErrLastID(
t, db, "INSERT INTO policies (name, query, description, checksum, continuous_automations_enabled) VALUES (?,?,?,?,?)",
"policy2", "", "", "checksum2", 1,
)
policyCheck = nil
err = db.SelectContext(context.Background(), &policyCheck, `SELECT id, continuous_automations_enabled FROM policies WHERE id = ?`, policy2)
require.NoError(t, err)
require.Len(t, policyCheck, 1)
assert.Equal(t, policy2, policyCheck[0].ID)
assert.True(t, policyCheck[0].ContinuousAutomationsEnabled)
}
+43 -13
View File
@@ -39,7 +39,7 @@ const policyCols = `
p.author_id, p.platforms, p.created_at, p.updated_at, p.critical,
p.calendar_events_enabled, p.software_installer_id, p.script_id,
p.vpp_apps_teams_id, p.conditional_access_enabled, p.type,
p.patch_software_title_id
p.patch_software_title_id, p.continuous_automations_enabled
`
const (
@@ -393,11 +393,12 @@ func savePolicy(ctx context.Context, db sqlx.ExtContext, logger *slog.Logger, p
SET name = ?, query = ?, description = ?, resolution = ?,
platforms = ?, critical = ?, calendar_events_enabled = ?,
software_installer_id = ?, script_id = ?, vpp_apps_teams_id = ?,
conditional_access_enabled = ?, checksum = ` + policiesChecksumComputedColumn() + `
conditional_access_enabled = ?, continuous_automations_enabled = ?,
checksum = ` + policiesChecksumComputedColumn() + `
WHERE id = ?
`
result, err := db.ExecContext(
ctx, updateStmt, p.Name, p.Query, p.Description, p.Resolution, p.Platform, p.Critical, p.CalendarEventsEnabled, p.SoftwareInstallerID, p.ScriptID, p.VPPAppsTeamsID, p.ConditionalAccessEnabled, p.ID,
ctx, updateStmt, p.Name, p.Query, p.Description, p.Resolution, p.Platform, p.Critical, p.CalendarEventsEnabled, p.SoftwareInstallerID, p.ScriptID, p.VPPAppsTeamsID, p.ConditionalAccessEnabled, p.ContinuousAutomationsEnabled, p.ID,
)
if err != nil {
return ctxerr.Wrap(ctx, err, "updating policy")
@@ -424,6 +425,33 @@ func savePolicy(ctx context.Context, db sqlx.ExtContext, logger *slog.Logger, p
)
}
// ResetPolicyAutomationRetryAttemptsForHost marks all prior script and software
// install attempts on this host (across the given policies) as "old sequence" by
// setting attempt_number=0. The retry gate counts attempt_number > 0 OR NULL, so
// after this reset the next attempt restarts the sequence at 1.
func (ds *Datastore) ResetPolicyAutomationRetryAttemptsForHost(ctx context.Context, hostID uint, policyIDs []uint) error {
if len(policyIDs) == 0 {
return nil
}
return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
q, args, err := sqlx.In(resetScriptAttemptsStmt, hostID, policyIDs)
if err != nil {
return ctxerr.Wrap(ctx, err, "building reset host script attempts query")
}
if _, err := tx.ExecContext(ctx, q, args...); err != nil {
return ctxerr.Wrap(ctx, err, "reset host script attempts")
}
q, args, err = sqlx.In(resetInstallAttemptsStmt, hostID, policyIDs)
if err != nil {
return ctxerr.Wrap(ctx, err, "building reset host install attempts query")
}
if _, err := tx.ExecContext(ctx, q, args...); err != nil {
return ctxerr.Wrap(ctx, err, "reset host install attempts")
}
return nil
})
}
// resetPolicyAutomationAttempts resets all attempt numbers for script and software install executions
// associated with the given policy.
func resetPolicyAutomationAttempts(ctx context.Context, db sqlx.ExecerContext, policyID uint) error {
@@ -1167,13 +1195,13 @@ func newTeamPolicy(ctx context.Context, db sqlx.ExtContext, teamID uint, authorI
name, query, description, team_id, resolution, author_id,
platforms, critical, calendar_events_enabled, software_installer_id,
script_id, vpp_apps_teams_id, conditional_access_enabled, checksum,
type, patch_software_title_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, %s, ?, ?)`,
type, patch_software_title_id, continuous_automations_enabled
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, %s, ?, ?, ?)`,
policiesChecksumComputedColumn(),
),
nameUnicode, args.Query, args.Description, teamID, args.Resolution, authorID, args.Platform, args.Critical,
args.CalendarEventsEnabled, args.SoftwareInstallerID, args.ScriptID, args.VPPAppsTeamsID,
args.ConditionalAccessEnabled, args.Type, args.PatchSoftwareTitleID,
args.ConditionalAccessEnabled, args.Type, args.PatchSoftwareTitleID, args.ContinuousAutomationsEnabled,
)
switch {
case err == nil:
@@ -1465,8 +1493,9 @@ func (ds *Datastore) ApplyPolicySpecs(ctx context.Context, authorID uint, specs
conditional_access_enabled,
checksum,
type,
patch_software_title_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, %s, ?, ?)
patch_software_title_id,
continuous_automations_enabled
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, %s, ?, ?, ?)
ON DUPLICATE KEY UPDATE
query = VALUES(query),
description = VALUES(description),
@@ -1480,7 +1509,8 @@ func (ds *Datastore) ApplyPolicySpecs(ctx context.Context, authorID uint, specs
script_id = VALUES(script_id),
conditional_access_enabled = VALUES(conditional_access_enabled),
type = VALUES(type),
patch_software_title_id = VALUES(patch_software_title_id)
patch_software_title_id = VALUES(patch_software_title_id),
continuous_automations_enabled = VALUES(continuous_automations_enabled)
`, policiesChecksumComputedColumn(),
)
for teamID, teamPolicySpecs := range teamIDToPolicies {
@@ -1546,7 +1576,7 @@ func (ds *Datastore) ApplyPolicySpecs(ctx context.Context, authorID uint, specs
query,
spec.Name, spec.Query, spec.Description, authorID, spec.Resolution, teamID, spec.Platform, spec.Critical,
spec.CalendarEventsEnabled, softwareInstallerID, vppAppsTeamsID, scriptID, spec.ConditionalAccessEnabled,
spec.Type, patchSoftwareTitleIDArg,
spec.Type, patchSoftwareTitleIDArg, spec.ContinuousAutomationsEnabled,
)
if err != nil {
return ctxerr.Wrap(ctx, err, "exec ApplyPolicySpecs insert")
@@ -2521,7 +2551,7 @@ func (ds *Datastore) GetPoliciesWithAssociatedInstaller(ctx context.Context, tea
if len(policyIDs) == 0 {
return nil, nil
}
query := `SELECT id, software_installer_id FROM policies WHERE team_id = ? AND software_installer_id IS NOT NULL AND id IN (?);`
query := `SELECT id, software_installer_id, continuous_automations_enabled FROM policies WHERE team_id = ? AND software_installer_id IS NOT NULL AND id IN (?);`
query, args, err := sqlx.In(query, teamID, policyIDs)
if err != nil {
return nil, ctxerr.Wrapf(ctx, err, "build sqlx.In for get policies with associated installer")
@@ -2537,7 +2567,7 @@ func (ds *Datastore) GetPoliciesWithAssociatedVPP(ctx context.Context, teamID ui
if len(policyIDs) == 0 {
return nil, nil
}
query := `SELECT p.id, vat.adam_id, vat.platform FROM policies p JOIN vpp_apps_teams vat ON vat.id = p.vpp_apps_teams_id WHERE p.team_id = ? AND p.id IN (?);`
query := `SELECT p.id, vat.adam_id, vat.platform, p.continuous_automations_enabled FROM policies p JOIN vpp_apps_teams vat ON vat.id = p.vpp_apps_teams_id WHERE p.team_id = ? AND p.id IN (?);`
query, args, err := sqlx.In(query, teamID, policyIDs)
if err != nil {
return nil, ctxerr.Wrapf(ctx, err, "build sqlx.In for get policies with associated installer")
@@ -2553,7 +2583,7 @@ func (ds *Datastore) GetPoliciesWithAssociatedScript(ctx context.Context, teamID
if len(policyIDs) == 0 {
return nil, nil
}
query := `SELECT id, script_id FROM policies WHERE team_id = ? AND script_id IS NOT NULL AND id IN (?);`
query := `SELECT id, script_id, continuous_automations_enabled FROM policies WHERE team_id = ? AND script_id IS NOT NULL AND id IN (?);`
query, args, err := sqlx.In(query, teamID, policyIDs)
if err != nil {
return nil, ctxerr.Wrapf(ctx, err, "build sqlx.In for get policies with associated script")
File diff suppressed because one or more lines are too long
+18 -17
View File
@@ -150,23 +150,24 @@ func (r AutofillPoliciesResponse) Error() error { return r.Err }
/////////////////////////////////////////////////////////////////////////////////
type TeamPolicyRequest struct {
TeamID uint `url:"fleet_id"`
QueryID *uint `json:"query_id" renameto:"report_id"`
Query string `json:"query"`
Name string `json:"name"`
Description string `json:"description"`
Resolution string `json:"resolution"`
Platform string `json:"platform"`
Critical bool `json:"critical" premium:"true"`
CalendarEventsEnabled bool `json:"calendar_events_enabled"`
SoftwareTitleID *uint `json:"software_title_id"`
ScriptID *uint `json:"script_id"`
LabelsIncludeAny []string `json:"labels_include_any"`
LabelsIncludeAll []string `json:"labels_include_all" premium:"true"`
LabelsExcludeAny []string `json:"labels_exclude_any"`
ConditionalAccessEnabled bool `json:"conditional_access_enabled"`
Type *string `json:"type"`
PatchSoftwareTitleID *uint `json:"patch_software_title_id"`
TeamID uint `url:"fleet_id"`
QueryID *uint `json:"query_id" renameto:"report_id"`
Query string `json:"query"`
Name string `json:"name"`
Description string `json:"description"`
Resolution string `json:"resolution"`
Platform string `json:"platform"`
Critical bool `json:"critical" premium:"true"`
CalendarEventsEnabled bool `json:"calendar_events_enabled"`
SoftwareTitleID *uint `json:"software_title_id"`
ScriptID *uint `json:"script_id"`
LabelsIncludeAny []string `json:"labels_include_any"`
LabelsIncludeAll []string `json:"labels_include_all" premium:"true"`
LabelsExcludeAny []string `json:"labels_exclude_any"`
ConditionalAccessEnabled bool `json:"conditional_access_enabled"`
ContinuousAutomationsEnabled bool `json:"continuous_automations_enabled" premium:"true"`
Type *string `json:"type"`
PatchSoftwareTitleID *uint `json:"patch_software_title_id"`
}
type TeamPolicyResponse struct {
+7
View File
@@ -933,6 +933,13 @@ type Datastore interface {
// GetPoliciesWithAssociatedVPP returns team policies that have an associated VPP app
GetPoliciesWithAssociatedVPP(ctx context.Context, teamID uint, policyIDs []uint) ([]PolicyVPPData, error)
GetPoliciesWithAssociatedScript(ctx context.Context, teamID uint, policyIDs []uint) ([]PolicyScriptData, error)
// ResetPolicyAutomationRetryAttemptsForHost marks all prior policy automation
// script/install attempts on this host as "old sequence" (attempt_number=0)
// for the given policies. Used when continuous_automations_enabled triggers
// a new automation run while the policy is still failing, so that the new
// attempt restarts the retry sequence at 1 instead of inheriting the cap
// from the previous sequence.
ResetPolicyAutomationRetryAttemptsForHost(ctx context.Context, hostID uint, policyIDs []uint) error
GetCalendarPolicies(ctx context.Context, teamID uint) ([]PolicyCalendarData, error)
// GetPoliciesForConditionalAccess returns the team policies that are configured for "Conditional access".
GetPoliciesForConditionalAccess(ctx context.Context, teamID uint, platform string) ([]uint, error)
+35 -7
View File
@@ -65,6 +65,12 @@ type PolicyPayload struct {
//
// Only applies to team policies with the patch type.
PatchSoftwareTitleID *uint
// ContinuousAutomationsEnabled indicates whether software/script automations
// should run on every failing policy result, not just on pass→fail transitions.
//
// Only applies to team policies.
ContinuousAutomationsEnabled bool
}
// NewTeamPolicyPayload holds data for team policy creation.
@@ -110,6 +116,9 @@ type NewTeamPolicyPayload struct {
Type *string
// PatchSoftwareTitleID is the title id of the Fleet maintained app checked by a patch policy.
PatchSoftwareTitleID *uint
// ContinuousAutomationsEnabled indicates whether software/script automations
// should run on every failing policy result, not just on pass→fail transitions.
ContinuousAutomationsEnabled bool
}
var (
@@ -282,6 +291,11 @@ type ModifyPolicyPayload struct {
//
// Only applies to team policies.
ConditionalAccessEnabled *bool `json:"conditional_access_enabled" premium:"true"`
// ContinuousAutomationsEnabled indicates whether software/script automations
// should run on every failing policy result, not just on pass→fail transitions.
//
// Only applies to team policies.
ContinuousAutomationsEnabled *bool `json:"continuous_automations_enabled" premium:"true"`
// Type is the policy type. It is 'dynamic' by default and 'patch' for patch policies.
Type string `json:"-"`
@@ -379,6 +393,12 @@ type PolicyData struct {
// Only applies to team policies with the patch type.
PatchSoftwareTitleID *uint `json:"-" db:"patch_software_title_id"`
// ContinuousAutomationsEnabled indicates whether software/script automations
// should run on every failing policy result, not just on pass→fail transitions.
//
// Only applies to team policies.
ContinuousAutomationsEnabled bool `json:"continuous_automations_enabled" db:"continuous_automations_enabled"`
UpdateCreateTimestamps
}
@@ -422,19 +442,22 @@ type PolicyCalendarData struct {
}
type PolicySoftwareInstallerData struct {
ID uint `db:"id"`
InstallerID uint `db:"software_installer_id"`
ID uint `db:"id"`
InstallerID uint `db:"software_installer_id"`
ContinuousAutomationsEnabled bool `db:"continuous_automations_enabled"`
}
type PolicyVPPData struct {
ID uint `db:"id"`
AdamID string `db:"adam_id"`
Platform InstallableDevicePlatform `db:"platform"`
ID uint `db:"id"`
AdamID string `db:"adam_id"`
Platform InstallableDevicePlatform `db:"platform"`
ContinuousAutomationsEnabled bool `db:"continuous_automations_enabled"`
}
type PolicyScriptData struct {
ID uint `db:"id"`
ScriptID uint `db:"script_id"`
ID uint `db:"id"`
ScriptID uint `db:"script_id"`
ContinuousAutomationsEnabled bool `db:"continuous_automations_enabled"`
}
// PolicyLite is a stripped down version of the policy.
@@ -504,6 +527,11 @@ type PolicySpec struct {
//
// Only applies to team policies.
ConditionalAccessEnabled bool `json:"conditional_access_enabled"`
// ContinuousAutomationsEnabled indicates whether software/script automations
// should run on every failing policy result, not just on pass→fail transitions.
//
// Only applies to team policies.
ContinuousAutomationsEnabled bool `json:"continuous_automations_enabled"`
Type string `json:"type"`
FleetMaintainedAppSlug string `json:"fleet_maintained_app_slug"`
+12
View File
@@ -677,6 +677,8 @@ type GetPoliciesWithAssociatedVPPFunc func(ctx context.Context, teamID uint, pol
type GetPoliciesWithAssociatedScriptFunc func(ctx context.Context, teamID uint, policyIDs []uint) ([]fleet.PolicyScriptData, error)
type ResetPolicyAutomationRetryAttemptsForHostFunc func(ctx context.Context, hostID uint, policyIDs []uint) error
type GetCalendarPoliciesFunc func(ctx context.Context, teamID uint) ([]fleet.PolicyCalendarData, error)
type GetPoliciesForConditionalAccessFunc func(ctx context.Context, teamID uint, platform string) ([]uint, error)
@@ -2985,6 +2987,9 @@ type DataStore struct {
GetPoliciesWithAssociatedScriptFunc GetPoliciesWithAssociatedScriptFunc
GetPoliciesWithAssociatedScriptFuncInvoked bool
ResetPolicyAutomationRetryAttemptsForHostFunc ResetPolicyAutomationRetryAttemptsForHostFunc
ResetPolicyAutomationRetryAttemptsForHostFuncInvoked bool
GetCalendarPoliciesFunc GetCalendarPoliciesFunc
GetCalendarPoliciesFuncInvoked bool
@@ -7266,6 +7271,13 @@ func (s *DataStore) GetPoliciesWithAssociatedScript(ctx context.Context, teamID
return s.GetPoliciesWithAssociatedScriptFunc(ctx, teamID, policyIDs)
}
func (s *DataStore) ResetPolicyAutomationRetryAttemptsForHost(ctx context.Context, hostID uint, policyIDs []uint) error {
s.mu.Lock()
s.ResetPolicyAutomationRetryAttemptsForHostFuncInvoked = true
s.mu.Unlock()
return s.ResetPolicyAutomationRetryAttemptsForHostFunc(ctx, hostID, policyIDs)
}
func (s *DataStore) GetCalendarPolicies(ctx context.Context, teamID uint) ([]fleet.PolicyCalendarData, error) {
s.mu.Lock()
s.GetCalendarPoliciesFuncInvoked = true
+15 -1
View File
@@ -234,7 +234,10 @@ func (svc Service) removeGlobalPoliciesFromWebhookConfig(ctx context.Context, id
// Modify
/////////////////////////////////////////////////////////////////////////////////
const errPolicyAllFleetsForConditionalAccess = "\"All fleets\" policy cannot have conditional_access_enabled set"
const (
errPolicyAllFleetsForConditionalAccess = "\"All fleets\" policy cannot have conditional_access_enabled set"
errPolicyAllFleetsForContinuousAutomations = "\"All fleets\" policy cannot have continuous_automations_enabled set"
)
func modifyGlobalPolicyEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
req := request.(*fleet.ModifyGlobalPolicyRequest)
@@ -456,6 +459,12 @@ func (svc *Service) ApplyPolicySpecs(ctx context.Context, policies []*fleet.Poli
})
}
if policy.Team == "" && policy.ContinuousAutomationsEnabled {
return ctxerr.Wrap(ctx, &fleet.BadRequestError{
Message: fmt.Sprintf("policy spec payload verification: %s", errPolicyAllFleetsForContinuousAutomations),
})
}
if err := policy.Verify(); err != nil {
return ctxerr.Wrap(ctx, &fleet.BadRequestError{
Message: fmt.Sprintf("policy spec payload verification: %s", err),
@@ -467,6 +476,11 @@ func (svc *Service) ApplyPolicySpecs(ctx context.Context, policies []*fleet.Poli
return fleet.ErrMissingLicense
}
// ContinuousAutomationsEnabled is premium-only.
if policy.ContinuousAutomationsEnabled && !license.IsPremium(ctx) {
return fleet.ErrMissingLicense
}
// Make sure any applied labels exist.
labels := slices.Concat(policy.LabelsIncludeAny, policy.LabelsIncludeAll, policy.LabelsExcludeAny)
if len(labels) > 0 {
File diff suppressed because it is too large Load Diff
+403
View File
@@ -19109,6 +19109,409 @@ func (s *integrationMDMTestSuite) TestVPPPolicyAutomationLabelScopingRetrigger()
require.Equal(t, uint(1), policy1.FailingHostCount)
}
// TestPolicyAutomationsContinuousVPPApp mirrors
// TestPolicyAutomationsContinuousSoftwareInstaller but for a VPP app
// automation: continuous_automations_enabled=true must re-trigger an
// install on every failing policy result (not only on pass→fail), and
// passing results must never trigger an install.
func (s *integrationMDMTestSuite) TestPolicyAutomationsContinuousVPPApp() {
t := s.T()
ctx := context.Background()
// VPP token setup.
orgName := "Fleet Device Management Inc."
token := "mycooltoken"
expTime := time.Now().Add(200 * time.Hour).UTC().Round(time.Second)
expDate := expTime.Format(fleet.VPPTimeFormat)
tokenJSON := fmt.Sprintf(`{"expDate":"%s","token":"%s","orgName":"%s"}`, expDate, token, orgName)
dev_mode.SetOverride("FLEET_DEV_VPP_URL", s.appleVPPConfigSrv.URL, t)
var validToken uploadVPPTokenResponse
s.uploadDataViaForm("/api/latest/fleet/vpp_tokens", "token", "token.vpptoken",
[]byte(base64.StdEncoding.EncodeToString([]byte(tokenJSON))),
http.StatusAccepted, "", &validToken)
var resp getVPPTokensResponse
s.DoJSON("GET", "/api/latest/fleet/vpp_tokens", &getVPPTokensRequest{}, http.StatusOK, &resp)
require.NoError(t, resp.Err)
// Team and MDM-enrolled host.
var newTeamResp teamResponse
s.DoJSON("POST", "/api/latest/fleet/teams", &createTeamRequest{TeamPayload: fleet.TeamPayload{Name: new(t.Name())}}, http.StatusOK, &newTeamResp)
team := newTeamResp.Team
mdmHost, mdmDevice := createHostThenEnrollMDM(s.ds, s.server.URL, t)
setOrbitEnrollment(t, mdmHost, s.ds)
s.awaitRunAppleMDMWorkerSchedule()
s.runWorker()
checkInstallFleetdCommandSent(t, mdmDevice, true)
s.appleVPPConfigSrvConfig.SerialNumbers = append(s.appleVPPConfigSrvConfig.SerialNumbers, mdmHost.HardwareSerial)
s.Do("POST", "/api/latest/fleet/hosts/transfer",
&addHostsToTeamRequest{HostIDs: []uint{mdmHost.ID}, TeamID: &team.ID}, http.StatusOK)
var resPatchVPP patchVPPTokensTeamsResponse
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/vpp_tokens/%d/teams", resp.Tokens[0].ID),
patchVPPTokensTeamsRequest{TeamIDs: []uint{team.ID}}, http.StatusOK, &resPatchVPP)
// Pick two macOS VPP apps and add them to the team. Each policy gets its
// own VPP app: a queued VPP install blocks further queueing for the same
// adam_id (MapAdamIDsPendingInstall gate), so sharing one app between the
// two policies would hide the behavior being tested.
var appResp getAppStoreAppsResponse
s.DoJSON("GET", "/api/latest/fleet/software/app_store_apps", &getAppStoreAppsRequest{}, http.StatusOK, &appResp, "team_id", fmt.Sprint(team.ID))
require.NoError(t, appResp.Err)
var macOSApps []*fleet.VPPApp
for _, app := range appResp.AppStoreApps {
if app.Platform == fleet.MacOSPlatform {
macOSApps = append(macOSApps, app)
}
}
require.GreaterOrEqual(t, len(macOSApps), 2, "expected at least two macOS VPP apps in the mock catalog")
continuousApp, transitionApp := macOSApps[0], macOSApps[1]
addTitleID := func(app *fleet.VPPApp) uint {
var addAppResp addAppStoreAppResponse
s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{
TeamID: &team.ID,
Platform: app.Platform,
AppStoreID: app.AdamID,
}, http.StatusOK, &addAppResp)
var listSw listSoftwareTitlesResponse
s.DoJSON("GET", "/api/latest/fleet/software/titles", nil, http.StatusOK, &listSw,
"team_id", fmt.Sprint(team.ID),
"available_for_install", "true",
"query", app.Name,
)
require.NotEmpty(t, listSw.SoftwareTitles)
require.NotNil(t, listSw.SoftwareTitles[0].AppStoreApp)
return listSw.SoftwareTitles[0].ID
}
continuousTitleID := addTitleID(continuousApp)
transitionTitleID := addTitleID(transitionApp)
// Two policies attached to the same VPP app: one continuous, one default.
continuousPolicy, err := s.ds.NewTeamPolicy(ctx, team.ID, nil, fleet.PolicyPayload{
Name: "continuous",
Query: "SELECT 1 FROM osquery_info WHERE start_time < 0;",
Platform: "darwin",
ContinuousAutomationsEnabled: true,
})
require.NoError(t, err)
transitionPolicy, err := s.ds.NewTeamPolicy(ctx, team.ID, nil, fleet.PolicyPayload{
Name: "transition-only",
Query: "SELECT 1 FROM osquery_info WHERE start_time < 0;",
Platform: "darwin",
})
require.NoError(t, err)
attach := func(policyID, titleID uint) {
var mtplr fleet.ModifyTeamPolicyResponse
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team.ID, policyID), fleet.ModifyTeamPolicyRequest{
ModifyPolicyPayload: fleet.ModifyPolicyPayload{
SoftwareTitleID: optjson.Any[uint]{Set: true, Valid: true, Value: titleID},
},
}, http.StatusOK, &mtplr)
}
attach(continuousPolicy.ID, continuousTitleID)
attach(transitionPolicy.ID, transitionTitleID)
submitPolicyResult := func(policyID uint, passes bool) {
var distributedResp submitDistributedQueryResultsResponse
s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults(
mdmHost,
map[uint]*bool{policyID: new(passes)},
), http.StatusOK, &distributedResp)
}
// host_vpp_software_installs grows by one row per queued install; a queued
// install also blocks further queueing for the same adam_id until the MDM
// install command is acknowledged.
countInstallsFor := func(policyID uint) int {
var count int
mysqltest.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(ctx, q, &count, `
SELECT COUNT(*) FROM host_vpp_software_installs
WHERE host_id = ? AND policy_id = ?
`, mdmHost.ID, policyID)
})
return count
}
completeVPPInstall := func() {
s.awaitRunAppleMDMWorkerSchedule()
s.runWorker()
// First drain: acknowledge the InstallApplication command.
cmd, err := mdmDevice.Idle()
require.NoError(t, err)
for cmd != nil {
switch cmd.Command.RequestType {
case "InstallApplication":
cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID)
require.NoError(t, err)
case "InstalledApplicationList":
cmd, err = mdmDevice.AcknowledgeInstalledApplicationList(mdmDevice.UUID, cmd.CommandUUID, []fleet.Software{
{Name: continuousApp.Name, BundleIdentifier: continuousApp.BundleIdentifier, Version: continuousApp.LatestVersion, Installed: true},
{Name: transitionApp.Name, BundleIdentifier: transitionApp.BundleIdentifier, Version: transitionApp.LatestVersion, Installed: true},
})
require.NoError(t, err)
default:
require.Fail(t, "unexpected command type", cmd.Command.RequestType)
}
}
// Second drain: the post-install InstalledApplicationList verification
// command is queued by a worker after the Acknowledge above, so we have
// to runWorker again and re-Idle to pick it up.
s.runWorker()
cmd, err = mdmDevice.Idle()
require.NoError(t, err)
for cmd != nil {
switch cmd.Command.RequestType {
case "InstalledApplicationList":
cmd, err = mdmDevice.AcknowledgeInstalledApplicationList(mdmDevice.UUID, cmd.CommandUUID, []fleet.Software{
{Name: continuousApp.Name, BundleIdentifier: continuousApp.BundleIdentifier, Version: continuousApp.LatestVersion, Installed: true},
{Name: transitionApp.Name, BundleIdentifier: transitionApp.BundleIdentifier, Version: transitionApp.LatestVersion, Installed: true},
})
require.NoError(t, err)
default:
require.Fail(t, "unexpected command type", cmd.Command.RequestType)
}
}
s.runWorker()
}
step := func(policyID uint, wantCount int, countFn func() int, msg string) {
t.Helper()
submitPolicyResult(policyID, false)
require.EventuallyWithT(t, func(t *assert.CollectT) {
assert.Equal(t, wantCount, countFn(), msg)
}, 5*time.Second, 100*time.Millisecond)
completeVPPInstall()
}
continuousCount := func() int { return countInstallsFor(continuousPolicy.ID) }
transitionCount := func() int { return countInstallsFor(transitionPolicy.ID) }
// First failing result: pass→fail transition queues an install on both.
step(continuousPolicy.ID, 1, continuousCount, "first install for continuous policy")
step(transitionPolicy.ID, 1, transitionCount, "first install for transition policy")
// Second failing result (fail→fail): only the continuous policy re-queues.
step(continuousPolicy.ID, 2, continuousCount, "continuous policy must fire on every failing result")
submitPolicyResult(transitionPolicy.ID, false)
require.Never(t, func() bool {
return transitionCount() != 1
}, 2*time.Second, 100*time.Millisecond, "default policy must not re-trigger on fail→fail")
// Third failing result: continuous still re-triggers, default still does not.
step(continuousPolicy.ID, 3, continuousCount, "continuous policy must fire on every failing result")
submitPolicyResult(transitionPolicy.ID, false)
require.Never(t, func() bool {
return transitionCount() != 1
}, 2*time.Second, 100*time.Millisecond)
// Final: passing results never trigger an install, regardless of mode.
continuousBefore := continuousCount()
transitionBefore := transitionCount()
submitPolicyResult(continuousPolicy.ID, true)
submitPolicyResult(transitionPolicy.ID, true)
require.Never(t, func() bool {
return continuousCount() != continuousBefore
}, 2*time.Second, 100*time.Millisecond, "continuous policy must not trigger install on passing result")
require.Never(t, func() bool {
return transitionCount() != transitionBefore
}, 2*time.Second, 100*time.Millisecond, "transition policy must not trigger install on passing result")
}
// TestPolicyAutomationsContinuousVPPAppRetryReset is the VPP analog of the
// script and software-installer retry-reset tests, but the assertion is
// different. VPP retry tracking is per-row (host_vpp_software_installs.retry_count
// is bumped in-place by RetryVPPInstall, gated against MaxSoftwareInstallAttempts
// on that single row), so a continuous re-fire produces a *brand new* row whose
// retry_count starts at 0 — no reset of the old row is needed for the new one to
// have a fresh retry budget. This test pins that behavior so future refactors
// don't quietly break it.
func (s *integrationMDMTestSuite) TestPolicyAutomationsContinuousVPPAppRetryReset() {
t := s.T()
ctx := context.Background()
// VPP token setup.
orgName := "Fleet Device Management Inc."
token := "mycooltoken"
expTime := time.Now().Add(200 * time.Hour).UTC().Round(time.Second)
expDate := expTime.Format(fleet.VPPTimeFormat)
tokenJSON := fmt.Sprintf(`{"expDate":"%s","token":"%s","orgName":"%s"}`, expDate, token, orgName)
dev_mode.SetOverride("FLEET_DEV_VPP_URL", s.appleVPPConfigSrv.URL, t)
var validToken uploadVPPTokenResponse
s.uploadDataViaForm("/api/latest/fleet/vpp_tokens", "token", "token.vpptoken",
[]byte(base64.StdEncoding.EncodeToString([]byte(tokenJSON))),
http.StatusAccepted, "", &validToken)
var resp getVPPTokensResponse
s.DoJSON("GET", "/api/latest/fleet/vpp_tokens", &getVPPTokensRequest{}, http.StatusOK, &resp)
require.NoError(t, resp.Err)
var newTeamResp teamResponse
s.DoJSON("POST", "/api/latest/fleet/teams", &createTeamRequest{TeamPayload: fleet.TeamPayload{Name: new(t.Name())}}, http.StatusOK, &newTeamResp)
team := newTeamResp.Team
mdmHost, mdmDevice := createHostThenEnrollMDM(s.ds, s.server.URL, t)
setOrbitEnrollment(t, mdmHost, s.ds)
s.awaitRunAppleMDMWorkerSchedule()
s.runWorker()
checkInstallFleetdCommandSent(t, mdmDevice, true)
s.appleVPPConfigSrvConfig.SerialNumbers = append(s.appleVPPConfigSrvConfig.SerialNumbers, mdmHost.HardwareSerial)
s.Do("POST", "/api/latest/fleet/hosts/transfer",
&addHostsToTeamRequest{HostIDs: []uint{mdmHost.ID}, TeamID: &team.ID}, http.StatusOK)
var resPatchVPP patchVPPTokensTeamsResponse
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/vpp_tokens/%d/teams", resp.Tokens[0].ID),
patchVPPTokensTeamsRequest{TeamIDs: []uint{team.ID}}, http.StatusOK, &resPatchVPP)
// Pick a macOS VPP app and add it to the team.
var appResp getAppStoreAppsResponse
s.DoJSON("GET", "/api/latest/fleet/software/app_store_apps", &getAppStoreAppsRequest{}, http.StatusOK, &appResp, "team_id", fmt.Sprint(team.ID))
require.NoError(t, appResp.Err)
var addedApp *fleet.VPPApp
for _, app := range appResp.AppStoreApps {
if app.Platform == fleet.MacOSPlatform {
addedApp = app
break
}
}
require.NotNil(t, addedApp)
var addAppResp addAppStoreAppResponse
s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{
TeamID: &team.ID,
Platform: addedApp.Platform,
AppStoreID: addedApp.AdamID,
}, http.StatusOK, &addAppResp)
var listSw listSoftwareTitlesResponse
s.DoJSON("GET", "/api/latest/fleet/software/titles", nil, http.StatusOK, &listSw,
"team_id", fmt.Sprint(team.ID),
"available_for_install", "true",
"query", addedApp.Name,
)
require.Len(t, listSw.SoftwareTitles, 1)
require.NotNil(t, listSw.SoftwareTitles[0].AppStoreApp)
vppTitleID := listSw.SoftwareTitles[0].ID
policy, err := s.ds.NewTeamPolicy(ctx, team.ID, nil, fleet.PolicyPayload{
Name: "continuous-vpp-retry-reset",
Query: "SELECT 1 FROM osquery_info WHERE start_time < 0;",
Platform: "darwin",
ContinuousAutomationsEnabled: true,
})
require.NoError(t, err)
var mtplr fleet.ModifyTeamPolicyResponse
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team.ID, policy.ID), fleet.ModifyTeamPolicyRequest{
ModifyPolicyPayload: fleet.ModifyPolicyPayload{
SoftwareTitleID: optjson.Any[uint]{Set: true, Valid: true, Value: vppTitleID},
},
}, http.StatusOK, &mtplr)
submitPolicyResult := func(passes bool) {
var distributedResp submitDistributedQueryResultsResponse
s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults(
mdmHost,
map[uint]*bool{policy.ID: new(passes)},
), http.StatusOK, &distributedResp)
}
// errorOnInstallApplicationCommand drains the MDM queue for an
// InstallApplication command and responds with an MDM error. apple_mdm.go's
// command-result handler reacts to that by calling RetryVPPInstall (in place
// on the same host_vpp_software_installs row, incrementing retry_count) up
// to MaxSoftwareInstallAttempts.
errorOnInstallApplicationCommand := func() {
s.awaitRunAppleMDMWorkerSchedule()
s.runWorker()
cmd, err := mdmDevice.Idle()
require.NoError(t, err)
require.NotNil(t, cmd, "expected an InstallApplication command on the MDM queue")
for cmd != nil {
switch cmd.Command.RequestType {
case "InstallApplication":
cmd, err = mdmDevice.Err(cmd.CommandUUID, []mdm.ErrorChain{{ErrorCode: 1234}})
require.NoError(t, err)
default:
cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID)
require.NoError(t, err)
}
}
s.runWorker()
}
type vppRow struct {
ID uint `db:"id"`
CommandUUID string `db:"command_uuid"`
RetryCount int `db:"retry_count"`
}
listVPPRows := func() []vppRow {
var rows []vppRow
mysqltest.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
return sqlx.SelectContext(ctx, q, &rows, `
SELECT id, command_uuid, retry_count
FROM host_vpp_software_installs
WHERE host_id = ? AND policy_id = ?
ORDER BY id ASC
`, mdmHost.ID, policy.ID)
})
return rows
}
// Phase 1: pass→fail queues a single row. Fail it through every retry until
// the per-row cap is reached. Each RetryVPPInstall reuses the same row and
// gives it a new command_uuid, so we still have exactly one row at the end.
// The gate is retry_count < MaxSoftwareInstallAttempts, so retries fire at
// retry_count 0,1,2 (→ ends at MaxSoftwareInstallAttempts); the final
// failure at the cap produces no further retry. That's
// MaxSoftwareInstallAttempts+1 failures total.
submitPolicyResult(false)
for i := 0; i <= fleet.MaxSoftwareInstallAttempts; i++ {
errorOnInstallApplicationCommand()
}
rows := listVPPRows()
require.Len(t, rows, 1, "single row tracks retry_count in place")
require.Equal(t, fleet.MaxSoftwareInstallAttempts, rows[0].RetryCount,
"retry_count incremented on each RetryVPPInstall until it reaches MaxSoftwareInstallAttempts")
firstRowID := rows[0].ID
// No more retries should fire once the cap is reached, even though the
// install never succeeded.
require.Never(t, func() bool {
r := listVPPRows()
return len(r) != 1 || r[0].ID != firstRowID || r[0].RetryCount != fleet.MaxSoftwareInstallAttempts
}, 2*time.Second, 100*time.Millisecond, "no further VPP retries after cap is hit")
// Phase 2: continuous re-fire. processVPPForNewlyFailingPolicies should
// queue a brand-new install, producing a *second* row in
// host_vpp_software_installs with retry_count = 0 (fresh budget).
submitPolicyResult(false)
require.EventuallyWithT(t, func(t *assert.CollectT) {
r := listVPPRows()
if !assert.Len(t, r, 2, "continuous re-fire should insert a new row") {
return
}
assert.Equal(t, firstRowID, r[0].ID)
assert.Equal(t, fleet.MaxSoftwareInstallAttempts, r[0].RetryCount, "old row's retry_count is left untouched")
assert.NotEqual(t, firstRowID, r[1].ID, "second row is a brand new install")
assert.Equal(t, 0, r[1].RetryCount, "new row starts with retry_count = 0 (fresh budget)")
}, 5*time.Second, 100*time.Millisecond)
// And the new row's retry budget is genuinely fresh — failing its first
// InstallApplication should trigger RetryVPPInstall, taking retry_count to 1.
errorOnInstallApplicationCommand()
require.EventuallyWithT(t, func(t *assert.CollectT) {
r := listVPPRows()
if !assert.Len(t, r, 2) {
return
}
assert.Equal(t, 1, r[1].RetryCount, "new row's retries are eligible (now at 1)")
}, 5*time.Second, 100*time.Millisecond)
}
// registerResetVPPProxyData resets the VPP proxy data after tests in `t` complete.
func (s *integrationMDMTestSuite) registerResetVPPProxyData(t *testing.T) {
oldApps := s.appleVPPProxySrvData
+35 -6
View File
@@ -2021,10 +2021,12 @@ func (svc *Service) processSoftwareForNewlyFailingPolicies(
return nil
}
// Filter to policies with installers that are newly failing, using the pre-computed set.
// Filter to policies with installers that are newly failing, or that have
// continuous_automations_enabled set (in which case every failing result
// triggers an install, not just pass→fail transitions).
var failingPoliciesWithInstaller []fleet.PolicySoftwareInstallerData
for _, policyWithInstaller := range policiesWithInstaller {
if _, ok := newFailingSet[policyWithInstaller.ID]; ok {
if _, ok := newFailingSet[policyWithInstaller.ID]; ok || policyWithInstaller.ContinuousAutomationsEnabled {
failingPoliciesWithInstaller = append(failingPoliciesWithInstaller, policyWithInstaller)
}
}
@@ -2075,6 +2077,18 @@ func (svc *Service) processSoftwareForNewlyFailingPolicies(
)
continue
}
// On a continuous re-fire (policy still failing), reset prior
// attempt_number values for this host/policy to 0 so the new attempt
// restarts the retry sequence at 1 instead of inheriting the cap from
// the previous sequence. A no-op on pass→fail transitions (those rows
// are already at 0 from the prior fail→pass reset).
if failingPolicyWithInstaller.ContinuousAutomationsEnabled {
if err := svc.ds.ResetPolicyAutomationRetryAttemptsForHost(ctx, hostID, []uint{policyID}); err != nil {
return ctxerr.Wrap(ctx, err, "reset policy automation retry attempts for host")
}
}
// NOTE(lucas): The user_id set in this software install will be NULL
// so this means that when generating the activity for this action
// (in SaveHostSoftwareInstallResult) the author will be set to Fleet.
@@ -2135,10 +2149,12 @@ func (svc *Service) processVPPForNewlyFailingPolicies(
return nil
}
// Filter to policies with VPP apps that are newly failing, using the pre-computed set.
// Filter to policies with VPP apps that are newly failing, or that have
// continuous_automations_enabled set (in which case every failing result
// triggers an install, not just pass→fail transitions).
var failingPoliciesWithVPP []fleet.PolicyVPPData
for _, policyWithVPP := range policiesWithVPP {
if _, ok := newFailingSet[policyWithVPP.ID]; ok {
if _, ok := newFailingSet[policyWithVPP.ID]; ok || policyWithVPP.ContinuousAutomationsEnabled {
failingPoliciesWithVPP = append(failingPoliciesWithVPP, policyWithVPP)
}
}
@@ -2270,10 +2286,12 @@ func (svc *Service) processScriptsForNewlyFailingPolicies(
return nil
}
// Filter to policies with scripts that are newly failing, using the pre-computed set.
// Filter to policies with scripts that are newly failing, or that have
// continuous_automations_enabled set (in which case every failing result
// triggers a script run, not just pass→fail transitions).
var failingPoliciesWithScript []fleet.PolicyScriptData
for _, policyWithScript := range policiesWithScript {
if _, ok := newFailingSet[policyWithScript.ID]; ok {
if _, ok := newFailingSet[policyWithScript.ID]; ok || policyWithScript.ContinuousAutomationsEnabled {
failingPoliciesWithScript = append(failingPoliciesWithScript, policyWithScript)
}
}
@@ -2332,6 +2350,17 @@ func (svc *Service) processScriptsForNewlyFailingPolicies(
continue
}
// On a continuous re-fire (policy still failing), reset prior
// attempt_number values for this host/policy to 0 so the new attempt
// restarts the retry sequence at 1 instead of inheriting the cap from
// the previous sequence. A no-op on pass→fail transitions (those rows
// are already at 0 from the prior fail→pass reset).
if failingPolicyWithScript.ContinuousAutomationsEnabled {
if err := svc.ds.ResetPolicyAutomationRetryAttemptsForHost(ctx, hostID, []uint{policyID}); err != nil {
return ctxerr.Wrap(ctx, err, "reset policy automation retry attempts for host")
}
}
contents, err := svc.ds.GetScriptContents(ctx, scriptMetadata.ID)
if err != nil {
return ctxerr.Wrap(ctx, err, "get script contents")
+44 -33
View File
@@ -24,22 +24,23 @@ import (
func teamPolicyEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
req := request.(*fleet.TeamPolicyRequest)
resp, err := svc.NewTeamPolicy(ctx, req.TeamID, fleet.NewTeamPolicyPayload{
QueryID: req.QueryID,
Name: req.Name,
Query: req.Query,
Description: req.Description,
Resolution: req.Resolution,
Platform: req.Platform,
Critical: req.Critical,
CalendarEventsEnabled: req.CalendarEventsEnabled,
SoftwareTitleID: req.SoftwareTitleID,
ScriptID: req.ScriptID,
LabelsIncludeAny: req.LabelsIncludeAny,
LabelsIncludeAll: req.LabelsIncludeAll,
LabelsExcludeAny: req.LabelsExcludeAny,
ConditionalAccessEnabled: req.ConditionalAccessEnabled,
Type: req.Type,
PatchSoftwareTitleID: req.PatchSoftwareTitleID,
QueryID: req.QueryID,
Name: req.Name,
Query: req.Query,
Description: req.Description,
Resolution: req.Resolution,
Platform: req.Platform,
Critical: req.Critical,
CalendarEventsEnabled: req.CalendarEventsEnabled,
SoftwareTitleID: req.SoftwareTitleID,
ScriptID: req.ScriptID,
LabelsIncludeAny: req.LabelsIncludeAny,
LabelsIncludeAll: req.LabelsIncludeAll,
LabelsExcludeAny: req.LabelsExcludeAny,
ConditionalAccessEnabled: req.ConditionalAccessEnabled,
ContinuousAutomationsEnabled: req.ContinuousAutomationsEnabled,
Type: req.Type,
PatchSoftwareTitleID: req.PatchSoftwareTitleID,
})
if err != nil {
return fleet.TeamPolicyResponse{Err: err}, nil
@@ -215,23 +216,24 @@ func (svc *Service) newTeamPolicyPayloadToPolicyPayload(ctx context.Context, tea
return fleet.PolicyPayload{}, err
}
return fleet.PolicyPayload{
QueryID: p.QueryID,
Name: p.Name,
Query: p.Query,
Critical: p.Critical,
Description: p.Description,
Resolution: p.Resolution,
Platform: p.Platform,
CalendarEventsEnabled: p.CalendarEventsEnabled,
SoftwareInstallerID: softwareInstallerID,
VPPAppsTeamsID: vppAppsTeamsID,
ScriptID: p.ScriptID,
LabelsIncludeAny: p.LabelsIncludeAny,
LabelsIncludeAll: p.LabelsIncludeAll,
LabelsExcludeAny: p.LabelsExcludeAny,
ConditionalAccessEnabled: p.ConditionalAccessEnabled,
Type: policyType,
PatchSoftwareTitleID: p.PatchSoftwareTitleID,
QueryID: p.QueryID,
Name: p.Name,
Query: p.Query,
Critical: p.Critical,
Description: p.Description,
Resolution: p.Resolution,
Platform: p.Platform,
CalendarEventsEnabled: p.CalendarEventsEnabled,
SoftwareInstallerID: softwareInstallerID,
VPPAppsTeamsID: vppAppsTeamsID,
ScriptID: p.ScriptID,
LabelsIncludeAny: p.LabelsIncludeAny,
LabelsIncludeAll: p.LabelsIncludeAll,
LabelsExcludeAny: p.LabelsExcludeAny,
ConditionalAccessEnabled: p.ConditionalAccessEnabled,
ContinuousAutomationsEnabled: p.ContinuousAutomationsEnabled,
Type: policyType,
PatchSoftwareTitleID: p.PatchSoftwareTitleID,
}, nil
}
@@ -528,6 +530,12 @@ func (svc *Service) modifyPolicy(ctx context.Context, teamID *uint, id uint, p f
})
}
if p.ContinuousAutomationsEnabled != nil && *p.ContinuousAutomationsEnabled && teamID == nil {
return nil, ctxerr.Wrap(ctx, &fleet.BadRequestError{
Message: fmt.Sprintf(`policy payload verification: %s`, errPolicyAllFleetsForContinuousAutomations),
})
}
p.Type = policy.Type
if err := p.Verify(); err != nil {
return nil, ctxerr.Wrap(ctx, &fleet.BadRequestError{
@@ -576,6 +584,9 @@ func (svc *Service) modifyPolicy(ctx context.Context, teamID *uint, id uint, p f
if p.ConditionalAccessEnabled != nil {
policy.ConditionalAccessEnabled = *p.ConditionalAccessEnabled
}
if p.ContinuousAutomationsEnabled != nil {
policy.ContinuousAutomationsEnabled = *p.ContinuousAutomationsEnabled
}
if removeStats {
policy.FailingHostCount = 0
policy.PassingHostCount = 0
+2
View File
@@ -129,6 +129,7 @@ func TestTriggerFailingPoliciesWebhookBasic(t *testing.T) {
"critical": true,
"calendar_events_enabled": false,
"conditional_access_enabled": false,
"continuous_automations_enabled": false,
"type": "dynamic"
},
"hosts": [
@@ -322,6 +323,7 @@ func TestTriggerFailingPoliciesWebhookTeam(t *testing.T) {
"critical": false,
"calendar_events_enabled": true,
"conditional_access_enabled": false,
"continuous_automations_enabled": false,
"type": "dynamic"
},
"hosts": [