Skip to content

Commit adb1cc7

Browse files
Add GitOps name_template under controls
Relates to #48625 Lets GitOps own the host name template via controls.name_template in team YAML. Parsed into GitOpsControls (with type validation and Set() presence check), applied declaratively through the same team-spec path as the endpoint (absent key clears the template), and rejected for org-level and no-team controls. generate-gitops emits controls field-by-field and does not include name_template, so it does not round-trip (matches the story's 'No changes' for generation).
1 parent 98b827e commit adb1cc7

7 files changed

Lines changed: 327 additions & 1 deletion

File tree

cmd/fleetctl/fleetctl/generate_gitops.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1406,6 +1406,10 @@ func (cmd *GenerateGitopsCommand) generateControls(teamId *uint, teamName string
14061406
result[jsonFieldName(mdmT, "EnableDiskEncryption")] = teamMdm.EnableDiskEncryption
14071407
result[jsonFieldName(mdmT, "EnableRecoveryLockPassword")] = teamMdm.EnableRecoveryLockPassword
14081408
result[jsonFieldName(mdmT, "RequireBitLockerPIN")] = teamMdm.RequireBitLockerPIN
1409+
// name_template is fleets-only, so only emit it for real fleets.
1410+
if teamId != nil && *teamId != 0 {
1411+
result[jsonFieldName(mdmT, "HostNameTemplate")] = teamMdm.HostNameTemplate
1412+
}
14091413
result[jsonFieldName(mdmT, "MacOSUpdates")] = teamMdm.MacOSUpdates
14101414
result[jsonFieldName(mdmT, "IOSUpdates")] = teamMdm.IOSUpdates
14111415
result[jsonFieldName(mdmT, "IPadOSUpdates")] = teamMdm.IPadOSUpdates

cmd/fleetctl/fleetctl/gitops_test.go

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7218,6 +7218,259 @@ software:
72187218
})
72197219
}
72207220

7221+
func TestGitOpsHostNameTemplate(t *testing.T) {
7222+
// Cannot t.Parallel() — RunServerWithMockedDS sets environment variables.
7223+
license := &fleet.LicenseInfo{Tier: fleet.TierPremium, Expiration: time.Now().Add(24 * time.Hour)}
7224+
opts := &service.TestServerOpts{
7225+
License: license,
7226+
KeyValueStore: testing_utils.NewMemKeyValueStore(),
7227+
}
7228+
_, ds := testing_utils.RunServerWithMockedDS(t, opts)
7229+
setupEmptyGitOpsMocks(ds)
7230+
7231+
// Mock Apple GDMF API (required for validating OS update minimum version settings)
7232+
mdmtest.StartNewAppleGDMFTestServer(t)
7233+
7234+
const (
7235+
localTeamName = "Team1"
7236+
fleetServerURL = "https://fleet.example.com"
7237+
orgName = "GitOps Test"
7238+
template = "iPad $FLEET_VAR_HOST_HARDWARE_SERIAL"
7239+
)
7240+
7241+
var savedTeam *fleet.Team
7242+
ds.TeamByNameFunc = func(ctx context.Context, name string) (*fleet.Team, error) {
7243+
if name == localTeamName && savedTeam != nil {
7244+
return savedTeam, nil
7245+
}
7246+
return nil, &notFoundError{}
7247+
}
7248+
ds.SaveTeamFunc = func(ctx context.Context, tm *fleet.Team) (*fleet.Team, error) {
7249+
savedTeam = tm
7250+
return tm, nil
7251+
}
7252+
ds.NewTeamFunc = func(ctx context.Context, newTeam *fleet.Team) (*fleet.Team, error) {
7253+
newTeam.ID = 1
7254+
savedTeam = newTeam
7255+
return newTeam, nil
7256+
}
7257+
ds.TeamByFilenameFunc = func(ctx context.Context, filename string) (*fleet.Team, error) {
7258+
if savedTeam != nil && savedTeam.Filename != nil && *savedTeam.Filename == filename {
7259+
return savedTeam, nil
7260+
}
7261+
return nil, &notFoundError{}
7262+
}
7263+
defaultTeamConfig := &fleet.TeamConfig{}
7264+
ds.TeamLiteFunc = func(ctx context.Context, tid uint) (*fleet.TeamLite, error) {
7265+
if tid == 0 {
7266+
return &fleet.TeamLite{ID: 0, Name: fleet.ReservedNameNoTeam, Config: defaultTeamConfig.ToLite()}, nil
7267+
}
7268+
if tid == 1 && savedTeam != nil {
7269+
return savedTeam.ToTeamLite(), nil
7270+
}
7271+
return nil, nil
7272+
}
7273+
ds.DefaultTeamConfigFunc = func(ctx context.Context) (*fleet.TeamConfig, error) { return defaultTeamConfig, nil }
7274+
ds.SaveDefaultTeamConfigFunc = func(ctx context.Context, config *fleet.TeamConfig) error {
7275+
defaultTeamConfig = config
7276+
return nil
7277+
}
7278+
ds.LabelIDsByNameFunc = func(ctx context.Context, names []string, filter fleet.TeamFilter) (map[string]uint, error) {
7279+
return map[string]uint{
7280+
fleet.BuiltinLabelMacOS14Plus: 1,
7281+
fleet.BuiltinLabelIOS: 2,
7282+
fleet.BuiltinLabelIPadOS: 3,
7283+
}, nil
7284+
}
7285+
ds.HasAppleUpdateConfigProfileConfiguredFunc = func(ctx context.Context, teamID uint) (bool, error) { return false, nil }
7286+
ds.BulkUpsertHostDeviceNameEnforcementFunc = func(ctx context.Context, teamID uint) error { return nil }
7287+
ds.DeleteHostDeviceNameEnforcementForTeamFunc = func(ctx context.Context, teamID uint) error { return nil }
7288+
7289+
var emitted []activity_api.ActivityDetails
7290+
var emittedMu sync.Mutex
7291+
opts.ActivityMock.NewActivityFunc = func(_ context.Context, _ *activity_api.User, a activity_api.ActivityDetails) error {
7292+
emittedMu.Lock()
7293+
defer emittedMu.Unlock()
7294+
emitted = append(emitted, a)
7295+
return nil
7296+
}
7297+
nameTemplateActivities := func() []fleet.ActivityTypeEditedHostNameTemplate {
7298+
emittedMu.Lock()
7299+
defer emittedMu.Unlock()
7300+
var out []fleet.ActivityTypeEditedHostNameTemplate
7301+
for _, a := range emitted {
7302+
if act, ok := a.(fleet.ActivityTypeEditedHostNameTemplate); ok {
7303+
out = append(out, act)
7304+
}
7305+
}
7306+
return out
7307+
}
7308+
7309+
writeYAML := func(t *testing.T, body string) string {
7310+
f, err := os.CreateTemp(t.TempDir(), "*.yml")
7311+
require.NoError(t, err)
7312+
_, err = f.WriteString(body)
7313+
require.NoError(t, err)
7314+
require.NoError(t, f.Close())
7315+
return f.Name()
7316+
}
7317+
teamYAML := func(controlsSection string) string {
7318+
return fmt.Sprintf(`
7319+
controls:
7320+
%s
7321+
queries:
7322+
policies:
7323+
agent_options:
7324+
name: %s
7325+
team_settings:
7326+
secrets:
7327+
- secret: test
7328+
software:
7329+
`, controlsSection, localTeamName)
7330+
}
7331+
7332+
// 0. Dry-run validates but does not persist, and still enforces the
7333+
// fleets-only rule (GitOps CI runs --dry-run on every PR).
7334+
t.Run("dry run does not persist a valid template", func(t *testing.T) {
7335+
ds.BulkUpsertHostDeviceNameEnforcementFuncInvoked = false
7336+
yml := writeYAML(t, teamYAML(fmt.Sprintf(" name_template: %q", template)))
7337+
_ = runAppForTest(t, []string{"gitops", "-f", yml, "--dry-run"})
7338+
7339+
require.Nil(t, savedTeam)
7340+
require.False(t, ds.BulkUpsertHostDeviceNameEnforcementFuncInvoked)
7341+
require.Empty(t, nameTemplateActivities())
7342+
})
7343+
7344+
t.Run("dry run rejects org-level template", func(t *testing.T) {
7345+
yml := writeYAML(t, fmt.Sprintf(`
7346+
controls:
7347+
name_template: %q
7348+
queries:
7349+
policies:
7350+
agent_options:
7351+
org_settings:
7352+
server_settings:
7353+
server_url: %s
7354+
org_info:
7355+
contact_url: https://example.com/contact
7356+
org_logo_url: ""
7357+
org_logo_url_light_background: ""
7358+
org_name: %s
7359+
secrets:
7360+
software:
7361+
`, template, fleetServerURL, orgName))
7362+
_, err := runAppNoChecks([]string{"gitops", "-f", yml, "--dry-run"})
7363+
require.Error(t, err)
7364+
require.Contains(t, err.Error(), "only supported for fleets")
7365+
})
7366+
7367+
// 1. Setting the template on a fleet stores it and emits the activity.
7368+
t.Run("set template on fleet", func(t *testing.T) {
7369+
ds.BulkUpsertHostDeviceNameEnforcementFuncInvoked = false
7370+
yml := writeYAML(t, teamYAML(fmt.Sprintf(" name_template: %q", template)))
7371+
_ = runAppForTest(t, []string{"gitops", "-f", yml})
7372+
7373+
require.NotNil(t, savedTeam)
7374+
require.Equal(t, template, savedTeam.Config.MDM.HostNameTemplate)
7375+
require.True(t, ds.BulkUpsertHostDeviceNameEnforcementFuncInvoked)
7376+
7377+
acts := nameTemplateActivities()
7378+
require.Len(t, acts, 1)
7379+
require.NotNil(t, acts[0].HostNameTemplate)
7380+
require.Equal(t, template, *acts[0].HostNameTemplate)
7381+
})
7382+
7383+
// 2. Re-applying the same YAML is a no-op: no re-enqueue, no duplicate activity.
7384+
t.Run("idempotent re-apply", func(t *testing.T) {
7385+
ds.BulkUpsertHostDeviceNameEnforcementFuncInvoked = false
7386+
yml := writeYAML(t, teamYAML(fmt.Sprintf(" name_template: %q", template)))
7387+
_ = runAppForTest(t, []string{"gitops", "-f", yml})
7388+
7389+
require.Equal(t, template, savedTeam.Config.MDM.HostNameTemplate)
7390+
require.False(t, ds.BulkUpsertHostDeviceNameEnforcementFuncInvoked)
7391+
require.Len(t, nameTemplateActivities(), 1) // still just the one from step 1
7392+
})
7393+
7394+
// 3. Removing the key clears the template (declarative) and emits a null activity.
7395+
t.Run("clear template", func(t *testing.T) {
7396+
ds.DeleteHostDeviceNameEnforcementForTeamFuncInvoked = false
7397+
yml := writeYAML(t, teamYAML(""))
7398+
_ = runAppForTest(t, []string{"gitops", "-f", yml})
7399+
7400+
require.Empty(t, savedTeam.Config.MDM.HostNameTemplate)
7401+
require.True(t, ds.DeleteHostDeviceNameEnforcementForTeamFuncInvoked)
7402+
7403+
acts := nameTemplateActivities()
7404+
require.Len(t, acts, 2)
7405+
require.Nil(t, acts[1].HostNameTemplate) // cleared → null
7406+
})
7407+
7408+
// 4. An invalid variable surfaces the server's 422 validation message through fleetctl.
7409+
t.Run("invalid variable rejected", func(t *testing.T) {
7410+
yml := writeYAML(t, teamYAML(` name_template: "$FLEET_VAR_HOST_END_USER_IDP_GROUPS"`))
7411+
_, err := runAppNoChecks([]string{"gitops", "-f", yml})
7412+
require.Error(t, err)
7413+
require.Contains(t, err.Error(), "is not supported in host name templates")
7414+
})
7415+
7416+
// 4b. A secret variable is rejected at the GitOps env-expansion layer (before
7417+
// the server), since $FLEET_SECRET_* is only allowed in profiles and scripts.
7418+
t.Run("secret variable rejected at expansion", func(t *testing.T) {
7419+
yml := writeYAML(t, teamYAML(` name_template: "iPad $FLEET_SECRET_FOO"`))
7420+
_, err := runAppNoChecks([]string{"gitops", "-f", yml})
7421+
require.Error(t, err)
7422+
require.Contains(t, err.Error(), "FLEET_SECRET_")
7423+
require.Contains(t, err.Error(), "only allowed in profiles and scripts")
7424+
})
7425+
7426+
// 5. name_template in org-level controls is rejected (fleets only).
7427+
t.Run("rejected at org level", func(t *testing.T) {
7428+
yml := writeYAML(t, fmt.Sprintf(`
7429+
controls:
7430+
name_template: %q
7431+
queries:
7432+
policies:
7433+
agent_options:
7434+
org_settings:
7435+
server_settings:
7436+
server_url: %s
7437+
org_info:
7438+
contact_url: https://example.com/contact
7439+
org_logo_url: ""
7440+
org_logo_url_light_background: ""
7441+
org_name: %s
7442+
secrets:
7443+
software:
7444+
`, template, fleetServerURL, orgName))
7445+
_, err := runAppNoChecks([]string{"gitops", "-f", yml})
7446+
require.Error(t, err)
7447+
require.Contains(t, err.Error(), "only supported for fleets")
7448+
})
7449+
7450+
// 6. name_template in no-team.yml controls is rejected (fleets only).
7451+
t.Run("rejected for no team", func(t *testing.T) {
7452+
globalFile := createGlobalFileBasic(t, fleetServerURL, orgName)
7453+
7454+
// The file must be named exactly "no-team.yml".
7455+
noTeamPath := filepath.Join(t.TempDir(), "no-team.yml")
7456+
noTeamFile, err := os.Create(noTeamPath)
7457+
require.NoError(t, err)
7458+
_, err = noTeamFile.WriteString(fmt.Sprintf(`
7459+
controls:
7460+
name_template: %q
7461+
policies:
7462+
name: No team
7463+
software:
7464+
`, template))
7465+
require.NoError(t, err)
7466+
require.NoError(t, noTeamFile.Close())
7467+
7468+
_, err = runAppNoChecks([]string{"gitops", "-f", globalFile.Name(), "-f", noTeamPath})
7469+
require.Error(t, err)
7470+
require.Contains(t, err.Error(), "only supported for fleets")
7471+
})
7472+
}
7473+
72217474
func TestGitOpsWindowsOSUpdates(t *testing.T) {
72227475
license := &fleet.LicenseInfo{Tier: fleet.TierPremium, Expiration: time.Now().Add(24 * time.Hour)}
72237476
_, ds := testing_utils.RunServerWithMockedDS(

cmd/fleetctl/fleetctl/testdata/generateGitops/teamConfig.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@
109109
},
110110
"mdm": {
111111
"enable_disk_encryption": true,
112+
"name_template": "iPad $FLEET_VAR_HOST_HARDWARE_SERIAL",
112113
"macos_updates": {
113114
"minimum_version": "95.1",
114115
"deadline": "2020-12-31",

cmd/fleetctl/fleetctl/testdata/generateGitops/test_dir_premium/fleets/team-a-thumbsup.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ controls:
4141
deadline: "2020-12-31"
4242
minimum_version: "95.1"
4343
update_new_hosts: true
44+
name_template: iPad $FLEET_VAR_HOST_HARDWARE_SERIAL
4445
scripts:
4546
- path: "../lib/team-a-👍/scripts/Script B.ps1"
4647
windows_enabled_and_configured: true

pkg/spec/gitops.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,7 @@ type GitOpsControls struct {
194194
EnableDiskEncryption any `json:"enable_disk_encryption"`
195195
EnableRecoveryLockPassword any `json:"enable_recovery_lock_password"`
196196
RequireBitLockerPIN any `json:"windows_require_bitlocker_pin,omitempty"`
197+
NameTemplate any `json:"name_template"`
197198
Scripts []fleet.BaseItem `json:"scripts"`
198199

199200
Defined bool
@@ -207,7 +208,8 @@ func (c GitOpsControls) Set() bool {
207208
c.WindowsMigrationEnabled != nil || c.EnableDiskEncryption != nil || c.EnableRecoveryLockPassword != nil ||
208209
len(c.Scripts) > 0 || c.AndroidEnabledAndConfigured != nil || c.AndroidSettings != nil ||
209210
c.AppleRequireHardwareAttestation != nil || c.EnableTurnOnWindowsMDMManually != nil ||
210-
c.WindowsEntraTenantIDs != nil || c.WindowsEntraClientIDs != nil || c.RequireBitLockerPIN != nil
211+
c.WindowsEntraTenantIDs != nil || c.WindowsEntraClientIDs != nil || c.RequireBitLockerPIN != nil ||
212+
c.NameTemplate != nil
211213
}
212214

213215
type Policy struct {
@@ -1174,6 +1176,13 @@ func parseControls(top map[string]json.RawMessage, result *GitOps, logFn Logf, y
11741176
}
11751177
}
11761178

1179+
// Validate that name_template, if present, is a string.
1180+
if result.Controls.NameTemplate != nil {
1181+
if _, ok := result.Controls.NameTemplate.(string); !ok {
1182+
multiError = multierror.Append(multiError, fmt.Errorf("'controls.name_template' must be a string in %s", controlsFilePath))
1183+
}
1184+
}
1185+
11771186
// Find Fleet secrets in profiles
11781187
if result.Controls.MacOSSettings != nil {
11791188
// We are marshalling/unmarshalling to get the data into the fleet.MacOSSettings struct.

pkg/spec/gitops_test.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -409,6 +409,49 @@ func TestValidGitOpsYaml(t *testing.T) {
409409
}
410410
}
411411

412+
func TestGitOpsHostNameTemplate(t *testing.T) {
413+
t.Parallel()
414+
415+
t.Run("valid string parses", func(t *testing.T) {
416+
config := getTeamConfig([]string{"controls"})
417+
config += "controls:\n name_template: \"iPad $FLEET_VAR_HOST_HARDWARE_SERIAL\"\n"
418+
gitops, err := gitOpsFromString(t, config)
419+
require.NoError(t, err)
420+
nameTemplate, ok := gitops.Controls.NameTemplate.(string)
421+
require.True(t, ok, "name_template should be a string")
422+
require.Equal(t, "iPad $FLEET_VAR_HOST_HARDWARE_SERIAL", nameTemplate)
423+
})
424+
425+
t.Run("integer value rejected", func(t *testing.T) {
426+
config := getTeamConfig([]string{"controls"})
427+
config += "controls:\n name_template: 42\n"
428+
_, err := gitOpsFromString(t, config)
429+
require.ErrorContains(t, err, "name_template")
430+
require.ErrorContains(t, err, "must be a string")
431+
})
432+
433+
t.Run("map value rejected", func(t *testing.T) {
434+
config := getTeamConfig([]string{"controls"})
435+
config += "controls:\n name_template:\n foo: bar\n"
436+
_, err := gitOpsFromString(t, config)
437+
require.ErrorContains(t, err, "name_template")
438+
require.ErrorContains(t, err, "must be a string")
439+
})
440+
441+
t.Run("null value treated as absent", func(t *testing.T) {
442+
config := getTeamConfig([]string{"controls"})
443+
config += "controls:\n name_template:\n"
444+
gitops, err := gitOpsFromString(t, config)
445+
require.NoError(t, err)
446+
require.Nil(t, gitops.Controls.NameTemplate)
447+
})
448+
449+
t.Run("Set returns true when only name_template present", func(t *testing.T) {
450+
c := GitOpsControls{NameTemplate: "iPad $FLEET_VAR_HOST_HARDWARE_SERIAL"}
451+
require.True(t, c.Set())
452+
})
453+
}
454+
412455
func TestDuplicatePolicyNames(t *testing.T) {
413456
t.Parallel()
414457
config := getGlobalConfig([]string{"policies"})

server/service/client.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2409,6 +2409,21 @@ func (c *Client) DoGitOps(
24092409
mdmAppConfig = team["mdm"].(map[string]interface{})
24102410
}
24112411

2412+
// name_template (host name template) is a fleets-only control.
2413+
nameTemplate := ""
2414+
if incoming.Controls.NameTemplate != nil {
2415+
var ok bool
2416+
nameTemplate, ok = incoming.Controls.NameTemplate.(string)
2417+
if !ok {
2418+
return nil, errors.New("controls.name_template must be a string")
2419+
}
2420+
}
2421+
if incoming.TeamName != nil && !incoming.IsNoTeam() {
2422+
mdmAppConfig["name_template"] = nameTemplate
2423+
} else if nameTemplate != "" {
2424+
return nil, errors.New(`name_template is only supported for fleets (teams); remove it from org-level and "No team" controls`)
2425+
}
2426+
24122427
if !incoming.IsNoTeam() {
24132428

24142429
// Common controls settings between org and team settings

0 commit comments

Comments
 (0)