fleet/cmd/fleetctl/integrationtest/gitops/gitops_integration_test.go
Scott Gress 6598b608b7
Enforce GitOps exceptions (#42191)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #42180 

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Enhanced GitOps exception handling for labels, secrets, and software
with clearer enforcement and omission semantics.
* Server-side prefetch of team software so omitted team software can
preserve existing installers during validation.
* Presence flags track whether top-level keys (labels, secrets,
software) were provided versus omitted.

* **Behavior Changes**
* Omitted vs empty sections are now distinguished: omission can mean
“no-op” or “delete-all” depending on exception settings.
* GitOps YAML can define and manage labels directly; validations now
reject YAML that includes keys marked as excepted.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

# Checklist for submitter

If some of the following don't apply, delete the relevant line.

- [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
    * **Labels**
- [ ] Validated that with label exceptions off, omitting `labels:` key
from default.yml clears all global labels
- [ ] Validated that with label exceptions off, omitting `labels:` key
from a fleet .yml clears all labels for that fleet
- [ ] Validated that with label exceptions off, setting empty `labels:`
key from default.yml clears all global labels
- [ ] Validated that with label exceptions off, setting empty `labels:`
key from a fleet .yml clears all labels for that fleet
- [ ] Validated that with label exceptions on, omitting `labels:` key
from default .yml leaves existing global labels as-is
- [ ] Validated that with label exceptions on, omitting `labels:` key
from a fleet .yml leaves existing labels as-is
- [ ] Validated that with label exceptions on, setting `labels:` key on
default .yml generates an error
- [ ] Validated that with label exceptions on, setting `labels:` key on
a fleet .yml generates an error
- [ ] Validated that with label exceptions on, a policy using
`labels_include_any` referencing an existing label succeeds without
`labels:` key
- [ ] Validated that with label exceptions on, a query using
`labels_include_any` referencing an existing label succeeds without
`labels:` key
- [ ] Validated that with label exceptions on, an MDM profile using
`labels_include_any` referencing an existing label succeeds without
`labels:` key
- [ ] Validated that with label exceptions on, a software package using
`labels_include_any` referencing an existing label succeeds without
`labels:` key (requires software exceptions off)
- [ ] Validated that with label exceptions on, an app store app using
`labels_include_any` referencing an existing label succeeds without
`labels:` key (requires software exceptions off)
- [ ] Validated that with label exceptions on, a fleet maintained app
using `labels_include_any` referencing an existing label succeeds
without `labels:` key (requires software exceptions off)
    * **Secrets**
- [ ] Validated that with secrets exceptions off, omitting `secrets:`
key from default.yml clears all global secrets
- [ ] Validated that with secrets exceptions off, omitting `secrets:`
key from a fleet .yml clears all secrets for that fleet
- [ ] Validated that with secrets exceptions on, omitting `secrets:` key
from default .yml leaves existing global secrets as-is
- [ ] Validated that with secrets exceptions on, omitting `secrets:` key
from a fleet .yml leaves existing secrets as-is
- [ ] Validated that with secrets exceptions on, setting `secrets:` key
on default .yml generates an error
- [ ] Validated that with secrets exceptions on, setting `secrets:` key
on a fleet .yml generates an error
    * **Software**
- [ ] Validated that with software exceptions off, omitting `software:`
key from no-team.yml/unassigned.yml clears all software for "no team"
- [ ] Validated that with software exceptions off, omitting `software:`
key from a fleet .yml clears all software for that fleet
- [ ] Validated that with software exceptions off, setting empty
`software:` key on a fleet .yml clears all software for that fleet
- [ ] Validated that with software exceptions off, setting empty
`software:` key on no-team.yml/unassigned.yml clears all software for
"no team
- [ ] Validated that with software exceptions on, omitting `software:`
key from a fleet .yml leaves existing software as-is
- [ ] Validated that with software exceptions on, setting `software:`
key on a fleet .yml generates an error
- [ ] Validated that with software exceptions on, omitting `software:`
key from no-team.yml/unassigned.yml leaves existing software as-is for
"no team"
- [ ] Validated that with software exceptions on, setting `software:`
key on no-team.yml/unassigned.yml generates an error
- [ ] Validated that with software exceptions on, a policy using
`install_software.hash_sha256` referencing an existing package succeeds
without `software:` key
- [ ] Validated that with software exceptions on, a policy using
`install_software.app_store_id` referencing an existing VPP app succeeds
without `software:` key
- [ ] Validated that with software exceptions on, a patch policy using
`fleet_maintained_app_slug` referencing an existing FMA succeeds without
`software:` key
- [ ] Validated that with software exceptions on,
`setup_experience.software` referencing existing software succeeds
without `software:` key (server-side validation fallback)
- [ ] Validated that with software exceptions on, omitting `software:`
from no-team.yml/unassigned.yml preserves existing no-team software
- [ ] Validated that with software exceptions on, a policy in
no-team.yml/unassigned.yml using `install_software.hash_sha256`
referencing existing no-team software succeeds without `software:` key
For unreleased bug fixes in a release candidate, one of:

- [X] Confirmed that the fix is not expected to adversely impact load
test results
I don't think so. There is a bit of overhead when this feature is used
since we have to fetch software from the server, but it would be done in
a specific test, so even if there is an impact it should affect existing
load testing, only new, specific tests.
2026-03-27 15:38:08 -05:00

224 lines
7.4 KiB
Go

package gitops
import (
"context"
"fmt"
"os"
"path"
"testing"
"github.com/fleetdm/fleet/v4/cmd/fleetctl/fleetctl"
"github.com/fleetdm/fleet/v4/cmd/fleetctl/integrationtest"
"github.com/fleetdm/fleet/v4/server/config"
"github.com/fleetdm/fleet/v4/server/datastore/redis/redistest"
"github.com/fleetdm/fleet/v4/server/fleet"
appleMdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/tokenpki"
"github.com/fleetdm/fleet/v4/server/service"
"github.com/fleetdm/fleet/v4/server/test"
"github.com/go-git/go-git/v5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
)
func TestIntegrationsGitops(t *testing.T) {
testingSuite := new(integrationGitopsTestSuite)
testingSuite.WithServer.Suite = &testingSuite.Suite
suite.Run(t, testingSuite)
}
type integrationGitopsTestSuite struct {
suite.Suite
integrationtest.WithServer
fleetCfg config.FleetConfig
}
func (s *integrationGitopsTestSuite) SetupSuite() {
s.WithDS.SetupSuite("integrationGitopsTestSuite")
appConf, err := s.DS.AppConfig(context.Background())
require.NoError(s.T(), err)
appConf.MDM.EnabledAndConfigured = true
appConf.MDM.AppleBMEnabledAndConfigured = true
appConf.MDM.WindowsEnabledAndConfigured = true
err = s.DS.SaveAppConfig(context.Background(), appConf)
require.NoError(s.T(), err)
testCert, testKey, err := appleMdm.NewSCEPCACertKey()
require.NoError(s.T(), err)
testCertPEM := tokenpki.PEMCertificate(testCert.Raw)
testKeyPEM := tokenpki.PEMRSAPrivateKey(testKey)
fleetCfg := config.TestConfig()
config.SetTestMDMConfig(s.T(), &fleetCfg, testCertPEM, testKeyPEM, "../../../../server/service/testdata")
fleetCfg.Osquery.EnrollCooldown = 0
mdmStorage, err := s.DS.NewMDMAppleMDMStorage()
require.NoError(s.T(), err)
depStorage, err := s.DS.NewMDMAppleDEPStorage()
require.NoError(s.T(), err)
scepStorage, err := s.DS.NewSCEPDepot()
require.NoError(s.T(), err)
redisPool := redistest.SetupRedis(s.T(), "zz", false, false, false)
serverConfig := service.TestServerOpts{
License: &fleet.LicenseInfo{
Tier: fleet.TierFree,
},
FleetConfig: &fleetCfg,
MDMStorage: mdmStorage,
DEPStorage: depStorage,
SCEPStorage: scepStorage,
Pool: redisPool,
APNSTopic: "com.apple.mgmt.External.10ac3ce5-4668-4e58-b69a-b2b5ce667589",
}
err = s.DS.InsertMDMConfigAssets(context.Background(), []fleet.MDMConfigAsset{
{Name: fleet.MDMAssetSCEPChallenge, Value: []byte("scepchallenge")},
}, nil)
require.NoError(s.T(), err)
users, server := service.RunServerForTestsWithDS(s.T(), s.DS, &serverConfig)
s.T().Setenv("FLEET_SERVER_ADDRESS", server.URL) // fleetctl always uses this env var in tests
s.Server = server
s.Users = users
s.fleetCfg = fleetCfg
appConf, err = s.DS.AppConfig(context.Background())
require.NoError(s.T(), err)
appConf.ServerSettings.ServerURL = server.URL
// Disable gitops exceptions so that existing tests can freely use labels, secrets, etc. in their YAML.
appConf.GitOpsConfig.Exceptions = fleet.GitOpsExceptions{}
err = s.DS.SaveAppConfig(context.Background(), appConf)
require.NoError(s.T(), err)
}
func (s *integrationGitopsTestSuite) TearDownSuite() {
appConf, err := s.DS.AppConfig(context.Background())
require.NoError(s.T(), err)
appConf.MDM.EnabledAndConfigured = false
err = s.DS.SaveAppConfig(context.Background(), appConf)
require.NoError(s.T(), err)
}
// TestFleetGitops runs `fleetctl gitops` command on configs in https://github.com/fleetdm/fleet-gitops repo.
// Changes to that repo may cause this test to fail.
func (s *integrationGitopsTestSuite) TestFleetGitops() {
t := s.T()
const fleetGitopsRepo = "https://github.com/fleetdm/fleet-gitops"
fleetctlConfig := s.createFleetctlConfig()
// Clone git repo
repoDir := t.TempDir()
_, err := git.PlainClone(
repoDir, false, &git.CloneOptions{
ReferenceName: "main",
SingleBranch: true,
Depth: 1,
URL: fleetGitopsRepo,
Progress: os.Stdout,
},
)
require.NoError(t, err)
// Set the required environment variables
t.Setenv("FLEET_URL", s.Server.URL)
t.Setenv("FLEET_GLOBAL_ENROLL_SECRET", "global_enroll_secret")
globalFile := path.Join(repoDir, "default.yml")
// Dry run
_ = fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile, "--dry-run"})
// Real run
_ = fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile})
}
func (s *integrationGitopsTestSuite) createFleetctlConfig() *os.File {
t := s.T()
// Create a temporary fleetctl config file
fleetctlConfig, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
// GitOps user is a premium feature, so we simply use an admin user.
token := s.GetTestToken("admin1@example.com", test.GoodPassword)
configStr := fmt.Sprintf(
`
contexts:
default:
address: %s
tls-skip-verify: true
token: %s
`, s.Server.URL, token,
)
_, err = fleetctlConfig.WriteString(configStr)
require.NoError(t, err)
return fleetctlConfig
}
func (s *integrationGitopsTestSuite) TestFleetGitopsWithFleetSecrets() {
t := s.T()
const (
secretName1 = "NAME"
secretName2 = "LENGTH"
)
ctx := context.Background()
fleetctlConfig := s.createFleetctlConfig()
// Set the required environment variables
t.Setenv("FLEET_URL", s.Server.URL)
t.Setenv("FLEET_GLOBAL_ENROLL_SECRET", "global_enroll_secret")
t.Setenv("FLEET_SECRET_"+secretName1, "secret_value")
t.Setenv("FLEET_SECRET_"+secretName2, "2")
globalFile := path.Join("..", "..", "fleetctl", "testdata", "gitops", "global_integration.yml")
// Dry run
_ = fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile, "--dry-run"})
secrets, err := s.DS.GetSecretVariables(ctx, []string{secretName1})
require.NoError(t, err)
require.Empty(t, secrets)
// Real run
_ = fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile})
// Check secrets
secrets, err = s.DS.GetSecretVariables(ctx, []string{secretName1, secretName2})
require.NoError(t, err)
require.Len(t, secrets, 2)
for _, secret := range secrets {
switch secret.Name {
case secretName1:
assert.Equal(t, "secret_value", secret.Value)
case secretName2:
assert.Equal(t, "2", secret.Value)
default:
t.Fatalf("unexpected secret %s", secret.Name)
}
}
// Check script(s)
scriptID, err := s.DS.GetScriptIDByName(ctx, "fleet-secret.sh", nil)
require.NoError(t, err)
expected, err := os.ReadFile("../../fleetctl/testdata/gitops/lib/fleet-secret.sh")
require.NoError(t, err)
script, err := s.DS.GetScriptContents(ctx, scriptID)
require.NoError(t, err)
assert.Equal(t, expected, script)
// Check Apple profiles
profiles, err := s.DS.ListMDMAppleConfigProfiles(ctx, nil)
require.NoError(t, err)
require.Len(t, profiles, 1)
assert.Contains(t, string(profiles[0].Mobileconfig), "$FLEET_SECRET_"+secretName1)
// Check Windows profiles
allProfiles, _, err := s.DS.ListMDMConfigProfiles(ctx, nil, fleet.ListOptions{})
require.NoError(t, err)
require.Len(t, allProfiles, 2)
var windowsProfileUUID string
for _, profile := range allProfiles {
if profile.Platform == "windows" {
windowsProfileUUID = profile.ProfileUUID
}
}
require.NotEmpty(t, windowsProfileUUID)
winProfile, err := s.DS.GetMDMWindowsConfigProfile(ctx, windowsProfileUUID)
require.NoError(t, err)
assert.Contains(t, string(winProfile.SyncML), "${FLEET_SECRET_"+secretName2+"}")
}