diff --git a/changes/45661-fix-gitops-relative-paths b/changes/45661-fix-gitops-relative-paths new file mode 100644 index 00000000000..a92148d82d4 --- /dev/null +++ b/changes/45661-fix-gitops-relative-paths @@ -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. \ No newline at end of file diff --git a/cmd/fleetctl/fleetctl/gitops.go b/cmd/fleetctl/fleetctl/gitops.go index 06261b691aa..85bdb941a8b 100644 --- a/cmd/fleetctl/fleetctl/gitops.go +++ b/cmd/fleetctl/fleetctl/gitops.go @@ -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 } } @@ -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 } } diff --git a/cmd/fleetctl/fleetctl/gitops_test.go b/cmd/fleetctl/fleetctl/gitops_test.go index f9966848c81..ec4eeb70c5b 100644 --- a/cmd/fleetctl/fleetctl/gitops_test.go +++ b/cmd/fleetctl/fleetctl/gitops_test.go @@ -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) { diff --git a/pkg/spec/gitops.go b/pkg/spec/gitops.go index b837440107f..0eebc30bfea 100644 --- a/pkg/spec/gitops.go +++ b/pkg/spec/gitops.go @@ -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) @@ -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) + 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. diff --git a/pkg/spec/gitops_test.go b/pkg/spec/gitops_test.go index d4c53a2c3a2..b3b4231d7a7 100644 --- a/pkg/spec/gitops_test.go +++ b/pkg/spec/gitops_test.go @@ -1365,6 +1365,192 @@ org_settings: }) } +// TestGitOpsOrgSettingsNestedPathResolution covers issue #45661: relative paths +// inside a nested org_settings file (loaded via org_settings.path) must be +// resolved against that file's directory, not the main file's baseDir. +func TestGitOpsOrgSettingsNestedPathResolution(t *testing.T) { + t.Parallel() + + // Build a layout where the org_settings file sits in a subdirectory and + // references assets up one level — the exact shape that fails without the fix. + setup := func(t *testing.T, orgSettingsBody string) (*GitOps, string) { + t.Helper() + tmpDir := t.TempDir() + + settingsDir := filepath.Join(tmpDir, "settings") + require.NoError(t, os.MkdirAll(settingsDir, 0o755)) + require.NoError(t, os.WriteFile( + filepath.Join(settingsDir, "org.yml"), + []byte(orgSettingsBody), + 0o644, + )) + + mainBody := getGlobalConfig([]string{"org_settings"}) + mainBody += "\norg_settings:\n path: ./settings/org.yml\n" + mainPath := filepath.Join(tmpDir, "default.yml") + require.NoError(t, os.WriteFile(mainPath, []byte(mainBody), 0o644)) + + gitops, err := GitOpsFromFile(mainPath, tmpDir, nil, nopLogf) + require.NoError(t, err) + return gitops, tmpDir + } + + t.Run("org_logo_path keys resolve relative to nested file", func(t *testing.T) { + gitops, tmpDir := setup(t, ` +server_settings: + server_url: https://fleet.example.com +org_info: + contact_url: https://example.com/contact + org_name: Test Org + org_logo_path_dark_mode: ../assets/dark.png + org_logo_path_light_mode: ../assets/light.png +secrets: +`) + orgInfo := gitops.OrgSettings["org_info"].(map[string]any) + assert.Equal(t, filepath.Join(tmpDir, "assets/dark.png"), orgInfo["org_logo_path_dark_mode"]) + assert.Equal(t, filepath.Join(tmpDir, "assets/light.png"), orgInfo["org_logo_path_light_mode"]) + }) + + t.Run("end_user_license_agreement resolves relative to nested file", func(t *testing.T) { + gitops, tmpDir := setup(t, ` +server_settings: + server_url: https://fleet.example.com +org_info: + contact_url: https://example.com/contact + org_name: Test Org +mdm: + end_user_license_agreement: ../docs/eula.pdf +secrets: +`) + mdm := gitops.OrgSettings["mdm"].(map[string]any) + assert.Equal(t, filepath.Join(tmpDir, "docs/eula.pdf"), mdm["end_user_license_agreement"]) + }) + + t.Run("absolute paths in nested file are untouched", func(t *testing.T) { + abs := "/etc/fleet/logo.png" + gitops, _ := setup(t, fmt.Sprintf(` +server_settings: + server_url: https://fleet.example.com +org_info: + contact_url: https://example.com/contact + org_name: Test Org + org_logo_path_dark_mode: %s +secrets: +`, abs)) + orgInfo := gitops.OrgSettings["org_info"].(map[string]any) + assert.Equal(t, abs, orgInfo["org_logo_path_dark_mode"]) + }) + + t.Run("inline org_settings unchanged", func(t *testing.T) { + // Regression guard: when org_settings is inline (no path:), values are NOT + // re-anchored at parse time — they continue through the existing + // client_appconfig.go resolution path. + config := getGlobalConfig([]string{"org_settings"}) + config += ` +org_settings: + server_settings: + server_url: https://fleet.example.com + org_info: + contact_url: https://example.com/contact + org_name: Test Org + org_logo_path_dark_mode: ./dark.png + secrets: +` + gitops, err := gitOpsFromString(t, config) + require.NoError(t, err) + orgInfo := gitops.OrgSettings["org_info"].(map[string]any) + assert.Equal(t, "./dark.png", orgInfo["org_logo_path_dark_mode"]) + }) +} + +// TestGitOpsControlsResolveFilePathsAbs verifies that control file paths are +// resolved to absolute paths against the no-team file's own dir. This is what +// lets relative paths (e.g. ../lib/...) survive being grafted onto the global +// config and applied under a different baseDir. The bug it guards against: +// using filepath.Join with a relative baseDir leaves the path relative, so the +// apply-side resolveApplyRelativePath re-anchors it a second time (the +// "generated/generated/..." double-anchor from issue #45661). +func TestGitOpsControlsResolveFilePathsAbs(t *testing.T) { + t.Parallel() + + t.Run("apply-time paths with ../ become absolute, not double-anchored", func(t *testing.T) { + controls := GitOpsControls{ + MacOSSetup: &fleet.MacOSSetup{ + MacOSSetupAssistant: optjson.SetString("../lib/no-team/macos_enrollment.json"), + Script: optjson.SetString("../lib/no-team/setup.sh"), + Software: optjson.SetSlice([]*fleet.MacOSSetupSoftware{ + {PackagePath: "../lib/no-team/app.pkg"}, + }), + }, + } + + // "fleets" mirrors the issue layout where unassigned.yml lives in fleets/. + controls.ResolveFilePathsAbs("fleets") + + for _, got := range []string{ + controls.MacOSSetup.MacOSSetupAssistant.Value, + controls.MacOSSetup.Script.Value, + controls.MacOSSetup.Software.Value[0].PackagePath, + } { + assert.True(t, filepath.IsAbs(got), "expected absolute path, got %q", got) + // ../ from fleets/ climbs out of fleets/, so the result must not contain it. + assert.NotContains(t, got, "fleets/lib", "path should not retain the fleets/lib segment: %q", got) + } + assert.True(t, strings.HasSuffix(controls.MacOSSetup.MacOSSetupAssistant.Value, "lib/no-team/macos_enrollment.json")) + }) + + t.Run("parse-time paths and bootstrap URL are left untouched", func(t *testing.T) { + // controls.scripts are already resolved at parse time, and bootstrap_package + // is a URL — re-anchoring either here would double-anchor / corrupt them. + scriptPath := "../lib/no-team/script.sh" + controls := GitOpsControls{ + MacOSSetup: &fleet.MacOSSetup{ + BootstrapPackage: optjson.SetString("https://example.com/bootstrap.pkg"), + }, + Scripts: []fleet.BaseItem{{Path: &scriptPath}}, + } + + controls.ResolveFilePathsAbs("fleets") + + assert.Equal(t, "https://example.com/bootstrap.pkg", controls.MacOSSetup.BootstrapPackage.Value) + assert.Equal(t, "../lib/no-team/script.sh", *controls.Scripts[0].Path) + }) + + t.Run("same-dir relative path is anchored exactly once", func(t *testing.T) { + // Reproduces the user's error: default.yml and no-team.yml both in generated/, + // path lib/no-team/... — before the fix this double-anchored to + // generated/generated/lib/no-team/macos_enrollment.json at apply time. + controls := GitOpsControls{ + MacOSSetup: &fleet.MacOSSetup{ + MacOSSetupAssistant: optjson.SetString("lib/no-team/macos_enrollment.json"), + }, + } + controls.ResolveFilePathsAbs("generated") + + got := controls.MacOSSetup.MacOSSetupAssistant.Value + assert.True(t, filepath.IsAbs(got), "expected absolute path, got %q", got) + assert.True(t, strings.HasSuffix(got, "generated/lib/no-team/macos_enrollment.json"), "got %q", got) + assert.Equal(t, 1, strings.Count(got, "generated/lib"), "the generated/ segment must appear once, not be double-anchored: %q", got) + }) + + t.Run("absolute and empty paths are untouched", func(t *testing.T) { + controls := GitOpsControls{ + MacOSSetup: &fleet.MacOSSetup{ + MacOSSetupAssistant: optjson.SetString("/etc/fleet/macos_enrollment.json"), + Script: optjson.SetString(""), + }, + } + controls.ResolveFilePathsAbs("fleets") + assert.Equal(t, "/etc/fleet/macos_enrollment.json", controls.MacOSSetup.MacOSSetupAssistant.Value) + assert.Empty(t, controls.MacOSSetup.Script.Value) + }) + + t.Run("nil MacOSSetup is a no-op", func(t *testing.T) { + controls := GitOpsControls{} + assert.NotPanics(t, func() { controls.ResolveFilePathsAbs("fleets") }) + }) +} + // TestGitOpsModeYaml exercises the parse-time validator for the // `org_settings.gitops` block: rejects `exceptions`, enforces the // repository_url requirement and scheme rules, and accepts well-formed diff --git a/server/service/client.go b/server/service/client.go index 2cdbb07f247..07fbe5bce4d 100644 --- a/server/service/client.go +++ b/server/service/client.go @@ -638,7 +638,7 @@ func (c *Client) ApplyGroup( return nil, nil, nil, nil, fmt.Errorf("applying fleet config: %w", err) } if !opts.DryRun { - if err := c.uploadMacOSSetupAssistant(content, nil, macosSetup.MacOSSetupAssistant.Value); err != nil { + if err := c.uploadMacOSSetupAssistant(content, nil, filepath.Base(macosSetup.MacOSSetupAssistant.Value)); err != nil { return nil, nil, nil, nil, fmt.Errorf("applying fleet config: %w", err) } } @@ -1036,7 +1036,7 @@ func (c *Client) ApplyGroup( if b, ok := tmMacSetupAssistants[tmName]; ok { switch { case b != nil: - if err := c.uploadMacOSSetupAssistant(b, &tmID, tmMacSetup[tmName].MacOSSetupAssistant.Value); err != nil { + if err := c.uploadMacOSSetupAssistant(b, &tmID, filepath.Base(tmMacSetup[tmName].MacOSSetupAssistant.Value)); err != nil { if strings.Contains(err.Error(), "Couldn't add") { // Then the error should look something like this: // "Couldn't add. CONFIG_NAME_INVALID"