Related user story
#33995
Task
Make a selected .ipa actually install while the device is held in Setup Assistant, and make its result drive the device release.
Types
server/fleet/setup_experience.go:
- Add
InHouseAppID *uint \db:"in_house_app_id" json:"-"`toSetupExperienceStatusResult (:37-70`).
- Extend
IsValid() (:72-105) to count it as a fourth mutually exclusive column, and to reject it paired with HostSoftwareInstallsExecutionID or ScriptExecutionID (it pairs with NanoCommandUUID, like VPP). Keep the existing policy_gated guard installer-only.
- Include it in
IsForSoftware() (:122-128) and add IsForInHouseApp().
Enqueue
enqueueSetupExperienceItems (server/datastore/mysql/setup_experience.go:31) — add a third UNION branch, gated on fleetPlatform being ios/ipados, modelled on the VPP branch at :298-331:
SELECT ? AS host_uuid, st.name AS name, 'pending' AS status,
NULL AS software_installer_id, NULL AS vpp_app_team_id, iha.id AS in_house_app_id,
FALSE AS policy_gated,
COALESCE(stdn.display_name, st.name) AS sort_name, st.id AS software_title_id
FROM in_house_apps iha
INNER JOIN software_titles st ON iha.title_id = st.id
LEFT JOIN software_title_display_names stdn ON stdn.software_title_id = st.id AND stdn.team_id = ?
WHERE iha.install_during_setup = true AND iha.global_or_team_id = ? AND iha.platform = ?
Add in_house_app_id to the INSERT column list and the inner SELECT at :338-356. The ROW_NUMBER() OVER (PARTITION BY software_title_id ...) dedup is a no-op across the iOS and iPadOS rows (different titles), but the partition list still needs the new column carried through. Mirror the branch in the resetFailedSetupSteps variant.
ListSetupExperienceResultsByHostUUID (:742) — LEFT JOIN in_house_apps iha ON iha.id = sesr.in_house_app_id and extend the software_title_id and source COALESCE chains (:759-763) so display name and icon resolution work for in-house rows.
Install during Setup Assistant
installSetupExperienceVPPAppsOnIosIpadOS (server/worker/apple_mdm.go:575) is the whole iOS/iPadOS setup-experience driver — called from post-DEP enrollment (:204-208) and post-manual enrollment (:136-141). Today it fails any non-VPP row at :593-601 with "unexpected setup experience item for iOS/iPadOS device, only VPP apps are supported". Rename it (installSetupExperienceAppsOnIosIpadOS) and add an in-house branch to the loop at :604-667:
- Enqueue via the equivalent of
InsertHostInHouseAppInstall (server/datastore/mysql/in_house_apps.go:531) with fleet.HostSoftwareInstallOptions{SelfService: false, ForSetupExperience: true}.
- The enqueue must carry the
precheckAppConfigResolvable pre-flight from the manual install path (ee/server/service/software_installers.go:1665-1670), so an unresolvable $FLEET_VAR_* fails the item with a user-facing reason instead of producing a broken command. That path is in ee, so expose it to the worker the same way VPP is — an installer interface on the worker mirroring VPPInstaller (server/worker/apple_mdm.go:672-683) — rather than duplicating the logic.
- Do not carry over the label-scope check. The manual path calls
IsInHouseAppLabelScoped and hard-fails an out-of-scope host (ee/server/service/software_installers.go:1649-1658); setup experience must skip that call entirely. Reuse the config pre-flight without it.
- On success, set
NanoCommandUUID to the command UUID (InsertHostInHouseAppInstall's commandUUID is the upcoming_activities.execution_id and becomes the MDM command UUID in activateNextInHouseAppInstallActivity), set status running, and append to the returned slice so the UUID lands in awaitCmdUUIDs for the release job.
- On failure before the command is sent, set status
failure + Error, and emit a failure activity — mirroring :636-665.
Why no label check (product decision): setup experience ignores label scoping for every software type today. VPP's setup-experience path at :633 has no scope check — IsVPPAppLabelScoped only guards manual and self-service installs — and enqueueSetupExperienceItems states it outright in the comment above its INSERT: "labels don't apply during setup" (server/datastore/mysql/setup_experience.go:335-337). Enforcing them for .ipa alone would also be unreliable: a freshly ABM-enrolled iOS host has little to no computed label membership at post-DEP time, so an out-of-scope result would usually be a race rather than intent. The enqueue query in the previous section deliberately has no in_house_app_labels join.
SetupExperienceNextStep (ee/server/service/setup_experience.go:324-415) switches on SoftwareInstallerID then VPPAppTeamID. iOS never reaches it, but an unhandled in-house row would fall through both cases and silently stall the flow. Add a branch or an explicit guard.
Command results
- In-house install failure produces no activity today. The
InstallApplication error branch (server/service/apple_mdm.go:5107-5158) calls GetHostVPPInstallByCommandUUID (nil for in-house, so no retry — correct), then maybeUpdateSetupExperienceStatus (works for in-house, since it keys on nano_command_uuid), then GetPastActivityDataForVPPAppInstall, which returns not-found and return nil, nil at :5147-5150. Add a GetPastActivityDataForInHouseAppInstall fallback (server/datastore/mysql/in_house_apps.go:683) before bailing.
- Tag the setup-experience activity. The in-house
activityFn in server/service/apple_mdm_cmd_results.go:224 drops the fromSetupExp argument. Plumb it into ActivityTypeInstalledSoftware.FromSetupExperience (server/fleet/activities.go:1211), which already exists.
No new terminal-status datastore method. MaybeUpdateSetupExperienceVPPStatus (server/datastore/mysql/setup_experience.go:1098) matches host_uuid + nano_command_uuid and already fires for in-house installs from inside the shared closure at apple_mdm_cmd_results.go:169. Consider renaming it to drop "VPP" since it now serves both.
Run make generate-mock after any datastore interface change.
Condition of satisfaction
Enqueue:
- An ABM-enrolled iPhone on a team with the iOS title selected gets exactly one
setup_experience_status_results row with in_house_app_id set and awaiting_configuration = 1.
- An enrolling iPad on the same team gets nothing when only the iOS title is selected; the inverse holds when the selection is swapped.
IsValid() rejects a row with both in_house_app_id and vpp_app_team_id, and a row with in_house_app_id + host_software_installs_execution_id.
- Tests pass for manual enrollment as well
Install and release:
- Fleet enqueues an
InstallApplication with ManifestURL pointing at /api/latest/fleet/software/titles/{id}/in_house_app/manifest/{token}; the token TTL (fleet.InHouseAppInstallTokenTTL, 6h) comfortably covers Setup Assistant.
DeviceConfigured is not sent until the row reaches a terminal status; verified against the setup_experience_status_results gate at server/worker/apple_mdm.go:533-555.
- Post-Setup-Assistant, the app is installed and launchable; host software inventory shows it as Installed and the install activity appears on Host details.
- Mixed payload: a
.ipa and a VPP app on the same platform both install, ordered by the sort_name ordering applied at enqueue.
Failure paths:
- A failing install (incompatible OS version, or a corrupt/unsigned manifest) flips the row to
failure, emits an installed_software activity with status: failed and from_setup_experience: true, and the host is still released — not left in AwaitingConfiguration.
- An install that never reaches a terminal state is released by the existing cap (15 min / 30 attempts).
- Confirm no cancel cascade fires:
isAllSetupExperienceSoftwareRequired (server/service/setup_experience.go:242) returns false for iOS, so remaining items are not canceled.
Label scoping ignored:
- A title with an exclude-any label the host matches (or an include-any label it does not match) still installs during setup experience. Assert with a host that would be rejected by
IsInHouseAppLabelScoped, so a future reintroduction of the check fails the test.
- The same title still rejects that host on a manual install from Host details, confirming the scope check was skipped only on the setup path.
Managed app configuration:
- Stored configuration and
$FLEET_VAR_* substitution are applied to the setup-experience install, not just post-enrollment installs — the substitution happens in activateNextInHouseAppInstallActivity (server/datastore/mysql/activities.go:1546-1553), so this should follow, but assert it in tests.
- An unresolvable variable fails the item with the user-facing reason rather than wedging setup.
Tests: MYSQL_TEST=1 go test ./server/datastore/mysql/..., MYSQL_TEST=1 REDIS_TEST=1 go test ./server/service/... ./server/worker/... ./ee/server/service/.... Add coverage to server/service/integration_mdm_setup_experience_test.go for the iOS .ipa path end to end.
Related user story
#33995
Task
Make a selected
.ipaactually install while the device is held in Setup Assistant, and make its result drive the device release.Types
server/fleet/setup_experience.go:InHouseAppID *uint \db:"in_house_app_id" json:"-"`toSetupExperienceStatusResult(:37-70`).IsValid()(:72-105) to count it as a fourth mutually exclusive column, and to reject it paired withHostSoftwareInstallsExecutionIDorScriptExecutionID(it pairs withNanoCommandUUID, like VPP). Keep the existingpolicy_gatedguard installer-only.IsForSoftware()(:122-128) and addIsForInHouseApp().Enqueue
enqueueSetupExperienceItems(server/datastore/mysql/setup_experience.go:31) — add a third UNION branch, gated onfleetPlatformbeingios/ipados, modelled on the VPP branch at:298-331:Add
in_house_app_idto the INSERT column list and the inner SELECT at:338-356. TheROW_NUMBER() OVER (PARTITION BY software_title_id ...)dedup is a no-op across the iOS and iPadOS rows (different titles), but the partition list still needs the new column carried through. Mirror the branch in theresetFailedSetupStepsvariant.ListSetupExperienceResultsByHostUUID(:742) —LEFT JOIN in_house_apps iha ON iha.id = sesr.in_house_app_idand extend thesoftware_title_idandsourceCOALESCE chains (:759-763) so display name and icon resolution work for in-house rows.Install during Setup Assistant
installSetupExperienceVPPAppsOnIosIpadOS(server/worker/apple_mdm.go:575) is the whole iOS/iPadOS setup-experience driver — called from post-DEP enrollment (:204-208) and post-manual enrollment (:136-141). Today it fails any non-VPP row at:593-601with "unexpected setup experience item for iOS/iPadOS device, only VPP apps are supported". Rename it (installSetupExperienceAppsOnIosIpadOS) and add an in-house branch to the loop at:604-667:InsertHostInHouseAppInstall(server/datastore/mysql/in_house_apps.go:531) withfleet.HostSoftwareInstallOptions{SelfService: false, ForSetupExperience: true}.precheckAppConfigResolvablepre-flight from the manual install path (ee/server/service/software_installers.go:1665-1670), so an unresolvable$FLEET_VAR_*fails the item with a user-facing reason instead of producing a broken command. That path is inee, so expose it to the worker the same way VPP is — an installer interface on the worker mirroringVPPInstaller(server/worker/apple_mdm.go:672-683) — rather than duplicating the logic.IsInHouseAppLabelScopedand hard-fails an out-of-scope host (ee/server/service/software_installers.go:1649-1658); setup experience must skip that call entirely. Reuse the config pre-flight without it.NanoCommandUUIDto the command UUID (InsertHostInHouseAppInstall'scommandUUIDis theupcoming_activities.execution_idand becomes the MDM command UUID inactivateNextInHouseAppInstallActivity), set statusrunning, and append to the returned slice so the UUID lands inawaitCmdUUIDsfor the release job.failure+Error, and emit a failure activity — mirroring:636-665.Why no label check (product decision): setup experience ignores label scoping for every software type today. VPP's setup-experience path at
:633has no scope check —IsVPPAppLabelScopedonly guards manual and self-service installs — andenqueueSetupExperienceItemsstates it outright in the comment above its INSERT: "labels don't apply during setup" (server/datastore/mysql/setup_experience.go:335-337). Enforcing them for.ipaalone would also be unreliable: a freshly ABM-enrolled iOS host has little to no computed label membership at post-DEP time, so an out-of-scope result would usually be a race rather than intent. The enqueue query in the previous section deliberately has noin_house_app_labelsjoin.SetupExperienceNextStep(ee/server/service/setup_experience.go:324-415) switches onSoftwareInstallerIDthenVPPAppTeamID. iOS never reaches it, but an unhandled in-house row would fall through both cases and silently stall the flow. Add a branch or an explicit guard.Command results
InstallApplicationerror branch (server/service/apple_mdm.go:5107-5158) callsGetHostVPPInstallByCommandUUID(nil for in-house, so no retry — correct), thenmaybeUpdateSetupExperienceStatus(works for in-house, since it keys onnano_command_uuid), thenGetPastActivityDataForVPPAppInstall, which returns not-found andreturn nil, nilat:5147-5150. Add aGetPastActivityDataForInHouseAppInstallfallback (server/datastore/mysql/in_house_apps.go:683) before bailing.activityFninserver/service/apple_mdm_cmd_results.go:224drops thefromSetupExpargument. Plumb it intoActivityTypeInstalledSoftware.FromSetupExperience(server/fleet/activities.go:1211), which already exists.No new terminal-status datastore method.
MaybeUpdateSetupExperienceVPPStatus(server/datastore/mysql/setup_experience.go:1098) matcheshost_uuid+nano_command_uuidand already fires for in-house installs from inside the shared closure atapple_mdm_cmd_results.go:169. Consider renaming it to drop "VPP" since it now serves both.Run
make generate-mockafter any datastore interface change.Condition of satisfaction
Enqueue:
setup_experience_status_resultsrow within_house_app_idset andawaiting_configuration = 1.IsValid()rejects a row with bothin_house_app_idandvpp_app_team_id, and a row within_house_app_id+host_software_installs_execution_id.Install and release:
InstallApplicationwithManifestURLpointing at/api/latest/fleet/software/titles/{id}/in_house_app/manifest/{token}; the token TTL (fleet.InHouseAppInstallTokenTTL, 6h) comfortably covers Setup Assistant.DeviceConfiguredis not sent until the row reaches a terminal status; verified against thesetup_experience_status_resultsgate atserver/worker/apple_mdm.go:533-555..ipaand a VPP app on the same platform both install, ordered by thesort_nameordering applied at enqueue.Failure paths:
failure, emits aninstalled_softwareactivity withstatus: failedandfrom_setup_experience: true, and the host is still released — not left inAwaitingConfiguration.isAllSetupExperienceSoftwareRequired(server/service/setup_experience.go:242) returns false for iOS, so remaining items are not canceled.Label scoping ignored:
IsInHouseAppLabelScoped, so a future reintroduction of the check fails the test.Managed app configuration:
$FLEET_VAR_*substitution are applied to the setup-experience install, not just post-enrollment installs — the substitution happens inactivateNextInHouseAppInstallActivity(server/datastore/mysql/activities.go:1546-1553), so this should follow, but assert it in tests.Tests:
MYSQL_TEST=1 go test ./server/datastore/mysql/...,MYSQL_TEST=1 REDIS_TEST=1 go test ./server/service/... ./server/worker/... ./ee/server/service/.... Add coverage toserver/service/integration_mdm_setup_experience_test.gofor the iOS.ipapath end to end.