Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changes/45661-fix-gitops-relative-paths
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Fixed GitOps relative path lookup for controls.setup_experience.(apple_setup_assistant, macos_script, software.package_path) in unassigned.yml, and org_logo_paths under org_settings.
Comment thread
MagnusHJensen marked this conversation as resolved.
7 changes: 7 additions & 0 deletions cmd/fleetctl/fleetctl/gitops.go
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,9 @@ func gitopsCommand() *cli.Command {
return errors.New("'controls' must be set on global config")
}
if !config.Controls.Set() {
// noTeamControls had its file paths resolved to absolute against the
// no-team file's own dir in extractControlsForNoTeam, so they survive
// being applied here under the global file's baseDir.
config.Controls = noTeamControls
}
}
Expand Down Expand Up @@ -1096,6 +1099,10 @@ func extractControlsForNoTeam(flFilenames cli.StringSlice, appConfig *fleet.Enri
if err != nil {
return spec.GitOpsControls{}, false, fileName, err
}
// These controls are applied onto the global config and applied under the
// global file's baseDir, so resolve their file paths to absolute against this
// file's own dir to keep relative paths (e.g. ../lib/...) working.
config.Controls.ResolveFilePathsAbs(baseDir)
return config.Controls, true, fileName, nil
}
}
Expand Down
179 changes: 179 additions & 0 deletions cmd/fleetctl/fleetctl/gitops_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3639,6 +3639,185 @@ software:
assert.Len(t, teamAppliedPoliceSpecs[1].LabelsExcludeAny, 0)
assert.Equal(t, teamAppliedPoliceSpecs[1].LabelsIncludeAny[0], "b")
})

// Reproduces issue #45661: apple_setup_assistant in unassigned.yml uses a
// relative path that climbs out of fleets/ into a sibling lib/ directory.
// Before the fix, the path resolved against the global file's baseDir and
// failed with "no such file or directory".
t.Run("unassigned.yml with apple_setup_assistant via ../ relative path", func(t *testing.T) {
ds.GetLabelSpecsFunc = func(ctx context.Context, filter fleet.TeamFilter) ([]*fleet.LabelSpec, error) {
return nil, nil
}

tmpDir := t.TempDir()

// lib/macos/enrollment-profiles/automatic-enrollment.dep.json
depDir := filepath.Join(tmpDir, "lib", "macos", "enrollment-profiles")
require.NoError(t, os.MkdirAll(depDir, 0o755))
depPath := filepath.Join(depDir, "automatic-enrollment.dep.json")
require.NoError(t, os.WriteFile(depPath, []byte(`{"profile_name":"test"}`), 0o644))

// fleets/
fleetsDir := filepath.Join(tmpDir, "fleets")
require.NoError(t, os.MkdirAll(fleetsDir, 0o755))

unassignedFile := filepath.Join(fleetsDir, "unassigned.yml")
require.NoError(t, os.WriteFile(unassignedFile, []byte(`
name: Unassigned
policies:
reports:
agent_options:
controls:
setup_experience:
apple_setup_assistant: ../lib/macos/enrollment-profiles/automatic-enrollment.dep.json
software:
`), 0o644))

workstationsFile := filepath.Join(fleetsDir, "workstations.yml")
require.NoError(t, os.WriteFile(workstationsFile, []byte(`
name: Workstations
policies:
reports:
agent_options:
controls:
setup_experience:
apple_setup_assistant: ../lib/macos/enrollment-profiles/automatic-enrollment.dep.json
settings:
secrets:
- secret: workstations-secret
software:
`), 0o644))

globalFile := filepath.Join(tmpDir, "default.yml")
require.NoError(t, os.WriteFile(globalFile, []byte(fmt.Sprintf(`
agent_options:
labels:
policies:
reports:
org_settings:
server_settings:
server_url: %s
org_info:
contact_url: https://example.com/contact
org_name: %s
secrets:
- secret: global-secret
`, fleetServerURL, orgName)), 0o644))

_, err := runAppNoChecks([]string{"gitops", "-f", globalFile, "-f", unassignedFile, "-f", workstationsFile, "--dry-run"})
require.NoError(t, err, "gitops should succeed when apple_setup_assistant in unassigned.yml uses a relative path that climbs out of the fleets/ directory")
})

// Same-directory layout (default.yml and no-team.yml side by side, path into
// a sibling lib/no-team/...). Guards against the double-anchoring regression
// (generated/generated/...) where the applied no-team control path was joined
// once at extraction and again at apply.
t.Run("no-team.yml with apple_setup_assistant in the same dir as default.yml", func(t *testing.T) {
ds.GetLabelSpecsFunc = func(ctx context.Context, filter fleet.TeamFilter) ([]*fleet.LabelSpec, error) {
return nil, nil
}

tmpDir := t.TempDir()

depDir := filepath.Join(tmpDir, "lib", "no-team")
require.NoError(t, os.MkdirAll(depDir, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(depDir, "macos_enrollment.json"), []byte(`{"profile_name":"test"}`), 0o644))

noTeamFile := filepath.Join(tmpDir, "no-team.yml")
require.NoError(t, os.WriteFile(noTeamFile, []byte(`
name: No team
policies:
reports:
agent_options:
controls:
setup_experience:
apple_setup_assistant: lib/no-team/macos_enrollment.json
software:
`), 0o644))

globalFile := filepath.Join(tmpDir, "default.yml")
require.NoError(t, os.WriteFile(globalFile, []byte(fmt.Sprintf(`
agent_options:
labels:
policies:
reports:
org_settings:
server_settings:
server_url: %s
org_info:
contact_url: https://example.com/contact
org_name: %s
secrets:
- secret: global-secret
`, fleetServerURL, orgName)), 0o644))

_, err := runAppNoChecks([]string{"gitops", "-f", globalFile, "-f", noTeamFile, "--dry-run"})
require.NoError(t, err, "gitops should succeed when apple_setup_assistant in no-team.yml lives in the same dir as default.yml")
})
}

func TestGitOpsUnassignedRelativePathsFromWorkingDir(t *testing.T) {
// Cannot run t.Parallel(): SetupFullGitOpsPremiumServer sets env vars and we t.Chdir.
ds, _, _ := testing_utils.SetupFullGitOpsPremiumServer(t)
testing_utils.StartSoftwareInstallerServer(t)
ds.GetLabelSpecsFunc = func(ctx context.Context, filter fleet.TeamFilter) ([]*fleet.LabelSpec, error) {
return nil, nil
}

root := t.TempDir()
// Layout under a "generated" subdir so the working dir sits one level above it,
// mirroring running `fleetctl gitops -f generated/fleets/...` from build/.
fleetsDir := filepath.Join(root, "generated", "fleets")
require.NoError(t, os.MkdirAll(fleetsDir, 0o755))

depDir := filepath.Join(root, "generated", "lib", "macos", "enrollment-profiles")
require.NoError(t, os.MkdirAll(depDir, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(depDir, "automatic-enrollment.dep.json"), []byte(`{"profile_name":"test"}`), 0o644))

scriptsDir := filepath.Join(root, "generated", "lib", "unassigned", "scripts")
require.NoError(t, os.MkdirAll(scriptsDir, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(scriptsDir, "turn-off-mdm.ps1"), []byte(`Write-Host "hi"`), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(scriptsDir, "setup.sh"), []byte("#!/bin/sh\necho hi\n"), 0o644))

require.NoError(t, os.WriteFile(filepath.Join(fleetsDir, "unassigned.yml"), []byte(`
name: Unassigned
policies:
reports:
agent_options:
controls:
setup_experience:
apple_setup_assistant: ../lib/macos/enrollment-profiles/automatic-enrollment.dep.json
macos_script: ../lib/unassigned/scripts/setup.sh
scripts:
- path: ../lib/unassigned/scripts/turn-off-mdm.ps1
software:
`), 0o644))

require.NoError(t, os.WriteFile(filepath.Join(fleetsDir, "default.yml"), []byte(fmt.Sprintf(`
agent_options:
labels:
policies:
reports:
org_settings:
server_settings:
server_url: %s
org_info:
contact_url: https://example.com/contact
org_name: %s
secrets:
- secret: global-secret
`, fleetServerURL, orgName)), 0o644))

// Working dir is the parent of generated/, and -f paths are relative.
t.Chdir(root)

_, err := runAppNoChecks([]string{
"gitops",
"-f", "generated/fleets/default.yml",
"-f", "generated/fleets/unassigned.yml",
"--dry-run",
})
require.NoError(t, err, "gitops should resolve unassigned.yml relative paths from fleets/, not double-anchor under the working dir")
}

func TestGitOpsCustomSettings(t *testing.T) {
Expand Down
80 changes: 80 additions & 0 deletions pkg/spec/gitops.go
Original file line number Diff line number Diff line change
Expand Up @@ -654,6 +654,13 @@ func parseOrgSettings(raw json.RawMessage, result *GitOps, baseDir string, fileP
// This error is currently unreachable because we know the file is valid YAML when we checked for nested path
multiError = multierror.Append(multiError, MaybeParseTypeError(filePath, []string{"org_settings"}, err))
} else {
// Anchor relative paths from a nested org_settings file to its own directory;
// otherwise they'd later resolve against the main file's baseDir and fail.
if orgSettingsTop.Path != nil {
orgSettingsDir := filepath.Dir(resolveApplyRelativePath(baseDir, *orgSettingsTop.Path))
reanchorOrgSettingsPaths(result.OrgSettings, orgSettingsDir)
}

multiError = parseSecrets(result, multiError)
multiError = validateOrgInfoLogo(result.OrgSettings, multiError)
multiError = validateGitOpsConfig(result.OrgSettings, multiError)
Expand All @@ -666,6 +673,79 @@ func parseOrgSettings(raw json.RawMessage, result *GitOps, baseDir string, fileP
return multiError
}

// absPathFrom resolves a relative file path to an absolute one anchored at baseDir.
// Absolute paths are independent of any baseDir, so a later resolveApplyRelativePath
// is a guaranteed no-op — this lets paths authored in one gitops file survive being
// applied under a different file's baseDir.
func absPathFrom(baseDir, p string) string {
if p == "" || filepath.IsAbs(p) {
return p
}
if abs, err := filepath.Abs(filepath.Join(baseDir, p)); err == nil {
return abs
}
return filepath.Join(baseDir, p) // fallback: at least anchored
}

// ResolveFilePathsAbs rewrites the controls' file-path fields that are resolved at
// APPLY time to absolute paths against baseDir. Used when controls from
// no-team.yml/unassigned.yml are applied onto the global config, which is applied
// under a different (global) baseDir — making these paths absolute means the
// apply-side resolveApplyRelativePath becomes a no-op, so paths authored relative to
// the no-team file (e.g. ../lib/...) keep working.
//
// Only fields resolved at apply time are touched. Deliberately excluded:
// - controls.scripts and macos_settings/windows_settings profiles are already
// resolved at parse time against the file's own dir (see parseControls), so
// re-resolving here would double-anchor them.
func (c *GitOpsControls) ResolveFilePathsAbs(baseDir string) {
if c.MacOSSetup == nil {
return
}
c.MacOSSetup.MacOSSetupAssistant.Value = absPathFrom(baseDir, c.MacOSSetup.MacOSSetupAssistant.Value)
c.MacOSSetup.Script.Value = absPathFrom(baseDir, c.MacOSSetup.Script.Value)
Comment thread
MagnusHJensen marked this conversation as resolved.
for _, sw := range c.MacOSSetup.Software.Value {
if sw != nil {
sw.PackagePath = absPathFrom(baseDir, sw.PackagePath)
}
}
}

// reanchorOrgSettingsPaths rewrites the relative path-bearing fields inside org_settings
// to be absolute against orgSettingsDir. Without this, paths authored in a nested
// org_settings file are later resolved against the main file's baseDir.
func reanchorOrgSettingsPaths(orgSettings map[string]any, orgSettingsDir string) {
anchor := func(v string) string {
return absPathFrom(orgSettingsDir, v)
}

if orgInfo, ok := orgSettings["org_info"].(map[string]any); ok {
for _, key := range []string{"org_logo_path_light_mode", "org_logo_path_dark_mode"} {
if s, ok := orgInfo[key].(string); ok && s != "" {
orgInfo[key] = anchor(s)
}
}
}

if rules, ok := orgSettings["yara_rules"].([]any); ok {
for _, r := range rules {
rule, ok := r.(map[string]any)
if !ok {
continue
}
if p, ok := rule["path"].(string); ok && p != "" {
rule["path"] = anchor(p)
}
}
}

if mdm, ok := orgSettings["mdm"].(map[string]any); ok {
if eula, ok := mdm["end_user_license_agreement"].(string); ok && eula != "" {
mdm["end_user_license_agreement"] = anchor(eula)
}
}
}

// validateOrgInfoLogo rejects org_info configurations that specify both a path
// and a URL for the same mode. Deprecated URL keys are already migrated to the
// new mode-aware names by ApplyDeprecatedKeyMappings before this runs.
Expand Down
Loading
Loading