From 87c1a719c13ea4174998cd21c509fb18e276ab24 Mon Sep 17 00:00:00 2001 From: Nico <32375741+nulmete@users.noreply.github.com> Date: Wed, 22 Jul 2026 09:38:24 -0300 Subject: [PATCH] Support custom host vitals in host name templates (#49586) **Related issue:** Resolves #49489 Custom host vitals were skipped when host name template enforcement (#38806) shipped, since both features were in development at the same time. This adds `$FLEET_HOST_VITAL_` support to host name templates, matching the existing secret-variable pattern (validation, per-host resolution, resend on value change). I also introduced a new `IsInvalidReferencedCustomHostVitalsError` call after Copilot's comment below. # 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 ## Summary by CodeRabbit ## Summary by CodeRabbit * **New Features** * Added support for `$FLEET_HOST_VITAL_` in Apple host name templates. * Device-name template reconciliation now expands referenced per-host vital values and updates automatically when those values change. * **Bug Fixes** * Prevents deleting custom host vitals that are referenced by host name templates. * If a referenced vital has no value for a host, device-name delivery is marked failed for that host (retryable). * **Improved Error Handling** * Refined validation behavior so unknown/malformed vital references return user-facing invalid-argument errors, while infrastructure errors propagate unchanged. --- ...49489-custom-host-vitals-in-name-templates | 1 + ee/server/service/maintained_apps.go | 3 + ee/server/service/setup_experience.go | 3 + ee/server/service/setup_experience_test.go | 24 ++++ ee/server/service/software_installers.go | 9 ++ server/datastore/mysql/apple_device_names.go | 32 +++++ server/datastore/mysql/custom_host_vitals.go | 29 ++++- .../mysql/custom_host_vitals_test.go | 113 ++++++++++++++++++ server/fleet/custom_host_vitals.go | 23 +++- server/fleet/custom_host_vitals_test.go | 41 +++++++ server/fleet/name_template.go | 20 +++- server/fleet/name_template_test.go | 24 ++++ server/service/apple_device_names.go | 19 +++ server/service/apple_device_names_test.go | 68 +++++++++++ server/service/apple_mdm.go | 9 ++ server/service/apple_mdm_test.go | 60 ++++++++++ server/service/mdm.go | 3 + server/service/mdm_test.go | 79 ++++++++++++ 18 files changed, 556 insertions(+), 4 deletions(-) create mode 100644 changes/49489-custom-host-vitals-in-name-templates diff --git a/changes/49489-custom-host-vitals-in-name-templates b/changes/49489-custom-host-vitals-in-name-templates new file mode 100644 index 0000000000..45780e2d00 --- /dev/null +++ b/changes/49489-custom-host-vitals-in-name-templates @@ -0,0 +1 @@ +* Added support for `$FLEET_HOST_VITAL_` custom host vital variables in host name templates, including per-host resolution, validation of referenced vital IDs, and automatic re-delivery when a host's vital value changes. diff --git a/ee/server/service/maintained_apps.go b/ee/server/service/maintained_apps.go index f9278bea0e..80babeab49 100644 --- a/ee/server/service/maintained_apps.go +++ b/ee/server/service/maintained_apps.go @@ -58,6 +58,9 @@ func (svc *Service) AddFleetMaintainedApp( return 0, ctxerr.Wrap(ctx, err, "transient server issue validating embedded secrets") } if err := svc.ds.ValidateReferencedCustomHostVitals(ctx, []string{installScript, postInstallScript, uninstallScript}); err != nil { + if !fleet.IsInvalidReferencedCustomHostVitalsError(err) { + return 0, ctxerr.Wrap(ctx, err, "validating referenced custom host vitals") + } var argErr *fleet.InvalidArgumentError argErr = svc.validateReferencedCustomHostVitalsOnScript(ctx, "install script", &installScript, argErr) argErr = svc.validateReferencedCustomHostVitalsOnScript(ctx, "post-install script", &postInstallScript, argErr) diff --git a/ee/server/service/setup_experience.go b/ee/server/service/setup_experience.go index 6f3a61b692..f329a64a61 100644 --- a/ee/server/service/setup_experience.go +++ b/ee/server/service/setup_experience.go @@ -141,6 +141,9 @@ func (svc *Service) SetSetupExperienceScript(ctx context.Context, teamID *uint, return fleet.NewInvalidArgumentError("script", err.Error()) } if err := svc.ds.ValidateReferencedCustomHostVitals(ctx, []string{script.ScriptContents}); err != nil { + if !fleet.IsInvalidReferencedCustomHostVitalsError(err) { + return ctxerr.Wrap(ctx, err, "validating referenced custom host vitals") + } return fleet.NewInvalidArgumentError("script", err.Error()) } diff --git a/ee/server/service/setup_experience_test.go b/ee/server/service/setup_experience_test.go index 896957a891..6972484fa7 100644 --- a/ee/server/service/setup_experience_test.go +++ b/ee/server/service/setup_experience_test.go @@ -3,6 +3,7 @@ package service import ( "bytes" "context" + "errors" "fmt" "io" "testing" @@ -10,6 +11,7 @@ import ( "github.com/WatchBeam/clock" "github.com/fleetdm/fleet/v4/pkg/optjson" + "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/mock" "github.com/fleetdm/fleet/v4/server/ptr" @@ -333,6 +335,28 @@ func TestSetupExperienceScriptRejectsUnknownCustomHostVital(t *testing.T) { require.False(t, ds.SetSetupExperienceScriptFuncInvoked) } +func TestSetupExperienceScriptCustomHostVitalInfraErrorPropagates(t *testing.T) { + ctx := test.UserContext(context.Background(), test.UserAdmin) + ds := new(mock.Store) + svc, _ := newTestServiceWithMock(t, ds) + + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + return &fleet.AppConfig{}, nil + } + ds.ValidateEmbeddedSecretsFunc = func(ctx context.Context, documents []string) error { return nil } + ds.ValidateReferencedCustomHostVitalsFunc = func(ctx context.Context, documents []string) error { + return ctxerr.Wrap(ctx, errors.New("connection refused"), "validating custom host vitals") + } + ds.SetSetupExperienceScriptFunc = func(ctx context.Context, script *fleet.Script) error { return nil } + + err := svc.SetSetupExperienceScript(ctx, nil, "potato.sh", bytes.NewReader([]byte("echo $FLEET_HOST_VITAL_99"))) + require.Error(t, err) + require.Contains(t, err.Error(), "connection refused") + var invalidArgErr *fleet.InvalidArgumentError + require.NotErrorAs(t, err, &invalidArgErr, "an infrastructure failure must not be reported as invalid input (422)") + require.False(t, ds.SetSetupExperienceScriptFuncInvoked) +} + // TestSetupExperienceNextStepPolicyGated covers the policy-gated (Windows/Linux) branch of SetupExperienceNextStep: the policy is // used only as a gate (pass -> skip, fail -> install via the normal ForSetupExperience path), the item is held running while // awaiting a fresh result, an out-of-scope gating policy falls back to installing, and the host policy clock is reset once when a diff --git a/ee/server/service/software_installers.go b/ee/server/service/software_installers.go index 6436b028aa..f7f6ecdece 100644 --- a/ee/server/service/software_installers.go +++ b/ee/server/service/software_installers.go @@ -118,6 +118,9 @@ func (svc *Service) UploadSoftwareInstaller(ctx context.Context, payload *fleet. } if err := svc.ds.ValidateReferencedCustomHostVitals(ctx, []string{payload.InstallScript, payload.PostInstallScript, payload.UninstallScript}); err != nil { + if !fleet.IsInvalidReferencedCustomHostVitalsError(err) { + return nil, ctxerr.Wrap(ctx, err, "validating referenced custom host vitals") + } // Redo per-script to report which script references the undefined custom host vital. var argErr *fleet.InvalidArgumentError argErr = svc.validateReferencedCustomHostVitalsOnScript(ctx, "install script", &payload.InstallScript, argErr) @@ -399,6 +402,9 @@ func (svc *Service) UpdateSoftwareInstaller(ctx context.Context, payload *fleet. return nil, ctxerr.Wrap(ctx, err, "transient server issue validating embedded secrets") } if err := svc.ds.ValidateReferencedCustomHostVitals(ctx, scripts); err != nil { + if !fleet.IsInvalidReferencedCustomHostVitalsError(err) { + return nil, ctxerr.Wrap(ctx, err, "validating referenced custom host vitals") + } var argErr *fleet.InvalidArgumentError argErr = svc.validateReferencedCustomHostVitalsOnScript(ctx, "install script", payload.InstallScript, argErr) argErr = svc.validateReferencedCustomHostVitalsOnScript(ctx, "post-install script", payload.PostInstallScript, argErr) @@ -2720,6 +2726,9 @@ func (svc *Service) BatchSetSoftwareInstallers( return "", ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("script", err.Error())) } if err := svc.ds.ValidateReferencedCustomHostVitals(ctx, allScripts); err != nil { + if !fleet.IsInvalidReferencedCustomHostVitalsError(err) { + return "", ctxerr.Wrap(ctx, err, "validating referenced custom host vitals") + } return "", ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("script", err.Error())) } } diff --git a/server/datastore/mysql/apple_device_names.go b/server/datastore/mysql/apple_device_names.go index b0166efab9..278c15ed5c 100644 --- a/server/datastore/mysql/apple_device_names.go +++ b/server/datastore/mysql/apple_device_names.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "errors" + "fmt" "strings" "time" @@ -351,6 +352,37 @@ func (ds *Datastore) resendDeviceNamesForSecretChange(ctx context.Context, chang return nil } +// resendDeviceNameForCustomHostVital re-queues the host's device-name +// enforcement row if its applicable (team or No-team) name template +// references $FLEET_HOST_VITAL_, so the cron re-resolves with the +// host's newly-set value. Mirrors resendMDMProfilesForCustomHostVital's +// content-match precision: a host whose template doesn't reference this vital +// gets no needless resend. Must run in the same transaction as the value +// write (see SetHostCustomHostVitalValue) so the reconciler never reads a +// stale value. +func resendDeviceNameForCustomHostVital(ctx context.Context, tx sqlx.ExtContext, hostID, vitalID uint) error { + var tmpl string + err := sqlx.GetContext(ctx, tx, &tmpl, ` + SELECT COALESCE( + CASE WHEN h.team_id IS NULL + THEN `+deviceNameNoTeamTemplateExpr+` + ELSE (SELECT t.config->>'$.mdm.name_template' FROM teams t WHERE t.id = h.team_id) + END, '') + FROM hosts h WHERE h.id = ?`, hostID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil + } + return ctxerr.Wrap(ctx, err, "get host name template for custom host vital resend") + } + + if tmpl == "" || !fleet.ContainsVar(tmpl, fmt.Sprintf("%s%d", fleet.CustomHostVitalPrefix, vitalID)) { + return nil + } + + return reconcileHostDeviceNamesForHostsDB(ctx, tx, []uint{hostID}) +} + func (ds *Datastore) ReconcileHostDeviceNamesForHosts(ctx context.Context, hostIDs []uint) error { if len(hostIDs) == 0 { return nil diff --git a/server/datastore/mysql/custom_host_vitals.go b/server/datastore/mysql/custom_host_vitals.go index 9778f56bf4..1636432c9f 100644 --- a/server/datastore/mysql/custom_host_vitals.go +++ b/server/datastore/mysql/custom_host_vitals.go @@ -152,7 +152,8 @@ type customHostVitalRefEntity struct { } // customHostVitalUsedBy scans script_contents, Apple configuration profiles, -// Apple declarations, and Windows configuration profiles for a +// Apple declarations, Windows configuration profiles, software installer and +// setup-experience scripts, and team/No-team host name templates for a // $FLEET_HOST_VITAL_ (or ${FLEET_HOST_VITAL_}) reference to the given // vital id. It returns a *fleet.CustomHostVitalUsedInfo describing the first // referencing entity found, or nil if unreferenced. Mirrors the scan structure @@ -217,6 +218,25 @@ func (ds *Datastore) customHostVitalUsedBy(ctx context.Context, tx sqlx.ExtConte JOIN script_contents sc ON sc.id = ses.script_content_id LEFT JOIN teams t ON t.id = ses.team_id;`, }, + // Host name templates aren't scripts/profiles, but the token can appear in + // a team's (or "No team"'s) name_template, same as DeleteSecretVariable + // scans for $FLEET_SECRET_* there. A team's name_template is a plain + // string that always serializes into the config JSON (as "" when unset), + // and the No-team template is an optjson that serializes to null when + // unset, so filter both on a non-empty resolved value rather than + // IS NOT NULL (avoids scanning a NULL contents column). + { + desc: "get host name template contents", + stmt: `SELECT 'host_name_template' AS entity, 'Host name' AS name, + t.name AS team_name, t.config->>'$.mdm.name_template' AS contents + FROM teams t + WHERE COALESCE(t.config->>'$.mdm.name_template', '') != '' + UNION ALL + SELECT 'host_name_template' AS entity, 'Host name' AS name, + 'Unassigned' AS team_name, json_value->>'$.mdm.name_template' AS contents + FROM app_config_json + WHERE COALESCE(json_value->>'$.mdm.name_template', '') != '';`, + }, } for _, scan := range scans { @@ -297,6 +317,13 @@ func (ds *Datastore) SetHostCustomHostVitalValue(ctx context.Context, hostID uin if err := resendMDMProfilesForCustomHostVital(ctx, tx, hostID, vitalID); err != nil { return ctxerr.Wrap(ctx, err, "resend mdm profiles for custom host vital value change") } + + // Re-queue the host's device-name enforcement row (if its name template + // references the vital), so the cron re-resolves the name with the new + // value. Same transaction as the value write, for the same reason as above. + if err := resendDeviceNameForCustomHostVital(ctx, tx, hostID, vitalID); err != nil { + return ctxerr.Wrap(ctx, err, "resend device name for custom host vital value change") + } return nil }) } diff --git a/server/datastore/mysql/custom_host_vitals_test.go b/server/datastore/mysql/custom_host_vitals_test.go index 11def1450a..f0259211e2 100644 --- a/server/datastore/mysql/custom_host_vitals_test.go +++ b/server/datastore/mysql/custom_host_vitals_test.go @@ -30,6 +30,7 @@ func TestCustomHostVitals(t *testing.T) { {"DeleteCustomHostVital", testDeleteCustomHostVital}, {"DeleteUsedCustomHostVital", testDeleteUsedCustomHostVital}, {"SetHostValueResendsReferencingProfiles", testSetHostCustomHostVitalValueResendsProfiles}, + {"SetHostValueResendsReferencingDeviceName", testSetHostCustomHostVitalValueResendsDeviceName}, {"ReconcileSnapshotMarksVitalDeclarations", testReconcileSnapshotMarksVitalDeclarations}, {"ValidateReferencedCustomHostVitalsRejectsMalformed", testValidateReferencedCustomHostVitalsRejectsMalformed}, } @@ -484,6 +485,58 @@ func testDeleteUsedCustomHostVital(t *testing.T, ds *Datastore) { require.NoError(t, ds.DeleteSetupExperienceScript(ctx, &foobarTeam.ID)) }) + t.Run("host name templates", func(t *testing.T) { + teamVitalID := createCustomHostVital(t, ds, "HT_TEAM") + _, err := ds.writer(ctx).ExecContext(ctx, + `UPDATE teams SET config = JSON_SET(config, '$.mdm.name_template', ?) WHERE id = ?`, + fmt.Sprintf("WS-$%s%d", fleet.CustomHostVitalPrefix, teamVitalID), foobarTeam.ID) + require.NoError(t, err) + + _, err = ds.DeleteCustomHostVital(ctx, teamVitalID) + require.Error(t, err) + var useErr *fleet.CustomHostVitalUsedError + require.ErrorAs(t, err, &useErr) + require.Equal(t, teamVitalID, useErr.CustomHostVitalID) + require.Equal(t, "HT_TEAM", useErr.CustomHostVitalName) + require.Equal(t, fleet.CustomHostVitalEntityHostNameTemplate, useErr.Entity.Type) + require.Equal(t, "Foobar", useErr.Entity.FleetName) + require.Contains(t, err.Error(), "host name template") + + // Clearing the team's template unblocks the delete. + _, err = ds.writer(ctx).ExecContext(ctx, + `UPDATE teams SET config = JSON_SET(config, '$.mdm.name_template', '') WHERE id = ?`, foobarTeam.ID) + require.NoError(t, err) + + name, err := ds.DeleteCustomHostVital(ctx, teamVitalID) + require.NoError(t, err) + require.Equal(t, "HT_TEAM", name) + + // The "No team" (global) template blocks the delete the same way. + noTeamVitalID := createCustomHostVital(t, ds, "HT_NOTEAM") + _, err = ds.writer(ctx).ExecContext(ctx, + `UPDATE app_config_json SET json_value = JSON_SET(json_value, '$.mdm.name_template', ?)`, + fmt.Sprintf("WS-${%s%d}", fleet.CustomHostVitalPrefix, noTeamVitalID)) + require.NoError(t, err) + + _, err = ds.DeleteCustomHostVital(ctx, noTeamVitalID) + require.Error(t, err) + useErr = nil + require.ErrorAs(t, err, &useErr) + require.Equal(t, noTeamVitalID, useErr.CustomHostVitalID) + require.Equal(t, "HT_NOTEAM", useErr.CustomHostVitalName) + require.Equal(t, fleet.CustomHostVitalEntityHostNameTemplate, useErr.Entity.Type) + require.Equal(t, "Unassigned", useErr.Entity.FleetName) + + // Clearing the global template as well unblocks the delete again. + _, err = ds.writer(ctx).ExecContext(ctx, + `UPDATE app_config_json SET json_value = JSON_SET(json_value, '$.mdm.name_template', CAST('null' AS JSON))`) + require.NoError(t, err) + + name, err = ds.DeleteCustomHostVital(ctx, noTeamVitalID) + require.NoError(t, err) + require.Equal(t, "HT_NOTEAM", name) + }) + t.Run("host vitals labels", func(t *testing.T) { // A host-vitals label references the vital by id in its criteria JSON, // not via the $FLEET_HOST_VITAL_ token. @@ -590,6 +643,66 @@ func testSetHostCustomHostVitalValueResendsProfiles(t *testing.T, ds *Datastore) require.Nil(t, declStatus, "declaration should be reset (NULL status) so the DDM reconciler re-delivers it") } +// testSetHostCustomHostVitalValueResendsDeviceName covers the device-name side +// of the resend-on-value-change hook: setting the value of a vital referenced +// by the host's (team) name template re-queues its device-name enforcement +// row, but setting a vital the template doesn't reference leaves the row +// untouched, mirroring the precision of the profile-resend case above. Also +// covers the same behavior for a No-team ("Unassigned") host, whose template +// lives in the global app config rather than a team's config. +func testSetHostCustomHostVitalValueResendsDeviceName(t *testing.T, ds *Datastore) { + ctx := t.Context() + + vitalID := createCustomHostVital(t, ds, "FUNCTION") + otherID := createCustomHostVital(t, ds, "OTHER") + + team, err := ds.NewTeam(ctx, &fleet.Team{Name: "device-name-vital-team"}) + require.NoError(t, err) + _, err = ds.writer(ctx).ExecContext(ctx, + `UPDATE teams SET config = JSON_SET(config, '$.mdm.name_template', ?) WHERE id = ?`, + fmt.Sprintf("WS-$%s%d", fleet.CustomHostVitalPrefix, vitalID), team.ID) + require.NoError(t, err) + + host := enrollAppleHostForDeviceName(t, ds, "vital-mac", "darwin", team.ID, false) + require.NoError(t, ds.BulkUpsertHostDeviceNameEnforcement(ctx, &team.ID)) + require.NoError(t, ds.SetHostDeviceNameStatus(ctx, host.UUID, fleet.MDMDeliveryVerifying, nil, "WS-Engineering", "")) + + // Setting a vital the template doesn't reference leaves the settled row alone. + require.NoError(t, ds.SetHostCustomHostVitalValue(ctx, host.ID, otherID, "ignored")) + require.Equal(t, fleet.MDMDeliveryVerifying, *getDeviceNameRow(t, ds, host.UUID).Status) + + // Setting the referenced vital's value re-queues the row (status reset to + // NULL) so the cron re-resolves the name with the new value. + require.NoError(t, ds.SetHostCustomHostVitalValue(ctx, host.ID, vitalID, "Engineering")) + require.Nil(t, getDeviceNameRow(t, ds, host.UUID).Status) + + // Same behavior for a No-team ("Unassigned") host: its template lives in + // app_config_json instead of a team's config. + _, err = ds.writer(ctx).ExecContext(ctx, + `UPDATE app_config_json SET json_value = JSON_SET(json_value, '$.mdm.name_template', ?)`, + fmt.Sprintf("NT-$%s%d", fleet.CustomHostVitalPrefix, vitalID)) + require.NoError(t, err) + defer func() { + _, err := ds.writer(ctx).ExecContext(ctx, + `UPDATE app_config_json SET json_value = JSON_SET(json_value, '$.mdm.name_template', CAST('null' AS JSON))`) + require.NoError(t, err) + }() + + noTeamHost := enrollAppleHostForDeviceName(t, ds, "vital-mac-noteam", "darwin", team.ID, false) + _, err = ds.writer(ctx).ExecContext(ctx, `UPDATE hosts SET team_id = NULL WHERE id = ?`, noTeamHost.ID) + require.NoError(t, err) + require.NoError(t, ds.BulkUpsertHostDeviceNameEnforcement(ctx, nil)) + require.NoError(t, ds.SetHostDeviceNameStatus(ctx, noTeamHost.UUID, fleet.MDMDeliveryVerifying, nil, "NT-Engineering", "")) + + // Setting a vital the No-team template doesn't reference leaves the row alone. + require.NoError(t, ds.SetHostCustomHostVitalValue(ctx, noTeamHost.ID, otherID, "ignored")) + require.Equal(t, fleet.MDMDeliveryVerifying, *getDeviceNameRow(t, ds, noTeamHost.UUID).Status) + + // Setting the referenced vital's value re-queues the No-team row too. + require.NoError(t, ds.SetHostCustomHostVitalValue(ctx, noTeamHost.ID, vitalID, "Engineering")) + require.Nil(t, getDeviceNameRow(t, ds, noTeamHost.UUID).Status) +} + // The DDM reconcile snapshot must flag declarations that reference a custom host // vital as HasFleetVariables, so the reconciler stamps variables_updated_at on // the host declaration row — the signal handleDeclarationItems relies on to load diff --git a/server/fleet/custom_host_vitals.go b/server/fleet/custom_host_vitals.go index bfbd8e8b66..15dbea1320 100644 --- a/server/fleet/custom_host_vitals.go +++ b/server/fleet/custom_host_vitals.go @@ -1,6 +1,7 @@ package fleet import ( + "errors" "fmt" "strconv" "strings" @@ -51,7 +52,7 @@ func (e MissingCustomHostVitalsError) Error() string { if len(tokens) > 1 { plural = "s" } - return fmt.Sprintf("Couldn't add. Custom host vital%s %s is not defined", plural, strings.Join(tokens, ", ")) + return fmt.Sprintf("Custom host vital%s %s is not defined", plural, strings.Join(tokens, ", ")) } // InvalidCustomHostVitalRefError is returned on upload when a document contains a @@ -72,7 +73,7 @@ func (e InvalidCustomHostVitalRefError) Error() string { plural = "s" } return fmt.Sprintf( - "Couldn't add. Invalid custom host vital reference%s %s; the value after $%s must be a custom host vital ID", + "Invalid custom host vital reference%s %s; the value after $%s must be a custom host vital ID", plural, strings.Join(tokens, ", "), CustomHostVitalPrefix, ) } @@ -105,6 +106,15 @@ func (e MissingCustomHostVitalValueError) Error() string { ) } +// IsInvalidReferencedCustomHostVitalsError reports whether err is a user-input validation failure: +// - an unknown vital ID (MissingCustomHostVitalsError) +// - or a malformed $FLEET_HOST_VITAL_ reference (InvalidCustomHostVitalRefError) +func IsInvalidReferencedCustomHostVitalsError(err error) bool { + var missing *MissingCustomHostVitalsError + var invalid *InvalidCustomHostVitalRefError + return errors.As(err, &missing) || errors.As(err, &invalid) +} + // CustomHostVitalEntity identifies the kind of entity that can reference a custom host vital. type CustomHostVitalEntity string @@ -116,6 +126,7 @@ const ( CustomHostVitalEntitySoftwareInstaller CustomHostVitalEntity = "software_installer" CustomHostVitalEntitySetupExperienceScript CustomHostVitalEntity = "setup_experience_script" CustomHostVitalEntityLabel CustomHostVitalEntity = "label" + CustomHostVitalEntityHostNameTemplate CustomHostVitalEntity = "host_name_template" ) // Describes an entity that references a custom host vital. @@ -136,6 +147,14 @@ type CustomHostVitalUsedInfo struct { // Message returns the human-readable "X is used by Y" explanation. func (i CustomHostVitalUsedInfo) Message() string { + if i.Entity.Type == CustomHostVitalEntityHostNameTemplate { + // there's no separate entity name to report, just the fleet whose template references the vital. + return fmt.Sprintf( + "Custom host vital %q (used as $%s%d) is used by the host name template in the %q fleet. Please edit or clear the host name template and try again.", + i.CustomHostVitalName, CustomHostVitalPrefix, i.CustomHostVitalID, i.Entity.FleetName, + ) + } + noun, action := "configuration profile", "Please delete the configuration profile and try again." switch i.Entity.Type { case CustomHostVitalEntityScript: diff --git a/server/fleet/custom_host_vitals_test.go b/server/fleet/custom_host_vitals_test.go index cddc4f16af..af52291448 100644 --- a/server/fleet/custom_host_vitals_test.go +++ b/server/fleet/custom_host_vitals_test.go @@ -1,8 +1,10 @@ package fleet import ( + "errors" "testing" + "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" "github.com/stretchr/testify/require" ) @@ -45,6 +47,22 @@ func TestMissingCustomHostVitalsError(t *testing.T) { require.Contains(t, multi.Error(), `"$FLEET_HOST_VITAL_9"`) } +func TestCustomHostVitalUsedInfoMessageHostNameTemplate(t *testing.T) { + info := CustomHostVitalUsedInfo{ + CustomHostVitalID: 5, + CustomHostVitalName: "FUNCTION", + Entity: EntityUsingCustomHostVital{ + Type: CustomHostVitalEntityHostNameTemplate, + FleetName: "Workstations", + }, + } + want := `Custom host vital "FUNCTION" (used as $FLEET_HOST_VITAL_5) is used by the host name template in the "Workstations" fleet. Please edit or clear the host name template and try again.` + require.Equal(t, want, info.Message()) + + err := (&CustomHostVitalUsedError{CustomHostVitalUsedInfo: info}).Error() + require.Equal(t, want, err) +} + func TestMissingCustomHostVitalValueError(t *testing.T) { single := MissingCustomHostVitalValueError{MissingIDs: []uint{5}, MissingNames: []string{"Asset tag"}} require.Equal( @@ -60,3 +78,26 @@ func TestMissingCustomHostVitalValueError(t *testing.T) { require.Contains(t, multi.Error(), "Asset tag ($FLEET_HOST_VITAL_5)") require.Contains(t, multi.Error(), "Department ($FLEET_HOST_VITAL_9)") } + +// TestIsInvalidReferencedCustomHostVitalsError covers the classification +// callers of ValidateReferencedCustomHostVitals rely on to decide whether to +// report a 422 (unknown ID / malformed reference) or propagate the error as-is +// (any other error, e.g. a wrapped DB failure, which must surface as a 500). +func TestIsInvalidReferencedCustomHostVitalsError(t *testing.T) { + cases := []struct { + name string + err error + want bool + }{ + {"unknown vital id", &MissingCustomHostVitalsError{MissingIDs: []uint{5}}, true}, + {"malformed reference", &InvalidCustomHostVitalRefError{Refs: []string{"FLEET_HOST_VITAL_asset_tag"}}, true}, + {"plain infra error", errors.New("connection refused"), false}, + {"wrapped infra error", ctxerr.Wrap(t.Context(), errors.New("connection refused"), "validating custom host vitals"), false}, + {"nil", nil, false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + require.Equal(t, c.want, IsInvalidReferencedCustomHostVitalsError(c.err)) + }) + } +} diff --git a/server/fleet/name_template.go b/server/fleet/name_template.go index b5aaedaa0c..16c48fe89e 100644 --- a/server/fleet/name_template.go +++ b/server/fleet/name_template.go @@ -73,6 +73,12 @@ func varAlternationRegexp(vars []FleetVarName) *regexp.Regexp { // so this is used to strip secret tokens out of a template when computing its fixed-text byte floor. var nameTemplateSecretRegexp = regexp.MustCompile(`\$` + ServerSecretPrefix + `\w+|\$\{` + ServerSecretPrefix + `\w+\}`) +// nameTemplateVitalRegexp matches a $FLEET_HOST_VITAL_ / ${FLEET_HOST_VITAL_} +// custom host vital token. Vital values, like secrets, are only known at +// resolve time, so this is used to strip vital tokens out of a template when +// computing its fixed-text byte floor. +var nameTemplateVitalRegexp = regexp.MustCompile(`\$` + CustomHostVitalPrefix + `\w+|\$\{` + CustomHostVitalPrefix + `\w+\}`) + // ValidateHostNameTemplate validates a host name template and returns the // normalized (trimmed) template that callers should persist. func ValidateHostNameTemplate(tmpl string) (string, error) { @@ -112,6 +118,7 @@ func ValidateHostNameTemplate(tmpl string) (string, error) { // still caught by the cron when it resolves against a host's actual values. literal := nameTemplateVarRegexp.ReplaceAllString(tmpl, "") literal = nameTemplateSecretRegexp.ReplaceAllString(literal, "") + literal = nameTemplateVitalRegexp.ReplaceAllString(literal, "") if len(literal) > MaxResolvedHostNameBytes { return "", NewInvalidArgumentError("name_template", fmt.Sprintf("Host name template's fixed text can't be longer than %d bytes (the device name limit).", MaxResolvedHostNameBytes)) @@ -124,7 +131,9 @@ func ValidateHostNameTemplate(tmpl string) (string, error) { // syntactically (see ValidateHostNameTemplate) and additionally verifies that // every custom (secret, $FLEET_SECRET_*) variable it references is defined in // the datastore, mirroring how scripts and profiles validate embedded secrets at -// save time. It returns the normalized template to persist. +// save time, and that every custom host vital ($FLEET_HOST_VITAL_) it +// references is a known vital ID. It returns the normalized template to +// persist. func ValidateHostNameTemplateWithSecrets(ctx context.Context, ds Datastore, tmpl string) (string, error) { validated, err := ValidateHostNameTemplate(tmpl) if err != nil { @@ -143,6 +152,15 @@ func ValidateHostNameTemplateWithSecrets(ctx context.Context, ds Datastore, tmpl return "", err } } + // Vital IDs are dynamic (not a fixed allow-list), so an unknown or malformed + // $FLEET_HOST_VITAL_ reference is only caught here, same as scripts and + // profiles validate their own embedded vital references. + if err := ds.ValidateReferencedCustomHostVitals(ctx, []string{validated}); err != nil { + if IsInvalidReferencedCustomHostVitalsError(err) { + return "", NewInvalidArgumentError("name_template", err.Error()) + } + return "", err + } return validated, nil } diff --git a/server/fleet/name_template_test.go b/server/fleet/name_template_test.go index 9d6a9860b7..03e5b99d55 100644 --- a/server/fleet/name_template_test.go +++ b/server/fleet/name_template_test.go @@ -75,6 +75,30 @@ func TestValidateHostNameTemplate(t *testing.T) { tmpl: "WS-${FLEET_SECRET_" + strings.Repeat("A", 80) + "}", wantNorm: "WS-${FLEET_SECRET_" + strings.Repeat("A", 80) + "}", }, + // Custom host vital references are allowed syntactically here too; their + // existence is checked separately (needs the datastore), same as secrets. + {name: "vital var", tmpl: "$FLEET_HOST_VITAL_5", wantNorm: "$FLEET_HOST_VITAL_5"}, + {name: "vital var braced", tmpl: "${FLEET_HOST_VITAL_5}", wantNorm: "${FLEET_HOST_VITAL_5}"}, + { + name: "built-in, secret, and vital vars mixed", + tmpl: "WS-$FLEET_VAR_HOST_HARDWARE_SERIAL-$FLEET_SECRET_SITE-$FLEET_HOST_VITAL_5", + wantNorm: "WS-$FLEET_VAR_HOST_HARDWARE_SERIAL-$FLEET_SECRET_SITE-$FLEET_HOST_VITAL_5", + }, + { + // A long vital token doesn't count toward the fixed-text byte floor + // either: the substituted value's length isn't known until resolve time. + name: "long vital token doesn't hit byte floor", + tmpl: "WS-${FLEET_HOST_VITAL_" + strings.Repeat("9", 80) + "}", + wantNorm: "WS-${FLEET_HOST_VITAL_" + strings.Repeat("9", 80) + "}", + }, + { + // A malformed vital reference (non-numeric suffix) is allowed + // syntactically here; ValidateHostNameTemplateWithSecrets rejects it once + // the datastore is available to distinguish malformed from unknown IDs. + name: "malformed vital ref allowed at the syntax layer", + tmpl: "$FLEET_HOST_VITAL_asset_tag", + wantNorm: "$FLEET_HOST_VITAL_asset_tag", + }, {name: "tab control char", tmpl: "bad\tname", wantErr: "control characters"}, {name: "rtl override format char", tmpl: "bad\u202ename", wantErr: "control characters"}, {name: "zero-width joiner format char", tmpl: "bad\u200dname", wantErr: "control characters"}, diff --git a/server/service/apple_device_names.go b/server/service/apple_device_names.go index 61db892fda..bb318d9c54 100644 --- a/server/service/apple_device_names.go +++ b/server/service/apple_device_names.go @@ -141,6 +141,25 @@ func ReconcileHostDeviceNames( expandedTmpl = exp.value } + // Expand any custom host vital ($FLEET_HOST_VITAL_) references with this + // host's stored value. Unlike secrets, vital values are per-host, so this + // can't be memoized across hosts sharing a template. + if len(fleet.ContainsCustomHostVitalIDs(expandedTmpl)) > 0 { + withVitals, vitalErr := ds.ExpandCustomHostVitals(ctx, host.HostID, expandedTmpl) + if vitalErr != nil { + if _, ok := errors.AsType[*fleet.MissingCustomHostVitalValueError](vitalErr); !ok { + return ctxerr.Wrap(ctx, vitalErr, "expand host name template custom host vitals") + } + // A referenced vital exists but has no value set for this host. + if err := ds.SetHostDeviceNameStatus(ctx, host.HostUUID, fleet.MDMDeliveryFailed, nil, "", + vitalErr.Error()); err != nil { + logger.ErrorContext(ctx, "mark device name row failed for missing custom host vital value", "host_uuid", host.HostUUID, "err", err) + } + continue + } + expandedTmpl = withVitals + } + resolved := fleet.ResolveHostNameTemplate(expandedTmpl, &fleet.Host{ UUID: host.HostUUID, HardwareSerial: host.HardwareSerial, diff --git a/server/service/apple_device_names_test.go b/server/service/apple_device_names_test.go index 946a71e2c8..1e26ab1c2c 100644 --- a/server/service/apple_device_names_test.go +++ b/server/service/apple_device_names_test.go @@ -2,8 +2,10 @@ package service import ( "context" + "log/slog" "testing" + "github.com/fleetdm/fleet/v4/pkg/optjson" "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/mock" "github.com/stretchr/testify/require" @@ -70,3 +72,69 @@ func TestResolveHostNameIDPVars(t *testing.T) { require.Contains(t, detail, "no IdP username for this host") }) } + +// TestReconcileHostDeviceNamesExpandsCustomHostVitals covers the per-host +// $FLEET_HOST_VITAL_ expansion step: a host with no value set for a +// referenced vital fails the row (mirroring the missing-secret path), and a +// host with a value gets it substituted before resolution. Both scenarios +// here settle on a name matching the host's current ComputerName, so the +// cron never reaches the MDM commander (nil is safe to pass). +func TestReconcileHostDeviceNamesExpandsCustomHostVitals(t *testing.T) { + ds := new(mock.Store) + logger := slog.New(slog.DiscardHandler) + + const tmpl = "WS-$FLEET_HOST_VITAL_5" + ds.AppConfigFunc = func(_ context.Context) (*fleet.AppConfig, error) { + return &fleet.AppConfig{ + MDM: fleet.MDM{ + EnabledAndConfigured: true, + HostNameTemplate: optjson.SetString(tmpl), + }, + }, nil + } + ds.DeactivateHostDeviceNameCommandsFunc = func(_ context.Context, _ []string) error { return nil } + + type recordedStatus struct { + status fleet.MDMDeliveryStatus + detail string + } + statuses := map[string]recordedStatus{} + ds.SetHostDeviceNameStatusFunc = func(_ context.Context, hostUUID string, status fleet.MDMDeliveryStatus, _ *string, _, detail string) error { + statuses[hostUUID] = recordedStatus{status, detail} + return nil + } + + t.Run("no value set for the host fails the row", func(t *testing.T) { + ds.ListHostsPendingDeviceNameCommandFunc = func(_ context.Context, _ int) ([]fleet.HostDeviceNamePending, error) { + return []fleet.HostDeviceNamePending{ + {HostID: 1, HostUUID: "host-1", HardwareSerial: "SERIAL1", Platform: "darwin", ComputerName: "old-name"}, + }, nil + } + ds.ExpandCustomHostVitalsFunc = func(_ context.Context, hostID uint, document string) (string, error) { + require.Equal(t, uint(1), hostID) + require.Equal(t, tmpl, document) + return "", &fleet.MissingCustomHostVitalValueError{MissingIDs: []uint{5}} + } + + require.NoError(t, ReconcileHostDeviceNames(t.Context(), ds, nil, logger)) + require.Equal(t, fleet.MDMDeliveryFailed, statuses["host-1"].status) + require.Contains(t, statuses["host-1"].detail, "no value set for this host") + }) + + t.Run("host's value is substituted and a matching name verifies without a command", func(t *testing.T) { + ds.ListHostsPendingDeviceNameCommandFunc = func(_ context.Context, _ int) ([]fleet.HostDeviceNamePending, error) { + return []fleet.HostDeviceNamePending{ + {HostID: 2, HostUUID: "host-2", HardwareSerial: "SERIAL2", Platform: "darwin", ComputerName: "WS-engineering"}, + }, nil + } + ds.ExpandCustomHostVitalsFunc = func(_ context.Context, hostID uint, document string) (string, error) { + require.Equal(t, uint(2), hostID) + require.Equal(t, tmpl, document) + return "WS-engineering", nil + } + + require.NoError(t, ReconcileHostDeviceNames(t.Context(), ds, nil, logger)) + require.Equal(t, fleet.MDMDeliveryVerified, statuses["host-2"].status) + require.Empty(t, statuses["host-2"].detail) + }) +} diff --git a/server/service/apple_mdm.go b/server/service/apple_mdm.go index a7ff48b7b7..55fa55bd18 100644 --- a/server/service/apple_mdm.go +++ b/server/service/apple_mdm.go @@ -471,6 +471,9 @@ func (svc *Service) parseAndValidateAppleConfigProfile(ctx context.Context, team } if err := svc.ds.ValidateReferencedCustomHostVitals(ctx, []string{string(data)}); err != nil { + if !fleet.IsInvalidReferencedCustomHostVitalsError(err) { + return nil, nil, "", ctxerr.Wrap(ctx, err, "validating referenced custom host vitals") + } return nil, nil, "", ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("profile", err.Error())) } @@ -1056,6 +1059,9 @@ func (svc *Service) parseAndValidateAppleDeclaration(ctx context.Context, teamID // Validate custom host vital references (top-level $FLEET_HOST_VITAL_). if err := svc.ds.ValidateReferencedCustomHostVitals(ctx, []string{string(data)}); err != nil { + if !fleet.IsInvalidReferencedCustomHostVitalsError(err) { + return nil, nil, "", ctxerr.Wrap(ctx, err, "validating referenced custom host vitals") + } return nil, nil, "", fleet.NewInvalidArgumentError("profile", err.Error()) } @@ -3306,6 +3312,9 @@ func (svc *Service) BatchSetMDMAppleProfiles(ctx context.Context, tmID *uint, tm "missing fleet secrets") } if err := svc.ds.ValidateReferencedCustomHostVitals(ctx, []string{string(prof)}); err != nil { + if !fleet.IsInvalidReferencedCustomHostVitalsError(err) { + return ctxerr.Wrap(ctx, err, "validating referenced custom host vitals") + } return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError(fmt.Sprintf("profiles[%d]", i), err.Error())) } mdmProf, err := fleet.NewMDMAppleConfigProfile([]byte(expanded), tmID) diff --git a/server/service/apple_mdm_test.go b/server/service/apple_mdm_test.go index df7f5637aa..7e4e705f00 100644 --- a/server/service/apple_mdm_test.go +++ b/server/service/apple_mdm_test.go @@ -31,6 +31,7 @@ import ( activity_api "github.com/fleetdm/fleet/v4/server/activity/api" "github.com/fleetdm/fleet/v4/server/authz" "github.com/fleetdm/fleet/v4/server/config" + "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" "github.com/fleetdm/fleet/v4/server/contexts/license" "github.com/fleetdm/fleet/v4/server/contexts/viewer" "github.com/fleetdm/fleet/v4/server/datastore/mysql/mysqltest" @@ -848,6 +849,65 @@ func TestNewMDMAppleConfigProfile(t *testing.T) { require.NoError(t, err) } +func TestNewMDMAppleConfigProfileCustomHostVitalErrors(t *testing.T) { + svc, ctx, ds, _ := setupAppleMDMService(t, &fleet.LicenseInfo{Tier: fleet.TierPremium}) + ctx = viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{GlobalRole: new(fleet.RoleAdmin)}}) + ds.GetGroupedCertificateAuthoritiesFunc = func(ctx context.Context, includeSecrets bool) (*fleet.GroupedCertificateAuthorities, error) { + return &fleet.GroupedCertificateAuthorities{}, nil + } + mcBytes := mcBytesForTest("Foo", "test.identifier.$FLEET_HOST_VITAL_5", "UUID") + + t.Run("unknown vital id is rejected", func(t *testing.T) { + ds.ValidateReferencedCustomHostVitalsFunc = func(ctx context.Context, documents []string) error { + return &fleet.MissingCustomHostVitalsError{MissingIDs: []uint{5}} + } + _, err := svc.NewMDMAppleConfigProfile(ctx, 0, mcBytes, nil, fleet.LabelsIncludeAll, nil) + require.Error(t, err) + require.Contains(t, err.Error(), "FLEET_HOST_VITAL_5") + var invalidArgErr *fleet.InvalidArgumentError + require.ErrorAs(t, err, &invalidArgErr) + }) + + t.Run("infrastructure failure propagates instead of being reported as invalid input", func(t *testing.T) { + ds.ValidateReferencedCustomHostVitalsFunc = func(ctx context.Context, documents []string) error { + return ctxerr.Wrap(ctx, errors.New("connection refused"), "validating custom host vitals") + } + _, err := svc.NewMDMAppleConfigProfile(ctx, 0, mcBytes, nil, fleet.LabelsIncludeAll, nil) + require.Error(t, err) + require.Contains(t, err.Error(), "connection refused") + var invalidArgErr *fleet.InvalidArgumentError + require.NotErrorAs(t, err, &invalidArgErr, "an infrastructure failure must not be reported as invalid input (422)") + }) +} + +func TestNewMDMAppleDeclarationCustomHostVitalErrors(t *testing.T) { + svc, ctx, ds, _ := setupAppleMDMService(t, &fleet.LicenseInfo{Tier: fleet.TierPremium}) + ctx = viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{GlobalRole: new(fleet.RoleAdmin)}}) + b := declBytesForTest("D1", "$FLEET_HOST_VITAL_5") + + t.Run("unknown vital id is rejected", func(t *testing.T) { + ds.ValidateReferencedCustomHostVitalsFunc = func(ctx context.Context, documents []string) error { + return &fleet.MissingCustomHostVitalsError{MissingIDs: []uint{5}} + } + _, err := svc.NewMDMAppleDeclaration(ctx, 0, b, nil, "name", fleet.LabelsIncludeAll, nil) + require.Error(t, err) + require.Contains(t, err.Error(), "FLEET_HOST_VITAL_5") + var invalidArgErr *fleet.InvalidArgumentError + require.ErrorAs(t, err, &invalidArgErr) + }) + + t.Run("infrastructure failure propagates instead of being reported as invalid input", func(t *testing.T) { + ds.ValidateReferencedCustomHostVitalsFunc = func(ctx context.Context, documents []string) error { + return ctxerr.Wrap(ctx, errors.New("connection refused"), "validating custom host vitals") + } + _, err := svc.NewMDMAppleDeclaration(ctx, 0, b, nil, "name", fleet.LabelsIncludeAll, nil) + require.Error(t, err) + require.Contains(t, err.Error(), "connection refused") + var invalidArgErr *fleet.InvalidArgumentError + require.NotErrorAs(t, err, &invalidArgErr, "an infrastructure failure must not be reported as invalid input (422)") + }) +} + func mcBytesForTest(name, identifier, uuid string) []byte { return []byte(fmt.Sprintf(` diff --git a/server/service/mdm.go b/server/service/mdm.go index caadeda5ba..fe71bda966 100644 --- a/server/service/mdm.go +++ b/server/service/mdm.go @@ -2524,6 +2524,9 @@ func (svc *Service) BatchSetMDMProfiles( customHostVitalDocs = append(customHostVitalDocs, string(p.Contents)) } if err := svc.ds.ValidateReferencedCustomHostVitals(ctx, customHostVitalDocs); err != nil { + if !fleet.IsInvalidReferencedCustomHostVitalsError(err) { + return ctxerr.Wrap(ctx, err, "validating referenced custom host vitals") + } return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("profiles", err.Error())) } diff --git a/server/service/mdm_test.go b/server/service/mdm_test.go index 85692f0d34..bee66aebcd 100644 --- a/server/service/mdm_test.go +++ b/server/service/mdm_test.go @@ -24,6 +24,7 @@ import ( "github.com/fleetdm/fleet/v4/pkg/optjson" activity_api "github.com/fleetdm/fleet/v4/server/activity/api" + "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" "github.com/fleetdm/fleet/v4/server/datastore/mysql/mysqltest" apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple" "github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig" @@ -3184,6 +3185,84 @@ func TestResendHostNameTemplate(t *testing.T) { }) } +func TestUpdateMDMHostNameTemplateValidatesCustomHostVitals(t *testing.T) { + ds := new(mock.Store) + license := &fleet.LicenseInfo{Tier: fleet.TierPremium} + svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: license, SkipCreateTestUsers: true}) + adminCtx := viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{GlobalRole: new(fleet.RoleAdmin)}}) + + // Simulate the real datastore: only vital id 1 exists. + ds.ValidateReferencedCustomHostVitalsFunc = func(ctx context.Context, documents []string) error { + var malformed []string + var missing []uint + for _, d := range documents { + malformed = append(malformed, fleet.ContainsMalformedCustomHostVitalRefs(d)...) + for _, id := range fleet.ContainsCustomHostVitalIDs(d) { + if id != 1 { + missing = append(missing, id) + } + } + } + if len(malformed) > 0 { + return &fleet.InvalidCustomHostVitalRefError{Refs: malformed} + } + if len(missing) > 0 { + return &fleet.MissingCustomHostVitalsError{MissingIDs: missing} + } + return nil + } + + t.Run("unknown vital id is rejected", func(t *testing.T) { + err := svc.UpdateMDMHostNameTemplate(adminCtx, nil, "WS-$FLEET_HOST_VITAL_999") + require.Error(t, err) + require.Contains(t, err.Error(), "FLEET_HOST_VITAL_999") + require.Contains(t, err.Error(), "is not defined") + require.True(t, ds.ValidateReferencedCustomHostVitalsFuncInvoked) + }) + + t.Run("malformed vital ref is rejected", func(t *testing.T) { + ds.ValidateReferencedCustomHostVitalsFuncInvoked = false + err := svc.UpdateMDMHostNameTemplate(adminCtx, nil, "WS-$FLEET_HOST_VITAL_asset_tag") + require.Error(t, err) + require.Contains(t, err.Error(), "must be a custom host vital ID") + require.True(t, ds.ValidateReferencedCustomHostVitalsFuncInvoked) + }) + + t.Run("known vital id passes validation and is persisted", func(t *testing.T) { + ds.ValidateReferencedCustomHostVitalsFuncInvoked = false + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + return &fleet.AppConfig{}, nil + } + var savedTemplate string + ds.SaveAppConfigFunc = func(ctx context.Context, conf *fleet.AppConfig) error { + savedTemplate = conf.MDM.HostNameTemplate.Value + return nil + } + svc.SetEnterpriseOverrides(fleet.EnterpriseOverrides{ + ApplyHostNameTemplateChange: func(ctx context.Context, team *fleet.Team, nameTemplate string) error { return nil }, + }) + + err := svc.UpdateMDMHostNameTemplate(adminCtx, nil, "WS-$FLEET_HOST_VITAL_1") + require.NoError(t, err) + require.True(t, ds.ValidateReferencedCustomHostVitalsFuncInvoked) + require.Equal(t, "WS-$FLEET_HOST_VITAL_1", savedTemplate) + }) + + t.Run("infrastructure failure propagates instead of being reported as invalid input", func(t *testing.T) { + ds.ValidateReferencedCustomHostVitalsFuncInvoked = false + ds.ValidateReferencedCustomHostVitalsFunc = func(ctx context.Context, documents []string) error { + return ctxerr.Wrap(ctx, errors.New("connection refused"), "validating custom host vitals") + } + + err := svc.UpdateMDMHostNameTemplate(adminCtx, nil, "WS-$FLEET_HOST_VITAL_1") + require.Error(t, err) + require.True(t, ds.ValidateReferencedCustomHostVitalsFuncInvoked) + require.Contains(t, err.Error(), "connection refused") + var invalidArgErr *fleet.InvalidArgumentError + require.NotErrorAs(t, err, &invalidArgErr, "an infrastructure failure must not be reported as invalid input (422)") + }) +} + func TestBatchSetMDMProfilesLabels(t *testing.T) { ds := new(mock.Store) // while the config profiles are not premium-only, teams are and we want to test with teams.