Files
fleet/server/worker/software_worker.go
Nico f5ca4b5b0d Add Android support for custom host vitals (#49696)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #49421

Custom host vitals (`$FLEET_HOST_VITAL_<id>`) already worked in scripts
and Apple/Windows configuration profiles, but Android configuration
profiles and managed app configuration explicitly rejected them at
upload to keep parity with `$FLEET_SECRET_*`. This left admins unable to
inject per-host vitals (e.g. an asset tag) into Android MDM
configuration the same way they can for every other platform.

For more context, prior PRs:
- https://github.com/fleetdm/fleet/pull/49334
- https://github.com/fleetdm/fleet/pull/49586

# Checklist for submitter

- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files)
for more information.

## Testing

- [x] Added/updated automated tests

- [x] QA'd all new/changed functionality manually

- Created an "Asset tag" host vital.
- Enrolled an Android device.
- Initially the test profile showed as "Failed" because no value was set
for the vital.
- Set a value for the vital, saw that it went from Enforcing to
Verified.

<img width="1446" height="510" alt="Screenshot 2026-07-24 at 8 57 46 AM"
src="https://github.com/user-attachments/assets/c0e2348c-e521-48f3-85cd-6f884689b2cd"
/>
<img width="1520" height="936" alt="Screenshot 2026-07-24 at 8 56 56 AM"
src="https://github.com/user-attachments/assets/169b9545-ec7a-429b-8f45-0e2740f61c77"
/>
<img width="1607" height="1136" alt="Screenshot 2026-07-24 at 8 57
30 AM"
src="https://github.com/user-attachments/assets/a8213745-b224-4a36-a54d-32152a15c377"
/>

Also tested the rejection cases:
- trying to upload a profile with an invalid custom host vital id
(either a non-numeric value, a numeric but non-existent ID, and
referencing a vital as a JSON key instead of a value)
- deleting a vital referenced in a profile



https://github.com/user-attachments/assets/e8b4acde-ddf4-41c0-b00a-5ab4945d0bc2



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

## Summary by CodeRabbit

* **New Features**
* Android app configurations and profiles now support custom host vital
placeholders (`$FLEET_HOST_VITAL_<id>`).
* Custom host vital values are expanded per device during Android
delivery.
* Managed Android profiles/configurations are automatically resent when
a referenced vital value changes.

* **Bug Fixes**
* Added validation for malformed, missing, or undefined vital references
during Android app association and profile/config uploads.
  * Prevented deletion of vitals referenced by Android profiles.
* Improved error handling and delivery failure details when a device
lacks a required vital value.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-29 08:22:57 -03:00

835 lines
32 KiB
Go

package worker
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"time"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mdm/android"
"github.com/fleetdm/fleet/v4/server/mdm/profiles"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/google/uuid"
"google.golang.org/api/androidmanagement/v1"
"google.golang.org/api/googleapi"
)
const softwareWorkerJobName = "software_worker"
type SoftwareWorkerTask string
type SoftwareWorker struct {
Datastore fleet.Datastore
AndroidModule android.Service
Log *slog.Logger
AndroidBatchSize int
}
func (v *SoftwareWorker) Name() string {
return softwareWorkerJobName
}
const (
makeAndroidAppsAvailableForHostTask SoftwareWorkerTask = "make_android_apps_available_for_host" // deprecated
makeAndroidAppAvailableTask SoftwareWorkerTask = "make_android_app_available"
makeAndroidAppAvailableBatchTask SoftwareWorkerTask = "make_android_app_available_batch"
makeAndroidAppUnavailableTask SoftwareWorkerTask = "make_android_app_unavailable"
runAndroidSetupExperienceTask SoftwareWorkerTask = "run_android_setup_experience"
bulkSetAndroidAppsAvailableForHostTask SoftwareWorkerTask = "bulk_set_android_apps_available_for_host"
bulkSetAndroidAppsAvailableForHostsTask SoftwareWorkerTask = "bulk_set_android_apps_available_for_hosts"
)
type softwareWorkerArgs struct {
Task SoftwareWorkerTask `json:"task"`
HostUUID string `json:"host_uuid,omitempty"`
ApplicationID string `json:"application_id,omitempty"`
ApplicationIDs []string `json:"application_ids,omitempty"`
EnterpriseName string `json:"enterprise_name,omitempty"`
// AppTeamID is *not* a team ID, it is the vpp_apps_teams.id value. This is a bit confusing
// as a name, but that is what is expected in this field.
AppTeamID uint `json:"app_team_id,omitempty"` //nolint:apiparamcheck
HostID uint `json:"host_id,omitempty"`
// HostEnrollTeamID is the team ID associated with the host at the time
// of enrollment, which is the one used to run the setup experience.
// A value of 0 is used to represent "no team".
HostEnrollTeamID uint `json:"host_enroll_team_id,omitempty"` //nolint:apiparamcheck // not user-facing
// PolicyID is the Android Management API Policy ID associated with the host, *not*
// a Fleet policy ID.
PolicyID string `json:"policy_id,omitempty"`
// AppConfigChanged indicates if the android app configuration changed as part
// of the action that triggered this task.
AppConfigChanged bool `json:"app_config_changed,omitempty"`
UUIDsToIDs map[string]uint `json:"uuids_to_ids,omitempty"`
// HostUUIDToPolicyID is a map of host UUID as key to policy ID as value
// for which the app to make unavailable applies.
HostUUIDToPolicyID map[string]string `json:"host_uuid_to_policy_id,omitempty"`
}
func (v *SoftwareWorker) Run(ctx context.Context, argsJSON json.RawMessage) error {
var args softwareWorkerArgs
if err := json.Unmarshal(argsJSON, &args); err != nil {
return ctxerr.Wrap(ctx, err, "unmarshal args")
}
switch args.Task {
case makeAndroidAppsAvailableForHostTask:
// this task is deprecated (its logic is part of the run setup experience task), but must
// be kept here in case some pending jobs are still in the queue.
return ctxerr.Wrapf(
ctx,
v.makeAndroidAppsAvailableForHost(ctx, args.HostUUID, args.HostID, args.EnterpriseName, args.PolicyID),
"running %s task",
makeAndroidAppsAvailableForHostTask,
)
case makeAndroidAppAvailableTask:
return ctxerr.Wrapf(
ctx,
v.makeAndroidAppAvailable(ctx, args.ApplicationID, args.AppTeamID, args.EnterpriseName, args.AppConfigChanged),
"running %s task",
makeAndroidAppAvailableTask,
)
case makeAndroidAppAvailableBatchTask:
return ctxerr.Wrapf(
ctx,
v.makeAndroidAppAvailableBatch(ctx, args.ApplicationID, args.AppTeamID, args.HostUUIDToPolicyID, args.EnterpriseName, args.AppConfigChanged),
"running %s task",
makeAndroidAppAvailableBatchTask,
)
case makeAndroidAppUnavailableTask:
return ctxerr.Wrapf(
ctx,
v.makeAndroidAppUnavailable(ctx, args.ApplicationID, args.HostUUIDToPolicyID, args.EnterpriseName),
"running %s task",
makeAndroidAppUnavailableTask,
)
case runAndroidSetupExperienceTask:
return ctxerr.Wrapf(
ctx,
v.runAndroidSetupExperience(ctx, args.HostUUID, args.HostEnrollTeamID, args.EnterpriseName),
"running %s task",
runAndroidSetupExperienceTask,
)
case bulkSetAndroidAppsAvailableForHostTask:
return ctxerr.Wrapf(ctx, v.bulkMakeAndroidAppsAvailableForHost(
ctx,
args.HostUUID,
args.PolicyID,
args.ApplicationIDs,
args.EnterpriseName,
), "running %s task",
bulkSetAndroidAppsAvailableForHostTask)
case bulkSetAndroidAppsAvailableForHostsTask:
return ctxerr.Wrapf(ctx, v.bulkSetAndroidAppsAvailableForHosts(
ctx,
args.UUIDsToIDs,
args.EnterpriseName,
), "running %s task", bulkSetAndroidAppsAvailableForHostsTask)
default:
return ctxerr.Errorf(ctx, "unknown task: %v", args.Task)
}
}
// this is called when a new app is added to Fleet and when an existing app is updated
// (either its scope of affected hosts changed due to labels conditions, or its
// configuration changed).
func (v *SoftwareWorker) makeAndroidAppAvailable(ctx context.Context, applicationID string, appTeamID uint, enterpriseName string, appConfigChanged bool) error {
hosts, err := v.Datastore.GetIncludedHostUUIDMapForAppStoreApp(ctx, appTeamID)
if err != nil {
return ctxerr.Wrap(ctx, err, "add app store app: getting android hosts in scope")
}
if len(hosts) == 0 {
return nil
}
// Queue staggered batch jobs. The phase-2 handler handles per-host
// variable substitution within each batch, so we always chunk the same way.
batchSize := v.AndroidBatchSize
if batchSize <= 0 {
batchSize = defaultAndroidBatchSize
}
batches := splitHostMap(hosts, batchSize)
for i, batch := range batches {
delay := time.Duration(i) * androidSoftwareInstallStaggerInterval
if err := queueMakeAndroidAppAvailableBatch(ctx, v.Datastore, applicationID, appTeamID, batch, enterpriseName, appConfigChanged, delay); err != nil {
return ctxerr.Wrap(ctx, err, "queue batch for make android app available")
}
}
return nil
}
func (v *SoftwareWorker) makeAndroidAppAvailableBatch(ctx context.Context, applicationID string, appTeamID uint, hostUUIDToPolicyID map[string]string, enterpriseName string, appConfigChanged bool) error {
config, err := v.Datastore.GetAndroidAppConfigurationByAppTeamID(ctx, appTeamID)
if err != nil && !fleet.IsNotFound(err) {
return ctxerr.Wrap(ctx, err, "get android app configuration")
}
var configByAppID map[string][]byte
if config != nil {
configByAppID = map[string][]byte{applicationID: config}
}
needsPerHostSubstitution := config != nil && profiles.ContainsFleetVarOrCustomHostVital(config)
if needsPerHostSubstitution {
hostUUIDs := make([]string, 0, len(hostUUIDToPolicyID))
for uuid := range hostUUIDToPolicyID {
hostUUIDs = append(hostUUIDs, uuid)
}
filter := fleet.TeamFilter{User: &fleet.User{GlobalRole: new("admin")}}
hostDetails, err := v.Datastore.ListHostsLiteByUUIDs(ctx, filter, hostUUIDs)
if err != nil {
return ctxerr.Wrap(ctx, err, "batch fetch host details for variable substitution")
}
hostByUUID := make(map[string]*fleet.Host, len(hostDetails))
for _, h := range hostDetails {
hostByUUID[h.UUID] = h
}
for hostUUID := range hostUUIDToPolicyID {
h, ok := hostByUUID[hostUUID]
if !ok {
continue // host deleted since the job was queued
}
subHost := profiles.AndroidAppConfigSubstitutionHost{
HostID: h.ID,
UUID: h.UUID,
HardwareSerial: h.HardwareSerial,
Platform: h.Platform,
}
substituted, err := v.substituteFleetVarsInConfigs(ctx, configByAppID, subHost)
if err != nil {
return ctxerr.Wrapf(ctx, err, "substitute fleet vars for host %s", hostUUID)
}
appPolicies, err := buildApplicationPolicyWithConfig(ctx, []string{applicationID}, substituted, "AVAILABLE")
if err != nil {
return ctxerr.Wrapf(ctx, err, "building application policies with config for host %s", hostUUID)
}
singleHost := map[string]string{hostUUID: hostUUIDToPolicyID[hostUUID]}
policyRequestsByHost, err := v.AndroidModule.AddAppsToAndroidPolicy(ctx, enterpriseName, appPolicies, singleHost)
if err != nil {
return ctxerr.Wrapf(ctx, err, "add app to android policy for host %s", hostUUID)
}
if appConfigChanged {
for uuid, policyRequest := range policyRequestsByHost {
if err := v.Datastore.SetAndroidAppInstallPendingApplyConfig(ctx, uuid, applicationID, policyRequest.PolicyVersion.V); err != nil {
return ctxerr.Wrapf(ctx, err, "set android app install pending apply config for host %s and app %s", uuid, applicationID)
}
}
}
}
} else {
appPolicies, err := buildApplicationPolicyWithConfig(ctx, []string{applicationID}, configByAppID, "AVAILABLE")
if err != nil {
return ctxerr.Wrap(ctx, err, "building application policies with config")
}
policyRequestsByHost, err := v.AndroidModule.AddAppsToAndroidPolicy(ctx, enterpriseName, appPolicies, hostUUIDToPolicyID)
if err != nil {
return ctxerr.Wrap(ctx, err, "add app store app: add app to android policy")
}
if appConfigChanged {
for hostUUID, policyRequest := range policyRequestsByHost {
if err := v.Datastore.SetAndroidAppInstallPendingApplyConfig(ctx, hostUUID, applicationID, policyRequest.PolicyVersion.V); err != nil {
return ctxerr.Wrapf(ctx, err, "set android app install pending apply config for host %s and app %s", hostUUID, applicationID)
}
}
}
}
return nil
}
func queueMakeAndroidAppAvailableBatch(ctx context.Context, ds fleet.Datastore, applicationID string, appTeamID uint, hostUUIDToPolicyID map[string]string, enterpriseName string, appConfigChanged bool, delay time.Duration) error {
args := &softwareWorkerArgs{
Task: makeAndroidAppAvailableBatchTask,
ApplicationID: applicationID,
AppTeamID: appTeamID,
HostUUIDToPolicyID: hostUUIDToPolicyID,
EnterpriseName: enterpriseName,
AppConfigChanged: appConfigChanged,
}
_, err := QueueJobWithDelay(ctx, ds, softwareWorkerJobName, args, delay)
return err
}
// this is called when an app is removed from Fleet.
func (v *SoftwareWorker) makeAndroidAppUnavailable(ctx context.Context, applicationID string, hostUUIDToPolicyID map[string]string, enterpriseName string) error {
// Update Android MDM policy to remove the app from the hosts
_, err := v.AndroidModule.RemoveAppsFromAndroidPolicy(ctx, enterpriseName, []string{applicationID}, hostUUIDToPolicyID)
if err != nil {
return ctxerr.Wrap(ctx, err, "add app store app: add app to android policy")
}
return nil
}
func (v *SoftwareWorker) ensureHostSpecificPolicyIsApplied(ctx context.Context, hostUUID string, enterpriseName, policyID string) error {
if policyID == fmt.Sprint(android.DefaultAndroidPolicyID) {
var policy androidmanagement.Policy
policy.StatusReportingSettings = &androidmanagement.StatusReportingSettings{
DeviceSettingsEnabled: true,
MemoryInfoEnabled: true,
NetworkInfoEnabled: true,
DisplayInfoEnabled: true,
PowerManagementEventsEnabled: true,
HardwareStatusEnabled: true,
SystemPropertiesEnabled: true,
SoftwareInfoEnabled: true,
CommonCriteriaModeEnabled: true,
ApplicationReportsEnabled: true,
ApplicationReportingSettings: nil, // only option is "includeRemovedApps", which I opted not to enable (we can diff apps to see removals)
}
policyName := fmt.Sprintf("%s/policies/%s", enterpriseName, hostUUID)
_, err := v.AndroidModule.PatchPolicy(ctx, hostUUID, policyName, &policy, nil)
if err != nil {
return err
}
err = v.AndroidModule.BuildAndSendFleetAgentConfig(ctx, enterpriseName, []string{hostUUID}, false)
if err != nil {
return ctxerr.Wrapf(ctx, err, "build and send fleet agent config for host %s", hostUUID)
}
device := &androidmanagement.Device{
PolicyName: policyName,
// State must be specified when updating a device, otherwise it fails with
// "Illegal state transition from ACTIVE to DEVICE_STATE_UNSPECIFIED"
//
// > Note that when calling enterprises.devices.patch, ACTIVE and
// > DISABLED are the only allowable values.
// TODO(ap): should we send whatever the previous state was? If it was DISABLED,
// we probably don't want to re-enable it by accident. Those are the only
// 2 valid states when patching a device.
State: "ACTIVE",
}
androidHost, err := v.Datastore.AndroidHostLiteByHostUUID(ctx, hostUUID)
if err != nil {
return ctxerr.Wrapf(ctx, err, "get android host by host UUID %s", hostUUID)
}
deviceName := fmt.Sprintf("%s/devices/%s", enterpriseName, androidHost.DeviceID)
_, err = v.AndroidModule.PatchDevice(ctx, hostUUID, deviceName, device)
if err != nil {
return err
}
}
return nil
}
func (v *SoftwareWorker) makeAndroidAppsAvailableForHost(ctx context.Context, hostUUID string, hostID uint, enterpriseName, policyID string) error {
if err := v.ensureHostSpecificPolicyIsApplied(ctx, hostUUID, enterpriseName, policyID); err != nil {
return ctxerr.Wrapf(ctx, err, "ensuring host-specific policy is applied for host %s", hostUUID)
}
androidHost, err := v.Datastore.AndroidHostLiteByHostUUID(ctx, hostUUID)
if err != nil {
return ctxerr.Wrapf(ctx, err, "get android host by host UUID %s", hostUUID)
}
appIDs, err := v.Datastore.GetAndroidAppsInScopeForHost(ctx, hostID)
if err != nil {
return ctxerr.Wrap(ctx, err, "get android apps in scope for host")
}
if len(appIDs) == 0 {
return nil
}
configsByAppID, err := v.Datastore.BulkGetAndroidAppConfigurations(ctx, appIDs, ptr.ValOrZero(androidHost.TeamID))
if err != nil {
return ctxerr.Wrap(ctx, err, "bulk get android app configurations")
}
configsByAppID, err = v.substituteFleetVarsInConfigs(ctx, configsByAppID, androidHostToSubstitutionHost(androidHost))
if err != nil {
return ctxerr.Wrap(ctx, err, "substitute fleet vars in app configs")
}
appPolicies, err := buildApplicationPolicyWithConfig(ctx, appIDs, configsByAppID, "AVAILABLE")
if err != nil {
return ctxerr.Wrap(ctx, err, "building application policies with config")
}
_, err = v.AndroidModule.AddAppsToAndroidPolicy(ctx, enterpriseName, appPolicies, map[string]string{hostUUID: hostUUID})
if err != nil {
return ctxerr.Wrap(ctx, err, "add app store app: add app to android policy")
}
return nil
}
func (v *SoftwareWorker) runAndroidSetupExperience(ctx context.Context,
hostUUID string, hostEnrollTeamID uint, enterpriseName string,
) error {
host, err := v.Datastore.AndroidHostLiteByHostUUID(ctx, hostUUID)
if err != nil {
return ctxerr.Wrapf(ctx, err, "getting android host lite by uuid %s", hostUUID)
}
// first step is to make the apps available for self-service available on the host
// we do this first because it also takes care of assigning the host-specific policy
// to the host if necessary.
policyID := fmt.Sprint(android.DefaultAndroidPolicyID)
if host.AppliedPolicyID != nil {
policyID = *host.AppliedPolicyID
}
// TODO(mna): obviously it would be ideal to define a single policy at enroll time with
// everything it needs at once (instead of that call to add self-service app, and the subsequent
// one to install setup experience apps). I'll keep this as a follow-up optimization if we
// have a bit of time at the end of this story - it will require a somewhat significant refactor.
// Also, this may be more API-efficient, but less portable to our ordered, unified queue framework
// that eventually Android apps will have to fit into
// (see https://github.com/fleetdm/fleet/issues/33761#issuecomment-3553434984).
if err := v.makeAndroidAppsAvailableForHost(ctx, hostUUID, host.Host.ID, enterpriseName, policyID); err != nil {
return ctxerr.Wrapf(ctx, err, "making android apps available for host %s", hostUUID)
}
// TODO(mna): if the host has been transferred to another team before it had a chance to install
// the enrollment team's setup experience software, do we still run those installs?
// my guess is yes (because we don't _uninstall_ on team transfers, so it should be
// expected that the original team's software gets installed despite being transferred).
appIDs, err := v.Datastore.GetVPPAppsToInstallDuringSetupExperience(ctx, &hostEnrollTeamID, string(fleet.AndroidPlatform))
if err != nil {
return ctxerr.Wrapf(ctx, err, "getting vpp apps to install during setup experience for team %d", hostEnrollTeamID)
}
if len(appIDs) > 0 {
// NOTE: from my tests, we do need to re-apply the app configs when installing apps,
// even if they were already applied when making the apps available for self-service.
// However, once installed, if the app config changes it is applied automatically by the
// policy change (no need to re-install).
configsByAppID, err := v.Datastore.BulkGetAndroidAppConfigurations(ctx, appIDs, hostEnrollTeamID)
if err != nil {
return ctxerr.Wrap(ctx, err, "bulk get android app configurations")
}
configsByAppID, err = v.substituteFleetVarsInConfigs(ctx, configsByAppID, androidHostToSubstitutionHost(host))
if err != nil {
return ctxerr.Wrap(ctx, err, "substitute fleet vars in app configs")
}
appPolicies, err := buildApplicationPolicyWithConfig(ctx, appIDs, configsByAppID, "PREINSTALLED")
if err != nil {
return ctxerr.Wrap(ctx, err, "building application policies with config")
}
// assign those apps to the host's Android policy
hostToPolicyRequest, err := v.AndroidModule.AddAppsToAndroidPolicy(ctx, enterpriseName, appPolicies, map[string]string{hostUUID: hostUUID})
if err != nil {
return ctxerr.Wrap(ctx, err, "add app store app: add app to android policy")
}
// if it succeeded, it's guaranteed that only one entry exists in that map (as there's only one host)
var policyRequest *android.MDMAndroidPolicyRequest
for _, req := range hostToPolicyRequest {
policyRequest = req
}
for _, appID := range appIDs {
// NOTE: there is a unique index on the command uuid, so we cannot use the
// Android request's UUID for this, as we currently add many apps in the same request
// per host. For the moment, this is fine as we don't store any response of the request
// so there's nothing to show in the UI in the details of the install. When we work
// on "standard" Android app install support, we'll have to revisit this and either
// make one API request per app per host (which we may have to do anyway to support the
// unified queue), or make some DB changes.
//
// So in the meantime we use a random uuid in this place.
err := v.Datastore.InsertAndroidSetupExperienceSoftwareInstall(ctx, &fleet.HostAndroidVPPSoftwareInstall{
HostID: host.Host.ID,
AdamID: appID,
CommandUUID: uuid.NewString(),
AssociatedEventID: fmt.Sprint(policyRequest.PolicyVersion.V),
})
if err != nil {
return ctxerr.Wrap(ctx, err, "inserting android setup experience install request")
}
}
}
return nil
}
func (v *SoftwareWorker) bulkMakeAndroidAppsAvailableForHost(ctx context.Context, hostUUID, policyID string, applicationIDs []string, enterpriseName string) error {
host, err := v.Datastore.AndroidHostLiteByHostUUID(ctx, hostUUID)
if err != nil {
return ctxerr.Wrapf(ctx, err, "getting android host lite by uuid %s", hostUUID)
}
configsByAppID, err := v.Datastore.BulkGetAndroidAppConfigurations(ctx, applicationIDs, ptr.ValOrZero(host.Host.TeamID))
if err != nil {
return ctxerr.Wrap(ctx, err, "bulk get android app configurations")
}
configsByAppID, err = v.substituteFleetVarsInConfigs(ctx, configsByAppID, androidHostToSubstitutionHost(host))
if err != nil {
return ctxerr.Wrap(ctx, err, "substitute fleet vars in app configs")
}
appPolicies, err := buildApplicationPolicyWithConfig(ctx, applicationIDs, configsByAppID, "AVAILABLE")
if err != nil {
return ctxerr.Wrap(ctx, err, "building application policies with config")
}
// Include the Fleet Agent in the app list so it's not removed when we replace the apps.
fleetAgentPolicy, err := v.AndroidModule.BuildFleetAgentApplicationPolicy(ctx, hostUUID)
if err != nil {
v.Log.ErrorContext(ctx, "failed to build Fleet Agent policy, Fleet Agent may be removed", "host_uuid", hostUUID, "err", err)
} else if fleetAgentPolicy != nil {
appPolicies = append(appPolicies, fleetAgentPolicy)
}
// Update Android MDM policy to include the apps in self service
err = v.AndroidModule.SetAppsForAndroidPolicy(ctx, enterpriseName, appPolicies, map[string]string{hostUUID: policyID})
if err != nil {
return ctxerr.Wrap(ctx, err, "make android apps available")
}
return nil
}
func buildApplicationPolicyWithConfig(ctx context.Context, appIDs []string,
configsByAppID map[string][]byte, installType string,
) ([]*androidmanagement.ApplicationPolicy, error) {
appPolicies := make([]*androidmanagement.ApplicationPolicy, 0, len(appIDs))
for _, appID := range appIDs {
var androidAppConfig struct {
ManagedConfiguration json.RawMessage `json:"managedConfiguration"`
WorkProfileWidgets string `json:"workProfileWidgets"`
}
if config := configsByAppID[appID]; config != nil {
if err := json.Unmarshal(config, &androidAppConfig); err != nil {
// should never happen, as it is stored as json in the db and is pre-validated
return nil, ctxerr.Wrap(ctx, err, "unmarshal android app configuration")
}
} else {
// if there is no config for this app, we must make sure we clear any previously-applied
// config.
androidAppConfig.ManagedConfiguration = json.RawMessage{}
androidAppConfig.WorkProfileWidgets = "WORK_PROFILE_WIDGETS_UNSPECIFIED"
}
appPolicies = append(appPolicies, &androidmanagement.ApplicationPolicy{
PackageName: appID,
InstallType: installType,
ManagedConfiguration: googleapi.RawMessage(androidAppConfig.ManagedConfiguration),
WorkProfileWidgets: androidAppConfig.WorkProfileWidgets,
})
}
return appPolicies, nil
}
func (v *SoftwareWorker) substituteFleetVarsInConfigs(
ctx context.Context,
configsByAppID map[string][]byte,
host profiles.AndroidAppConfigSubstitutionHost,
) (map[string][]byte, error) {
if len(configsByAppID) == 0 {
return configsByAppID, nil
}
hasVars := false
for _, cfg := range configsByAppID {
if profiles.ContainsFleetVarOrCustomHostVital(cfg) {
hasVars = true
break
}
}
if !hasVars {
return configsByAppID, nil
}
result := make(map[string][]byte, len(configsByAppID))
for appID, cfg := range configsByAppID {
substituted, err := profiles.SubstituteFleetVarsAndVitalsInAndroidAppConfig(ctx, v.Datastore, cfg, host)
if err != nil {
return nil, ctxerr.Wrapf(ctx, err, "substitute fleet vars in android app config for app %s", appID)
}
result[appID] = substituted
}
return result, nil
}
func androidHostToSubstitutionHost(h *fleet.AndroidHost) profiles.AndroidAppConfigSubstitutionHost {
return profiles.AndroidAppConfigSubstitutionHost{
HostID: h.Host.ID,
UUID: h.Host.UUID,
HardwareSerial: h.Host.HardwareSerial,
Platform: h.Host.Platform,
}
}
func QueueRunAndroidSetupExperience(ctx context.Context, ds fleet.Datastore, logger *slog.Logger,
hostUUID string, hostEnrollTeamID *uint, enterpriseName string,
) error {
var enrollTeamID uint
if hostEnrollTeamID != nil {
enrollTeamID = *hostEnrollTeamID
}
args := &softwareWorkerArgs{
Task: runAndroidSetupExperienceTask,
HostUUID: hostUUID,
EnterpriseName: enterpriseName,
HostEnrollTeamID: enrollTeamID,
}
job, err := QueueJob(ctx, ds, softwareWorkerJobName, args)
if err != nil {
return ctxerr.Wrap(ctx, err, "queueing job")
}
logger.DebugContext(ctx, "queued android setup experience job", "job_id", job.ID, "job_name", softwareWorkerJobName, "task", args.Task)
return nil
}
func QueueMakeAndroidAppAvailableJob(ctx context.Context, ds fleet.Datastore, logger *slog.Logger, applicationID string, appTeamID uint, enterpriseName string, appConfigChanged bool) error {
args := &softwareWorkerArgs{
Task: makeAndroidAppAvailableTask,
ApplicationID: applicationID,
AppTeamID: appTeamID,
EnterpriseName: enterpriseName,
AppConfigChanged: appConfigChanged,
}
job, err := QueueJob(ctx, ds, softwareWorkerJobName, args)
if err != nil {
return ctxerr.Wrap(ctx, err, "queueing job")
}
logger.DebugContext(ctx, "queued software worker job", "job_id", job.ID, "job_name", softwareWorkerJobName, "task", args.Task)
return nil
}
func QueueMakeAndroidAppUnavailableJob(ctx context.Context, ds fleet.Datastore, logger *slog.Logger, applicationID string, hostsUUIDToPolicyID map[string]string, enterpriseName string, batchSize int) error {
if batchSize <= 0 || len(hostsUUIDToPolicyID) <= batchSize {
return queueMakeAndroidAppUnavailableJob(ctx, ds, logger, applicationID, hostsUUIDToPolicyID, enterpriseName, 0)
}
batch := make(map[string]string, batchSize)
batchIdx := 0
for uuid, policyID := range hostsUUIDToPolicyID {
batch[uuid] = policyID
if len(batch) >= batchSize {
delay := time.Duration(batchIdx) * androidSoftwareInstallStaggerInterval
if err := queueMakeAndroidAppUnavailableJob(ctx, ds, logger, applicationID, batch, enterpriseName, delay); err != nil {
return err
}
batch = make(map[string]string, batchSize)
batchIdx++
}
}
if len(batch) > 0 {
delay := time.Duration(batchIdx) * androidSoftwareInstallStaggerInterval
if err := queueMakeAndroidAppUnavailableJob(ctx, ds, logger, applicationID, batch, enterpriseName, delay); err != nil {
return err
}
}
return nil
}
func queueMakeAndroidAppUnavailableJob(ctx context.Context, ds fleet.Datastore, logger *slog.Logger, applicationID string, hostsUUIDToPolicyID map[string]string, enterpriseName string, delay time.Duration) error {
args := &softwareWorkerArgs{
Task: makeAndroidAppUnavailableTask,
ApplicationID: applicationID,
HostUUIDToPolicyID: hostsUUIDToPolicyID,
EnterpriseName: enterpriseName,
}
job, err := QueueJobWithDelay(ctx, ds, softwareWorkerJobName, args, delay)
if err != nil {
return ctxerr.Wrap(ctx, err, "queueing job")
}
logger.DebugContext(ctx, "queued software worker job", "job_id", job.ID, "job_name", softwareWorkerJobName, "task", args.Task, "delay", delay, "hosts", len(hostsUUIDToPolicyID))
return nil
}
func QueueBulkSetAndroidAppsAvailableForHost(
ctx context.Context,
ds fleet.Datastore,
logger *slog.Logger,
hostUUID string,
policyID string,
applicationIDs []string,
enterpriseName string,
) error {
args := &softwareWorkerArgs{
Task: bulkSetAndroidAppsAvailableForHostTask,
HostUUID: hostUUID,
PolicyID: policyID,
EnterpriseName: enterpriseName,
ApplicationIDs: applicationIDs,
}
job, err := QueueJob(ctx, ds, softwareWorkerJobName, args)
if err != nil {
return ctxerr.Wrap(ctx, err, "queueing job")
}
logger.DebugContext(ctx, "queued software worker job", "job_id", job.ID, "job_name", softwareWorkerJobName, "task", args.Task)
return nil
}
func (v *SoftwareWorker) bulkSetAndroidAppsAvailableForHosts(ctx context.Context, uuidsToIDs map[string]uint, enterpriseName string) error {
// for each host
// get the set of self-service apps that are in scope for it
for uuid, hostID := range uuidsToIDs {
androidHost, err := v.Datastore.AndroidHostLiteByHostUUID(ctx, uuid)
if err != nil {
return ctxerr.Wrapf(ctx, err, "get android host by host UUID %s", uuid)
}
teamID := ptr.ValOrZero(androidHost.TeamID)
// Update certificate templates for team transfer:
// 1. Mark old templates as pending removal
// 2. Create new pending templates for the new team
// This must happen before building the managed config, which includes certificate template IDs.
if err := v.Datastore.SetHostCertificateTemplatesToPendingRemoveForHost(ctx, uuid); err != nil {
return ctxerr.Wrap(ctx, err, "set host certificate templates to pending remove for host")
}
if _, err := v.Datastore.CreatePendingCertificateTemplatesForNewHost(ctx, uuid, teamID); err != nil {
return ctxerr.Wrap(ctx, err, "create pending certificate templates for new host")
}
appIDs, err := v.Datastore.GetAndroidAppsInScopeForHost(ctx, hostID)
if err != nil {
return ctxerr.WrapWithData(ctx, err, "get android apps in scope for host", map[string]any{"host_id": hostID})
}
configsByAppID, err := v.Datastore.BulkGetAndroidAppConfigurations(ctx, appIDs, teamID)
if err != nil {
return ctxerr.Wrap(ctx, err, "bulk get android app configurations")
}
configsByAppID, err = v.substituteFleetVarsInConfigs(ctx, configsByAppID, androidHostToSubstitutionHost(androidHost))
if err != nil {
return ctxerr.Wrap(ctx, err, "substitute fleet vars in app configs")
}
appPolicies, err := buildApplicationPolicyWithConfig(ctx, appIDs, configsByAppID, "AVAILABLE")
if err != nil {
return ctxerr.Wrap(ctx, err, "building application policies with config")
}
// Include the Fleet Agent in the app list so it's not removed when we replace the apps.
fleetAgentPolicy, err := v.AndroidModule.BuildFleetAgentApplicationPolicy(ctx, uuid)
if err != nil {
v.Log.ErrorContext(ctx, "failed to build Fleet Agent policy, Fleet Agent may be removed", "host_uuid", uuid, "err", err)
} else if fleetAgentPolicy != nil {
appPolicies = append(appPolicies, fleetAgentPolicy)
}
err = v.AndroidModule.SetAppsForAndroidPolicy(ctx, enterpriseName, appPolicies, map[string]string{uuid: uuid})
if err != nil {
return ctxerr.WrapWithData(ctx, err, "set apps for android policy", map[string]any{"host_id": hostID})
}
}
return nil
}
const (
androidSoftwareInstallStaggerInterval = 60 * time.Second
defaultAndroidBatchSize = 100
)
func QueueBulkSetAndroidAppsAvailableForHosts(
ctx context.Context,
ds fleet.Datastore,
logger *slog.Logger,
uuidsToIDs map[string]uint,
enterpriseName string,
batchSize int,
) error {
if batchSize <= 0 || len(uuidsToIDs) <= batchSize {
// No batching needed.
return queueBulkSetAndroidAppsAvailableForHostsJob(ctx, ds, logger, uuidsToIDs, enterpriseName, 0)
}
// Chunk the host map into batches and stagger with not_before.
batch := make(map[string]uint, batchSize)
batchIdx := 0
for uuid, id := range uuidsToIDs {
batch[uuid] = id
if len(batch) >= batchSize {
delay := time.Duration(batchIdx) * androidSoftwareInstallStaggerInterval
if err := queueBulkSetAndroidAppsAvailableForHostsJob(ctx, ds, logger, batch, enterpriseName, delay); err != nil {
return err
}
batch = make(map[string]uint, batchSize)
batchIdx++
}
}
// Queue remaining hosts.
if len(batch) > 0 {
delay := time.Duration(batchIdx) * androidSoftwareInstallStaggerInterval
if err := queueBulkSetAndroidAppsAvailableForHostsJob(ctx, ds, logger, batch, enterpriseName, delay); err != nil {
return err
}
}
return nil
}
func queueBulkSetAndroidAppsAvailableForHostsJob(
ctx context.Context,
ds fleet.Datastore,
logger *slog.Logger,
uuidsToIDs map[string]uint,
enterpriseName string,
delay time.Duration,
) error {
args := &softwareWorkerArgs{
Task: bulkSetAndroidAppsAvailableForHostsTask,
UUIDsToIDs: uuidsToIDs,
EnterpriseName: enterpriseName,
}
job, err := QueueJobWithDelay(ctx, ds, softwareWorkerJobName, args, delay)
if err != nil {
return ctxerr.Wrap(ctx, err, "queueing job")
}
logger.DebugContext(ctx, "queued software worker job", "job_id", job.ID, "job_name", softwareWorkerJobName, "task", args.Task, "delay", delay, "hosts", len(uuidsToIDs))
return nil
}
func splitHostMap(hosts map[string]string, batchSize int) []map[string]string {
if batchSize <= 0 || len(hosts) <= batchSize {
return []map[string]string{hosts}
}
var batches []map[string]string
batch := make(map[string]string, batchSize)
for k, v := range hosts {
batch[k] = v
if len(batch) >= batchSize {
batches = append(batches, batch)
batch = make(map[string]string, batchSize)
}
}
if len(batch) > 0 {
batches = append(batches, batch)
}
return batches
}