Files
fleet/cmd/fleetctl/integrationtest/gitops/gitops_enterprise_integration_test.go
T
Jonathan Katz 45abf8c9ad Add software installer upload/download progress to GitOps runs (#50250)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #45728
Changes:
- Adds a new redis key to keep track of downloaded packages. It starts
out with an empty list and gets filled with each download. Each update
writes the entire struct at once to the key.
- Adds logging in the fleetctl gitops client to show which packages were
downloaded
- Fixes the categories key potentially expiring 

# 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.

- [ ] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented (using placeholders for values in statements), JS
inline code is prevented especially for url redirects, and untrusted
data interpolated into shell scripts/commands is validated against shell
metacharacters.
-  Timeouts are implemented and retries are limited to avoid infinite
loops
- Right now the batch will write the whole slice of all packages to a
single redis key for every package in the loop. Looks like performance
is acceptable for now (500 packages), but maybe this will need to be
limited.
- [ ] If paths of existing endpoints are modified without backwards
compatibility, checked the frontend/CLI for any necessary changes

## Testing

- [x] Added/updated automated tests
- [ ] Where appropriate, [automated tests simulate multiple hosts and
test for host
isolation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/reference/patterns-backend.md#unit-testing)
(updates to one hosts's records do not affect another)

- [x] QA'd all new/changed functionality manually



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

## New Features
- Added per-package software download progress in fleetctl GitOps.
- Progress now reports downloading, completed, skipped, and failed
packages during real and dry runs.
- Installation output now distinguishes applying and applied stages.

## Bug Fixes
- Improved download error messages and cached-package handling.
- Prevented duplicate progress messages and ensured tracking issues do
not interrupt successful software batches.

## Tests
- Expanded coverage for progress reporting, failures, dry runs, package
types, and authorization scenarios.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-31 21:36:44 -04:00

5976 lines
197 KiB
Go

package gitops
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"maps"
"net/http"
"net/http/httptest"
"os"
"path"
"path/filepath"
"regexp"
"runtime"
"strings"
"sync"
"testing"
"text/template"
activity_api "github.com/fleetdm/fleet/v4/server/activity/api"
"github.com/fleetdm/fleet/v4/cmd/fleetctl/fleetctl/fleetctltest"
"github.com/fleetdm/fleet/v4/cmd/fleetctl/fleetctl/testing_utils"
"github.com/fleetdm/fleet/v4/cmd/fleetctl/integrationtest"
ma "github.com/fleetdm/fleet/v4/ee/maintained-apps"
"github.com/fleetdm/fleet/v4/ee/server/service/digicert"
"github.com/fleetdm/fleet/v4/ee/server/service/scep"
"github.com/fleetdm/fleet/v4/server/config"
"github.com/fleetdm/fleet/v4/server/datastore/filesystem"
"github.com/fleetdm/fleet/v4/server/datastore/mysql"
"github.com/fleetdm/fleet/v4/server/datastore/mysql/mysqltest"
"github.com/fleetdm/fleet/v4/server/datastore/redis/redistest"
"github.com/fleetdm/fleet/v4/server/dev_mode"
"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"
mock_pkg "github.com/fleetdm/fleet/v4/server/mock"
"github.com/fleetdm/fleet/v4/server/platform/logging"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/fleetdm/fleet/v4/server/service"
"github.com/fleetdm/fleet/v4/server/service/integrationtest/scep_server"
"github.com/fleetdm/fleet/v4/server/service/svctest"
"github.com/fleetdm/fleet/v4/server/test"
"github.com/go-git/go-git/v5"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
)
const fleetGitopsRepo = "https://github.com/fleetdm/fleet-gitops"
func TestIntegrationsEnterpriseGitops(t *testing.T) {
testingSuite := new(enterpriseIntegrationGitopsTestSuite)
testingSuite.WithServer.Suite = &testingSuite.Suite
suite.Run(t, testingSuite)
}
type enterpriseIntegrationGitopsTestSuite struct {
suite.Suite
integrationtest.WithServer
fleetCfg config.FleetConfig
softwareTitleIconStore fleet.SoftwareTitleIconStore
iconDir string
activityMock *mock_pkg.MockActivityService
}
func (s *enterpriseIntegrationGitopsTestSuite) SetupSuite() {
s.WithDS.SetupSuite("enterpriseIntegrationGitopsTestSuite")
appConf, err := s.DS.AppConfig(context.Background())
require.NoError(s.T(), err)
appConf.MDM.EnabledAndConfigured = true
appConf.MDM.AppleBMEnabledAndConfigured = 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
err = s.DS.InsertMDMConfigAssets(context.Background(), []fleet.MDMConfigAsset{
{Name: fleet.MDMAssetAPNSCert, Value: testCertPEM},
{Name: fleet.MDMAssetAPNSKey, Value: testKeyPEM},
{Name: fleet.MDMAssetCACert, Value: testCertPEM},
{Name: fleet.MDMAssetCAKey, Value: testKeyPEM},
}, nil)
require.NoError(s.T(), err)
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)
// Create a software title icon store
iconDir := s.T().TempDir()
softwareTitleIconStore, err := filesystem.NewSoftwareTitleIconStore(iconDir)
require.NoError(s.T(), err)
s.softwareTitleIconStore = softwareTitleIconStore
s.iconDir = iconDir
serverConfig := service.TestServerOpts{
License: &fleet.LicenseInfo{
Tier: fleet.TierPremium,
},
FleetConfig: &fleetCfg,
MDMStorage: mdmStorage,
DEPStorage: depStorage,
SCEPStorage: scepStorage,
Pool: redisPool,
APNSTopic: "com.apple.mgmt.External.10ac3ce5-4668-4e58-b69a-b2b5ce667589",
SCEPConfigService: scep.NewSCEPConfigService(slog.New(slog.NewTextHandler(os.Stdout, nil)), nil),
DigiCertService: digicert.NewService(),
SoftwareTitleIconStore: softwareTitleIconStore,
}
err = s.DS.InsertMDMConfigAssets(context.Background(), []fleet.MDMConfigAsset{
{Name: fleet.MDMAssetSCEPChallenge, Value: []byte("scepchallenge")},
}, nil)
require.NoError(s.T(), err)
if os.Getenv("FLEET_INTEGRATION_TESTS_DISABLE_LOG") != "" {
serverConfig.Logger = slog.New(slog.DiscardHandler)
}
users, server := svctest.RunServerForTestsWithDS(s.T(), s.DS, &serverConfig)
s.activityMock = serverConfig.ActivityMock
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.
// Tests that specifically test exception enforcement should re-enable them.
appConf.GitOpsConfig.Exceptions = fleet.GitOpsExceptions{}
err = s.DS.SaveAppConfig(context.Background(), appConf)
require.NoError(s.T(), err)
}
func (s *enterpriseIntegrationGitopsTestSuite) 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)
}
func (s *enterpriseIntegrationGitopsTestSuite) TearDownTest() {
t := s.T()
ctx := context.Background()
mysqltest.ExecAdhocSQL(t, s.DS, func(q sqlx.ExtContext) error {
// Delete certificate templates before CAs and teams to avoid FK constraints.
if _, err := q.ExecContext(ctx, `DELETE FROM certificate_templates`); err != nil {
return err
}
_, err := q.ExecContext(ctx, `DELETE FROM certificate_authorities`)
return err
})
teams, err := s.DS.ListTeams(ctx, fleet.TeamFilter{User: test.UserAdmin}, fleet.ListOptions{})
require.NoError(t, err)
for _, tm := range teams {
err := s.DS.DeleteTeam(ctx, tm.ID)
require.NoError(t, err)
}
// Delete policies in "Unassigned" (the others are deleted in ts.DS.DeleteTeam above).
mysqltest.ExecAdhocSQL(t, s.DS, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx, `DELETE FROM policies WHERE team_id = 0;`)
return err
})
// Clean software installers in "Unassigned" (the others are deleted in ts.DS.DeleteTeam above).
mysqltest.ExecAdhocSQL(t, s.DS, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx, `DELETE FROM software_installers WHERE global_or_team_id = 0;`)
return err
})
vppTokens, err := s.DS.ListVPPTokens(ctx)
require.NoError(t, err)
for _, tok := range vppTokens {
err := s.DS.DeleteVPPToken(ctx, tok.ID)
require.NoError(t, err)
}
mysqltest.ExecAdhocSQL(t, s.DS, func(tx sqlx.ExtContext) error {
_, err := tx.ExecContext(ctx, "DELETE FROM vpp_apps;")
return err
})
mysqltest.ExecAdhocSQL(t, s.DS, func(tx sqlx.ExtContext) error {
_, err := tx.ExecContext(ctx, "DELETE FROM in_house_apps;")
return err
})
lbls, err := s.DS.ListLabels(ctx, fleet.TeamFilter{User: test.UserAdmin}, fleet.ListOptions{}, false)
require.NoError(t, err)
for _, lbl := range lbls {
if lbl.LabelType != fleet.LabelTypeBuiltIn {
err := s.DS.DeleteLabel(ctx, lbl.Name, fleet.TeamFilter{User: test.UserAdmin})
require.NoError(t, err)
}
}
}
func (s *enterpriseIntegrationGitopsTestSuite) assertDryRunOutput(t *testing.T, output string) {
s.assertDryRunOutputWithDeprecation(t, output, false)
}
func (s *enterpriseIntegrationGitopsTestSuite) assertDryRunOutputWithDeprecation(t *testing.T, output string, expectDeprecation bool) {
var sawDeprecation bool
allowedVerbs := []string{
"moved",
"deleted",
"updated",
"applied",
"added",
"created",
"set",
}
// A dry run downloads software packages in full, so it reports each download as it
// happens. Those lines say what it did, not what it would do.
pattern := fmt.Sprintf("\\[([+\\-!])] (would've (%s)|downloading|downloaded|skipped)", strings.Join(allowedVerbs, "|"))
reg := regexp.MustCompile(pattern)
for line := range strings.SplitSeq(output, "\n") {
if expectDeprecation && line != "" && strings.Contains(line, "is deprecated") {
sawDeprecation = true
continue
}
if line != "" && !strings.Contains(line, "succeeded") {
assert.Regexp(t, reg, line, "on dry run")
}
}
if expectDeprecation {
assert.True(t, sawDeprecation, "expected to see deprecation warning in dry run output")
}
}
func (s *enterpriseIntegrationGitopsTestSuite) assertRealRunOutput(t *testing.T, output string) {
s.assertRealRunOutputWithDeprecation(t, output, false)
}
func (s *enterpriseIntegrationGitopsTestSuite) assertRealRunOutputWithDeprecation(t *testing.T, output string, allowDeprecation bool) {
allowedVerbs := []string{
"moving",
"deleted",
"updated",
"applied",
"added",
"created",
"set",
"applying", // this is used when doing groups operations before the operation starts, e.g. "Applying 10 policies"
"deleting", // ditto
"downloading", // software packages report each download as it starts
"downloaded", // ditto, as it finishes
"skipped", // ditto, for a package already in storage
}
pattern := fmt.Sprintf("\\[([+\\-!])] (%s)", strings.Join(allowedVerbs, "|"))
reg := regexp.MustCompile(pattern)
for line := range strings.SplitSeq(output, "\n") {
if allowDeprecation && line != "" && strings.Contains(line, "is deprecated") {
continue
}
if line != "" && !strings.Contains(line, "succeeded") {
assert.Regexp(t, reg, line, "on real run")
}
}
}
// 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 *enterpriseIntegrationGitopsTestSuite) TestFleetGitops() {
os.Setenv("FLEET_ENABLE_LOG_TOPICS", logging.DeprecatedFieldTopic)
defer os.Unsetenv("FLEET_ENABLE_LOG_TOPICS")
t := s.T()
user := s.createGitOpsUser(t)
fleetctlConfig := s.createFleetctlConfig(t, user)
// 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")
t.Setenv("FLEET_WORKSTATIONS_ENROLL_SECRET", "workstations_enroll_secret")
t.Setenv("FLEET_PERSONAL_MOBILE_DEVICES_ENROLL_SECRET", "personal_mobile_devices_enroll_secret")
t.Setenv("FLEET_DEDICATED_DEVICES_ENROLL_SECRET", "dedicated_devices_enroll_secret")
t.Setenv("FLEET_EMPLOYEE_ISSUED_MOBILE_DEVICES_ENROLL_SECRET", "employee_issued_mobile_devices_enroll_secret")
t.Setenv("FLEET_IT_SERVERS_ENROLL_SECRET", "it_servers_enroll_secret")
globalFile := path.Join(repoDir, "default.yml")
teamsDir := path.Join(repoDir, "teams")
teamFiles, err := os.ReadDir(teamsDir)
require.NoError(t, err)
teamFileNames := make([]string, 0, len(teamFiles))
for _, file := range teamFiles {
if filepath.Ext(file.Name()) == ".yml" {
teamFileNames = append(teamFileNames, path.Join(teamsDir, file.Name()))
}
}
// Create a team to be deleted.
deletedTeamFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
const deletedTeamName = "team_to_be_deleted"
_, err = deletedTeamFile.WriteString(
fmt.Sprintf(
`
controls:
software:
reports:
policies:
agent_options:
name: %s
settings:
secrets: [{"secret":"deleted_team_secret"}]
`, deletedTeamName,
),
)
require.NoError(t, err)
test.CreateInsertGlobalVPPToken(t, s.DS)
// Apply the team to be deleted
s.assertRealRunOutputWithDeprecation(t, fleetctltest.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", deletedTeamFile.Name()}), true)
// Dry run
// NOTE: The fleet-gitops repo may still use deprecated keys (queries, team_settings),
// so we allow deprecation warnings in this test.
s.assertDryRunOutputWithDeprecation(t, fleetctltest.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile, "--dry-run"}), true)
for _, fileName := range teamFileNames {
// When running no-teams, global config must also be provided ...
if strings.Contains(fileName, "unassigned.yml") {
s.assertDryRunOutputWithDeprecation(t, fleetctltest.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", fileName, "-f", globalFile, "--dry-run"}), true)
} else {
s.assertDryRunOutputWithDeprecation(t, fleetctltest.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", fileName, "--dry-run"}), true)
}
}
// Dry run with all the files
args := []string{"gitops", "--config", fleetctlConfig.Name(), "--dry-run", "--delete-other-teams", "-f", globalFile}
for _, fileName := range teamFileNames {
args = append(args, "-f", fileName)
}
s.assertDryRunOutputWithDeprecation(t, fleetctltest.RunAppForTest(t, args), true)
// Real run with all the files, but don't delete other teams
args = []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile}
for _, fileName := range teamFileNames {
args = append(args, "-f", fileName)
}
s.assertRealRunOutputWithDeprecation(t, fleetctltest.RunAppForTest(t, args), true)
// Check that all the teams exist
teamsJSON := fleetctltest.RunAppForTest(t, []string{"get", "teams", "--config", fleetctlConfig.Name(), "--json"})
assert.Equal(t, 3, strings.Count(teamsJSON, "fleet_id"))
// Real run with all the files, and delete other teams
args = []string{"gitops", "--config", fleetctlConfig.Name(), "--delete-other-teams", "-f", globalFile}
for _, fileName := range teamFileNames {
args = append(args, "-f", fileName)
}
s.assertRealRunOutputWithDeprecation(t, fleetctltest.RunAppForTest(t, args), true)
// Check that only the right teams exist
teamsJSON = fleetctltest.RunAppForTest(t, []string{"get", "teams", "--config", fleetctlConfig.Name(), "--json"})
assert.Equal(t, 2, strings.Count(teamsJSON, "fleet_id"))
assert.NotContains(t, teamsJSON, deletedTeamName)
// Real run with one file at a time
s.assertRealRunOutputWithDeprecation(t, fleetctltest.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile}), true)
for _, fileName := range teamFileNames {
// When running no-teams, global config must also be provided ...
if strings.Contains(fileName, "unassigned.yml") {
s.assertRealRunOutputWithDeprecation(t, fleetctltest.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", fileName, "-f", globalFile}), true)
} else {
s.assertRealRunOutputWithDeprecation(t, fleetctltest.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", fileName}), true)
}
}
}
func (s *enterpriseIntegrationGitopsTestSuite) createFleetctlConfig(t *testing.T, user fleet.User) *os.File {
fleetctlConfig, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
token := s.GetTestToken(user.Email, 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 *enterpriseIntegrationGitopsTestSuite) createGitOpsUser(t *testing.T) fleet.User {
user := fleet.User{
Name: "GitOps User " + uuid.NewString(),
Email: uuid.NewString() + "@example.com",
GlobalRole: ptr.String(fleet.RoleGitOps),
}
require.NoError(t, user.SetPassword(test.GoodPassword, 10, 10))
_, err := s.DS.NewUser(context.Background(), &user)
require.NoError(t, err)
return user
}
// TestDeleteMacOSSetup tests the deletion of macOS setup assets by `fleetctl gitops` command.
func (s *enterpriseIntegrationGitopsTestSuite) TestDeleteMacOSSetup() {
t := s.T()
user := s.createGitOpsUser(t)
fleetctlConfig := s.createFleetctlConfig(t, user)
globalFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = globalFile.WriteString(`
agent_options:
controls:
org_settings:
server_settings:
server_url: $FLEET_URL
org_info:
org_name: Fleet
secrets:
policies:
reports:
`)
require.NoError(t, err)
teamName := uuid.NewString()
teamFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = teamFile.WriteString(
fmt.Sprintf(
`
controls:
software:
reports:
policies:
agent_options:
name: %s
settings:
secrets: [{"secret":"enroll_secret"}]
`, teamName,
),
)
require.NoError(t, err)
// Set the required environment variables
t.Setenv("FLEET_URL", s.Server.URL)
// Apply configs
s.assertDryRunOutput(t, fleetctltest.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", teamFile.Name(), "--dry-run"}))
s.assertRealRunOutput(t, fleetctltest.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", teamFile.Name()}))
// Add bootstrap packages
require.NoError(t, s.DS.InsertMDMAppleBootstrapPackage(context.Background(), &fleet.MDMAppleBootstrapPackage{
Name: "bootstrap.pkg",
TeamID: 0,
Bytes: []byte("bootstrap package"),
Token: uuid.NewString(),
Sha256: []byte("sha256"),
}, nil))
team, err := s.DS.TeamByName(context.Background(), teamName)
require.NoError(t, err)
t.Cleanup(func() {
_ = s.DS.DeleteTeam(context.Background(), team.ID)
})
require.NoError(t, s.DS.InsertMDMAppleBootstrapPackage(context.Background(), &fleet.MDMAppleBootstrapPackage{
Name: "bootstrap.pkg",
TeamID: team.ID,
Bytes: []byte("bootstrap package"),
Token: uuid.NewString(),
Sha256: []byte("sha256"),
}, nil))
mysqltest.ExecAdhocSQL(t, s.DS, func(q sqlx.ExtContext) error {
stmt := "SELECT COUNT(*) FROM mdm_apple_bootstrap_packages WHERE team_id IN (?, ?)"
var result int
require.NoError(t, sqlx.GetContext(context.Background(), q, &result, stmt, 0, team.ID))
assert.Equal(t, 2, result)
return nil
})
// Add enrollment profiles
_, err = s.DS.SetOrUpdateMDMAppleSetupAssistant(context.Background(), &fleet.MDMAppleSetupAssistant{
TeamID: nil,
Name: "enrollment_profile.json",
Profile: []byte(`{"foo":"bar"}`),
})
require.NoError(t, err)
_, err = s.DS.SetOrUpdateMDMAppleSetupAssistant(context.Background(), &fleet.MDMAppleSetupAssistant{
TeamID: &team.ID,
Name: "enrollment_profile.json",
Profile: []byte(`{"foo":"bar"}`),
})
require.NoError(t, err)
mysqltest.ExecAdhocSQL(t, s.DS, func(q sqlx.ExtContext) error {
stmt := "SELECT COUNT(*) FROM mdm_apple_setup_assistants WHERE global_or_team_id IN (?, ?)"
var result int
require.NoError(t, sqlx.GetContext(context.Background(), q, &result, stmt, 0, team.ID))
assert.Equal(t, 2, result)
return nil
})
// Re-apply configs and expect the macOS setup assets to be cleared
s.assertDryRunOutput(t, fleetctltest.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", teamFile.Name(), "--dry-run"}))
s.assertRealRunOutput(t, fleetctltest.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", teamFile.Name()}))
mysqltest.ExecAdhocSQL(t, s.DS, func(q sqlx.ExtContext) error {
stmt := "SELECT COUNT(*) FROM mdm_apple_bootstrap_packages WHERE team_id IN (?, ?)"
var result int
require.NoError(t, sqlx.GetContext(context.Background(), q, &result, stmt, 0, team.ID))
assert.Equal(t, 0, result)
return nil
})
mysqltest.ExecAdhocSQL(t, s.DS, func(q sqlx.ExtContext) error {
stmt := "SELECT COUNT(*) FROM mdm_apple_setup_assistants WHERE global_or_team_id IN (?, ?)"
var result int
require.NoError(t, sqlx.GetContext(context.Background(), q, &result, stmt, 0, team.ID))
assert.Equal(t, 0, result)
return nil
})
}
// TestCAIntegrations enables DigiCert and Custom SCEP CAs via GitOps.
// At the same time, GitOps uploads Apple profiles that use the newly configured CAs.
func (s *enterpriseIntegrationGitopsTestSuite) TestCAIntegrations() {
t := s.T()
user := s.createGitOpsUser(t)
fleetctlConfig := s.createFleetctlConfig(t, user)
var (
gotProfileMu sync.Mutex
gotProfile bool
)
digiCertServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.Method {
case http.MethodGet:
matches := regexp.MustCompile(`^/mpki/api/v2/profile/([a-zA-Z0-9_-]+)$`).FindStringSubmatch(r.URL.Path)
if len(matches) != 2 {
w.WriteHeader(http.StatusBadRequest)
return
}
profileID := matches[1]
resp := map[string]string{
"id": profileID,
"name": "DigiCert",
"status": "Active",
}
err := json.NewEncoder(w).Encode(resp)
require.NoError(t, err)
gotProfileMu.Lock()
gotProfile = profileID == "digicert_profile_id"
defer gotProfileMu.Unlock()
default:
w.WriteHeader(http.StatusMethodNotAllowed)
}
}))
t.Cleanup(digiCertServer.Close)
scepServer := scep_server.StartTestSCEPServer(t)
// Get the path to the directory of this test file
_, currentFile, _, ok := runtime.Caller(0)
require.True(t, ok, "failed to get runtime caller info")
dirPath := filepath.Dir(currentFile)
// Resolve ../../fleetctl relative to the source file directory
dirPath = filepath.Join(dirPath, "../../fleetctl")
// Clean and convert to absolute path
dirPath, err := filepath.Abs(filepath.Clean(dirPath))
require.NoError(t, err)
apiToken := "digicert_api_token" // nolint:gosec // G101: Potential hardcoded credentials
profileID := "digicert_profile_id"
certCN := "digicert_cn"
certSeatID := "digicert_seat_id"
_, err = s.DS.NewCertificateAuthority(t.Context(), &fleet.CertificateAuthority{
Type: string(fleet.CATypeDigiCert),
Name: ptr.String("DigiCert"),
URL: &digiCertServer.URL,
APIToken: &apiToken,
ProfileID: &profileID,
CertificateCommonName: &certCN,
CertificateUserPrincipalNames: &[]string{"digicert_upn"},
CertificateSeatID: &certSeatID,
})
require.NoError(t, err)
challenge := "challenge"
_, err = s.DS.NewCertificateAuthority(t.Context(), &fleet.CertificateAuthority{
Type: string(fleet.CATypeCustomSCEPProxy),
Name: ptr.String("CustomScepProxy"),
URL: &scepServer.URL,
Challenge: &challenge,
})
require.NoError(t, err)
globalFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = globalFile.WriteString(fmt.Sprintf(`
agent_options:
controls:
apple_settings:
configuration_profiles:
- path: %s/testdata/gitops/lib/scep-and-digicert.mobileconfig
org_settings:
server_settings:
server_url: $FLEET_URL
org_info:
org_name: Fleet
secrets:
certificate_authorities:
digicert:
- name: DigiCert
url: %s
api_token: digicert_api_token
profile_id: digicert_profile_id
certificate_common_name: digicert_cn
certificate_user_principal_names: ["digicert_upn"]
certificate_seat_id: digicert_seat_id
custom_scep_proxy:
- name: CustomScepProxy
url: %s
challenge: challenge
policies:
reports:
`,
dirPath,
digiCertServer.URL,
scepServer.URL+"/scep",
))
require.NoError(t, err)
// Set the required environment variables
t.Setenv("FLEET_URL", s.Server.URL)
// Apply configs
s.assertDryRunOutput(t, fleetctltest.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "--dry-run"}))
s.assertRealRunOutput(t, fleetctltest.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name()}))
groupedCAs, err := s.DS.GetGroupedCertificateAuthorities(t.Context(), false)
require.NoError(t, err)
// check digicert
require.Len(t, groupedCAs.DigiCert, 1)
digicertCA := groupedCAs.DigiCert[0]
require.Equal(t, "DigiCert", digicertCA.Name)
require.Equal(t, digiCertServer.URL, digicertCA.URL)
require.Equal(t, fleet.MaskedPassword, digicertCA.APIToken)
require.Equal(t, "digicert_profile_id", digicertCA.ProfileID)
require.Equal(t, "digicert_cn", digicertCA.CertificateCommonName)
require.Equal(t, []string{"digicert_upn"}, digicertCA.CertificateUserPrincipalNames)
require.Equal(t, "digicert_seat_id", digicertCA.CertificateSeatID)
gotProfileMu.Lock()
require.False(t, gotProfile) // external digicert service was NOT called because stored config was not modified
gotProfileMu.Unlock()
// check custom SCEP proxy
require.Len(t, groupedCAs.CustomScepProxy, 1)
customSCEPProxyCA := groupedCAs.CustomScepProxy[0]
require.Equal(t, "CustomScepProxy", customSCEPProxyCA.Name)
require.Equal(t, scepServer.URL+"/scep", customSCEPProxyCA.URL)
require.Equal(t, fleet.MaskedPassword, customSCEPProxyCA.Challenge)
profiles, _, err := s.DS.ListMDMConfigProfiles(context.Background(), nil, fleet.ListOptions{})
require.NoError(t, err)
assert.Len(t, profiles, 1)
// now modify the stored config and confirm that external digicert service is called
_, err = globalFile.WriteString(fmt.Sprintf(`
agent_options:
controls:
apple_settings:
configuration_profiles:
- path: %s/testdata/gitops/lib/scep-and-digicert.mobileconfig
org_settings:
server_settings:
server_url: $FLEET_URL
org_info:
org_name: Fleet
secrets:
certificate_authorities:
digicert:
- name: DigiCert
url: %s
api_token: digicert_api_token
profile_id: digicert_profile_id
certificate_common_name: digicert_cn
certificate_user_principal_names: [%q]
certificate_seat_id: digicert_seat_id
custom_scep_proxy:
- name: CustomScepProxy
url: %s
challenge: challenge
policies:
reports:
`,
dirPath,
digiCertServer.URL,
"digicert_upn_2", // minor modification to stored config so gitops run is not a no-op and triggers call to external digicert service
scepServer.URL+"/scep",
))
require.NoError(t, err)
// Apply configs
s.assertDryRunOutput(t, fleetctltest.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "--dry-run"}))
s.assertRealRunOutput(t, fleetctltest.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name()}))
groupedCAs, err = s.DS.GetGroupedCertificateAuthorities(t.Context(), false)
require.NoError(t, err)
// check digicert
require.Len(t, groupedCAs.DigiCert, 1)
digicertCA = groupedCAs.DigiCert[0]
require.Equal(t, "DigiCert", digicertCA.Name)
require.Equal(t, digiCertServer.URL, digicertCA.URL)
require.Equal(t, fleet.MaskedPassword, digicertCA.APIToken)
require.Equal(t, "digicert_profile_id", digicertCA.ProfileID)
require.Equal(t, "digicert_cn", digicertCA.CertificateCommonName)
require.Equal(t, []string{"digicert_upn_2"}, digicertCA.CertificateUserPrincipalNames)
require.Equal(t, "digicert_seat_id", digicertCA.CertificateSeatID)
gotProfileMu.Lock()
require.True(t, gotProfile) // external digicert service was called because stored config was modified
gotProfileMu.Unlock()
// Now test that we can clear the configs
_, err = globalFile.WriteString(`
agent_options:
controls:
apple_settings:
configuration_profiles:
org_settings:
server_settings:
server_url: $FLEET_URL
org_info:
org_name: Fleet
secrets:
policies:
reports:
`)
require.NoError(t, err)
s.assertDryRunOutput(t, fleetctltest.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "--dry-run"}))
s.assertRealRunOutput(t, fleetctltest.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name()}))
groupedCAs, err = s.DS.GetGroupedCertificateAuthorities(t.Context(), true)
require.NoError(t, err)
assert.Empty(t, groupedCAs.DigiCert)
assert.Empty(t, groupedCAs.CustomScepProxy)
}
// TestDeleteCAWithCertificateTemplates tests the GitOps ordering when deleting a certificate
// authority that is referenced by certificate templates on a team.
func (s *enterpriseIntegrationGitopsTestSuite) TestDeleteCAWithCertificateTemplates() {
t := s.T()
user := s.createGitOpsUser(t)
fleetctlConfig := s.createFleetctlConfig(t, user)
scepServer := scep_server.StartTestSCEPServer(t)
t.Setenv("FLEET_URL", s.Server.URL)
// Step 1: Create a CA and a team with a certificate template referencing it via GitOps.
globalFileWithCA := s.writeConfigFile(t, fmt.Sprintf(`
agent_options:
controls:
org_settings:
server_settings:
server_url: $FLEET_URL
org_info:
org_name: Fleet
secrets:
certificate_authorities:
custom_scep_proxy:
- name: TestSCEP
url: %s
challenge: challenge
policies:
reports:
`, scepServer.URL+"/scep"))
teamFileWithCert := s.writeConfigFile(t, `
name: CA Test Team
controls:
android_settings:
certificates:
- name: TestCert
certificate_authority_name: TestSCEP
subject_name: "CN=test,O=Fleet"
settings:
secrets:
- secret: ca_test_team_secret
agent_options:
policies:
reports:
software:
`)
// Apply both files to create the CA, team, and certificate template.
fleetctltest.RunAppForTest(t, []string{
"gitops", "--config", fleetctlConfig.Name(),
"-f", globalFileWithCA, "-f", teamFileWithCert,
})
// Verify the CA was created via GitOps.
groupedCAs, err := s.DS.GetGroupedCertificateAuthorities(t.Context(), false)
require.NoError(t, err)
require.Len(t, groupedCAs.CustomScepProxy, 1)
require.Equal(t, "TestSCEP", groupedCAs.CustomScepProxy[0].Name)
// Verify the team was created and the certificate template exists.
teams, err := s.DS.ListTeams(t.Context(), fleet.TeamFilter{User: test.UserAdmin}, fleet.ListOptions{})
require.NoError(t, err)
var teamID uint
for _, tm := range teams {
if tm.Name == "CA Test Team" {
teamID = tm.ID
break
}
}
require.NotZero(t, teamID, "team 'CA Test Team' should exist")
certTemplates, _, err := s.DS.GetCertificateTemplatesByTeamID(t.Context(), teamID, fleet.ListOptions{})
require.NoError(t, err)
require.Len(t, certTemplates, 1)
require.Equal(t, "TestCert", certTemplates[0].Name)
// Step 2: Run gitops removing the CA but WITHOUT the team file.
// This should fail because the team's certificate templates still reference the CA.
globalFileWithoutCA := s.writeConfigFile(t, `
agent_options:
controls:
org_settings:
server_settings:
server_url: $FLEET_URL
org_info:
org_name: Fleet
secrets:
policies:
reports:
`)
_, err = fleetctltest.RunAppNoChecks([]string{
"gitops", "--config", fleetctlConfig.Name(),
"-f", globalFileWithoutCA,
})
require.Error(t, err)
require.Contains(t, err.Error(), fleet.DeleteCAReferencedByTemplatesErrMsg)
// Verify CA and certificate template still exist.
groupedCAs, err = s.DS.GetGroupedCertificateAuthorities(t.Context(), false)
require.NoError(t, err)
require.Len(t, groupedCAs.CustomScepProxy, 1)
certTemplates, _, err = s.DS.GetCertificateTemplatesByTeamID(t.Context(), teamID, fleet.ListOptions{})
require.NoError(t, err)
require.Len(t, certTemplates, 1)
// Step 3: Run gitops removing BOTH the CA and the certificate template.
// This should succeed because the postOp ordering ensures certificate templates
// are deleted before the CA.
teamFileWithoutCert := s.writeConfigFile(t, `
name: CA Test Team
controls:
settings:
secrets:
- secret: ca_test_team_secret
agent_options:
policies:
reports:
software:
`)
fleetctltest.RunAppForTest(t, []string{
"gitops", "--config", fleetctlConfig.Name(),
"-f", globalFileWithoutCA, "-f", teamFileWithoutCert,
})
// Verify CA and certificate template are deleted.
groupedCAs, err = s.DS.GetGroupedCertificateAuthorities(t.Context(), false)
require.NoError(t, err)
assert.Empty(t, groupedCAs.CustomScepProxy)
certTemplates, _, err = s.DS.GetCertificateTemplatesByTeamID(t.Context(), teamID, fleet.ListOptions{})
require.NoError(t, err)
assert.Empty(t, certTemplates)
}
func (s *enterpriseIntegrationGitopsTestSuite) writeConfigFile(t *testing.T, content string) string {
t.Helper()
f, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = f.WriteString(content)
require.NoError(t, err)
require.NoError(t, f.Close())
return f.Name()
}
// TestUnsetConfigurationProfileLabels tests the removal of labels associated with a
// configuration profile via gitops.
func (s *enterpriseIntegrationGitopsTestSuite) TestUnsetConfigurationProfileLabels() {
t := s.T()
ctx := context.Background()
user := s.createGitOpsUser(t)
fleetctlConfig := s.createFleetctlConfig(t, user)
profileFile, err := os.CreateTemp(t.TempDir(), "*.mobileconfig")
require.NoError(t, err)
_, err = profileFile.WriteString(test.GenerateMDMAppleProfile("test", "test", uuid.NewString()))
require.NoError(t, err)
err = profileFile.Close()
require.NoError(t, err)
const (
globalTemplate = `
agent_options:
labels:
- name: Label1
query: select 1
platform: linux
controls:
apple_settings:
configuration_profiles:
- path: %s
%s
org_settings:
server_settings:
server_url: $FLEET_URL
org_info:
org_name: Fleet
secrets:
policies:
reports:
`
withLabelsIncludeAny = `
labels_include_any:
- Label1
`
emptyLabelsIncludeAny = `
labels_include_any:
`
teamTemplate = `
controls:
apple_settings:
configuration_profiles:
- path: %s
%s
software:
reports:
policies:
agent_options:
name: %s
settings:
secrets: [{"secret":"enroll_secret"}]
`
withLabelsIncludeAll = `
labels_include_all:
- Label1
`
)
globalFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = globalFile.WriteString(fmt.Sprintf(globalTemplate, profileFile.Name(), withLabelsIncludeAny))
require.NoError(t, err)
err = globalFile.Close()
require.NoError(t, err)
teamName := uuid.NewString()
teamFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = teamFile.WriteString(fmt.Sprintf(teamTemplate, profileFile.Name(), withLabelsIncludeAll, teamName))
require.NoError(t, err)
err = teamFile.Close()
require.NoError(t, err)
// Set the required environment variables
t.Setenv("FLEET_URL", s.Server.URL)
// Apply configs
s.assertDryRunOutput(t, fleetctltest.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", teamFile.Name(), "--dry-run"}))
s.assertRealRunOutput(t, fleetctltest.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", teamFile.Name()}))
// get the team ID
team, err := s.DS.TeamByName(ctx, teamName)
require.NoError(t, err)
// the custom setting is scoped by the label for no team
profs, _, err := s.DS.ListMDMConfigProfiles(ctx, nil, fleet.ListOptions{})
require.NoError(t, err)
require.Len(t, profs, 1)
require.Len(t, profs[0].LabelsIncludeAny, 1)
require.Equal(t, "Label1", profs[0].LabelsIncludeAny[0].LabelName)
// the custom setting is scoped by the label for team
profs, _, err = s.DS.ListMDMConfigProfiles(ctx, &team.ID, fleet.ListOptions{})
require.NoError(t, err)
require.Len(t, profs, 1)
require.Len(t, profs[0].LabelsIncludeAll, 1)
require.Equal(t, "Label1", profs[0].LabelsIncludeAll[0].LabelName)
// remove the label conditions
err = os.WriteFile(globalFile.Name(), []byte(fmt.Sprintf(globalTemplate, profileFile.Name(), emptyLabelsIncludeAny)), 0o644)
require.NoError(t, err)
err = os.WriteFile(teamFile.Name(), []byte(fmt.Sprintf(teamTemplate, profileFile.Name(), "", teamName)), 0o644)
require.NoError(t, err)
// Apply configs
s.assertDryRunOutput(t, fleetctltest.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", teamFile.Name(), "--dry-run"}))
s.assertRealRunOutput(t, fleetctltest.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", teamFile.Name()}))
// the custom setting is not scoped by label anymore
profs, _, err = s.DS.ListMDMConfigProfiles(ctx, nil, fleet.ListOptions{})
require.NoError(t, err)
require.Len(t, profs, 1)
require.Len(t, profs[0].LabelsIncludeAny, 0)
profs, _, err = s.DS.ListMDMConfigProfiles(ctx, &team.ID, fleet.ListOptions{})
require.NoError(t, err)
require.Len(t, profs, 1)
require.Len(t, profs[0].LabelsIncludeAll, 0)
}
// TestUnsetSoftwareInstallerLabels tests the removal of labels associated with a
// software installer via gitops.
func (s *enterpriseIntegrationGitopsTestSuite) TestUnsetSoftwareInstallerLabels() {
t := s.T()
ctx := context.Background()
user := s.createGitOpsUser(t)
fleetctlConfig := s.createFleetctlConfig(t, user)
const (
globalTemplate = `
agent_options:
labels:
- name: Label1
query: select 1
controls:
org_settings:
server_settings:
server_url: $FLEET_URL
org_info:
org_name: Fleet
secrets:
policies:
reports:
`
noTeamTemplate = `name: Unassigned
controls:
policies:
software:
packages:
- url: ${SOFTWARE_INSTALLER_URL}/ruby.deb
%s
`
withLabelsIncludeAny = `
labels_include_any:
- Label1
`
emptyLabelsIncludeAny = `
labels_include_any:
`
withLabelsIncludeAll = `
labels_include_all:
- Label1
`
teamTemplate = `
controls:
software:
packages:
- url: ${SOFTWARE_INSTALLER_URL}/ruby.deb
%s
reports:
policies:
agent_options:
name: %s
settings:
secrets: [{"secret":"enroll_secret"}]
`
withLabelsExcludeAny = `
labels_exclude_any:
- Label1
`
)
globalFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = globalFile.WriteString(globalTemplate)
require.NoError(t, err)
err = globalFile.Close()
require.NoError(t, err)
noTeamFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = noTeamFile.WriteString(fmt.Sprintf(noTeamTemplate, withLabelsIncludeAny))
require.NoError(t, err)
err = noTeamFile.Close()
require.NoError(t, err)
noTeamFilePath := filepath.Join(filepath.Dir(noTeamFile.Name()), "unassigned.yml")
err = os.Rename(noTeamFile.Name(), noTeamFilePath)
require.NoError(t, err)
teamName := uuid.NewString()
teamFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = teamFile.WriteString(fmt.Sprintf(teamTemplate, withLabelsExcludeAny, teamName))
require.NoError(t, err)
err = teamFile.Close()
require.NoError(t, err)
// Set the required environment variables
t.Setenv("FLEET_URL", s.Server.URL)
testing_utils.StartSoftwareInstallerServer(t)
// Apply configs
s.assertDryRunOutput(t, fleetctltest.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", noTeamFilePath, "-f", teamFile.Name(), "--dry-run"}))
s.assertRealRunOutput(t, fleetctltest.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", noTeamFilePath, "-f", teamFile.Name()}))
// get the team ID
team, err := s.DS.TeamByName(ctx, teamName)
require.NoError(t, err)
// the installer is scoped by the label for no team
titles, _, _, err := s.DS.ListSoftwareTitles(ctx, fleet.SoftwareTitleListOptions{AvailableForInstall: true, TeamID: ptr.Uint(0)},
fleet.TeamFilter{User: test.UserAdmin})
require.NoError(t, err)
require.Len(t, titles, 1)
require.NotNil(t, titles[0].SoftwarePackage)
noTeamTitleID := titles[0].ID
meta, err := s.DS.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, nil, noTeamTitleID, false)
require.NoError(t, err)
require.Len(t, meta.LabelsIncludeAny, 1)
require.Equal(t, "Label1", meta.LabelsIncludeAny[0].LabelName)
// the installer is scoped by the label for team
titles, _, _, err = s.DS.ListSoftwareTitles(ctx, fleet.SoftwareTitleListOptions{TeamID: &team.ID}, fleet.TeamFilter{User: test.UserAdmin})
require.NoError(t, err)
require.Len(t, titles, 1)
require.NotNil(t, titles[0].SoftwarePackage)
teamTitleID := titles[0].ID
meta, err = s.DS.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, &team.ID, teamTitleID, false)
require.NoError(t, err)
require.Len(t, meta.LabelsExcludeAny, 1)
require.Equal(t, "Label1", meta.LabelsExcludeAny[0].LabelName)
// switch both to labels_include_all
err = os.WriteFile(noTeamFilePath, fmt.Appendf(nil, noTeamTemplate, withLabelsIncludeAll), 0o644)
require.NoError(t, err)
err = os.WriteFile(teamFile.Name(), fmt.Appendf(nil, teamTemplate, withLabelsIncludeAll, teamName), 0o644)
require.NoError(t, err)
// Apply configs
s.assertDryRunOutput(t, fleetctltest.RunAppForTest(t,
[]string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", noTeamFilePath, "-f", teamFile.Name(), "--dry-run"}))
s.assertRealRunOutput(t, fleetctltest.RunAppForTest(t,
[]string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", noTeamFilePath, "-f", teamFile.Name()}))
// the installer is now scoped by labels_include_all for no team
meta, err = s.DS.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, nil, noTeamTitleID, false)
require.NoError(t, err)
require.Empty(t, meta.LabelsIncludeAny)
require.Empty(t, meta.LabelsExcludeAny)
require.Len(t, meta.LabelsIncludeAll, 1)
require.Equal(t, "Label1", meta.LabelsIncludeAll[0].LabelName)
// the installer is now scoped by labels_include_all for team
meta, err = s.DS.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, &team.ID, teamTitleID, false)
require.NoError(t, err)
require.Empty(t, meta.LabelsIncludeAny)
require.Empty(t, meta.LabelsExcludeAny)
require.Len(t, meta.LabelsIncludeAll, 1)
require.Equal(t, "Label1", meta.LabelsIncludeAll[0].LabelName)
// remove the label conditions
err = os.WriteFile(noTeamFilePath, []byte(fmt.Sprintf(noTeamTemplate, emptyLabelsIncludeAny)), 0o644)
require.NoError(t, err)
err = os.WriteFile(teamFile.Name(), []byte(fmt.Sprintf(teamTemplate, "", teamName)), 0o644)
require.NoError(t, err)
// Apply configs
s.assertDryRunOutput(t, fleetctltest.RunAppForTest(t,
[]string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", noTeamFilePath, "-f", teamFile.Name(), "--dry-run"}))
s.assertRealRunOutput(t, fleetctltest.RunAppForTest(t,
[]string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", noTeamFilePath, "-f", teamFile.Name()}))
// the installer is not scoped by label anymore
meta, err = s.DS.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, nil, noTeamTitleID, false)
require.NoError(t, err)
require.NotNil(t, meta.TitleID)
require.Equal(t, noTeamTitleID, *meta.TitleID)
require.Len(t, meta.LabelsExcludeAny, 0)
require.Len(t, meta.LabelsIncludeAny, 0)
require.Len(t, meta.LabelsIncludeAll, 0)
meta, err = s.DS.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, &team.ID, teamTitleID, false)
require.NoError(t, err)
require.NotNil(t, meta.TitleID)
require.Equal(t, teamTitleID, *meta.TitleID)
require.Len(t, meta.LabelsExcludeAny, 0)
require.Len(t, meta.LabelsIncludeAny, 0)
require.Len(t, meta.LabelsIncludeAll, 0)
}
func (s *enterpriseIntegrationGitopsTestSuite) TestDeletingNoTeamYAML() {
t := s.T()
ctx := context.Background()
user := s.createGitOpsUser(t)
fleetctlConfig := s.createFleetctlConfig(t, user)
// Set the required environment variables
t.Setenv("FLEET_URL", s.Server.URL)
// global file setup
const (
globalTemplate = `
agent_options:
controls:
org_settings:
server_settings:
server_url: $FLEET_URL
org_info:
org_name: Fleet
secrets:
policies:
reports:
`
)
globalFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = globalFile.WriteString(globalTemplate)
require.NoError(t, err)
err = globalFile.Close()
require.NoError(t, err)
// setup script
const testScriptTemplate = `echo "Hello, world!"`
scriptFile, err := os.CreateTemp(t.TempDir(), "*.sh")
require.NoError(t, err)
_, err = scriptFile.WriteString(testScriptTemplate)
require.NoError(t, err)
err = scriptFile.Close()
require.NoError(t, err)
// no team file setup
const (
noTeamTemplate = `name: Unassigned
policies:
controls:
setup_experience:
macos_script: %s
software:
`
)
noTeamFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = noTeamFile.WriteString(fmt.Sprintf(noTeamTemplate, scriptFile.Name()))
require.NoError(t, err)
err = noTeamFile.Close()
require.NoError(t, err)
noTeamFilePath := filepath.Join(filepath.Dir(noTeamFile.Name()), "unassigned.yml")
err = os.Rename(noTeamFile.Name(), noTeamFilePath)
require.NoError(t, err)
s.assertDryRunOutput(t, fleetctltest.RunAppForTest(t,
[]string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", noTeamFilePath, "--dry-run"}))
s.assertRealRunOutput(t, fleetctltest.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", noTeamFilePath}))
// Check script existence
_, err = s.DS.GetSetupExperienceScript(ctx, nil)
require.NoError(t, err)
_ = fleetctltest.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "--dry-run"})
_ = fleetctltest.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name()})
// Check script does not exist
_, err = s.DS.GetSetupExperienceScript(ctx, nil)
var nfe fleet.NotFoundError
require.ErrorAs(t, err, &nfe)
}
// Helper function to get No Team webhook settings from the database
func getNoTeamWebhookSettings(ctx context.Context, t *testing.T, ds *mysql.Datastore) fleet.FailingPoliciesWebhookSettings {
cfg, err := ds.DefaultTeamConfig(ctx)
require.NoError(t, err)
require.NotNil(t, cfg)
return cfg.WebhookSettings.FailingPoliciesWebhook
}
// Helper function to verify No Team webhook settings match expected values
func verifyNoTeamWebhookSettings(ctx context.Context, t *testing.T, ds *mysql.Datastore, expected fleet.FailingPoliciesWebhookSettings) {
actual := getNoTeamWebhookSettings(ctx, t, ds)
require.Equal(t, expected.Enable, actual.Enable)
if expected.Enable {
require.Equal(t, expected.DestinationURL, actual.DestinationURL)
require.Equal(t, expected.HostBatchSize, actual.HostBatchSize)
require.ElementsMatch(t, expected.PolicyIDs, actual.PolicyIDs)
}
}
func (s *enterpriseIntegrationGitopsTestSuite) TestNoTeamWebhookSettings() {
t := s.T()
ctx := t.Context()
user := s.createGitOpsUser(t)
fleetctlConfig := s.createFleetctlConfig(t, user)
var webhookSettings fleet.FailingPoliciesWebhookSettings
// Set the required environment variables
t.Setenv("FLEET_URL", s.Server.URL)
// Create a global config file
const globalTemplate = `
agent_options:
controls:
org_settings:
server_settings:
server_url: $FLEET_URL
org_info:
org_name: Fleet
secrets:
- secret: global_secret
policies:
reports:
`
globalFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = globalFile.WriteString(globalTemplate)
require.NoError(t, err)
err = globalFile.Close()
require.NoError(t, err)
// Create a no-team.yml file with webhook settings
const noTeamTemplateWithWebhook = `
name: Unassigned
policies:
- name: No Team Test Policy
query: SELECT 1 FROM osquery_info WHERE version = '0.0.0';
description: Test policy for no team
resolution: This is a test
controls:
software:
settings:
webhook_settings:
failing_policies_webhook:
enable_failing_policies_webhook: true
destination_url: https://example.com/no-team-webhook
host_batch_size: 50
policy_ids:
- 1
- 2
- 3
`
noTeamFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = noTeamFile.WriteString(noTeamTemplateWithWebhook)
require.NoError(t, err)
err = noTeamFile.Close()
require.NoError(t, err)
noTeamFilePath := filepath.Join(filepath.Dir(noTeamFile.Name()), "unassigned.yml")
err = os.Rename(noTeamFile.Name(), noTeamFilePath)
require.NoError(t, err)
// Test dry-run first
output := fleetctltest.RunAppForTest(t,
[]string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", noTeamFilePath, "--dry-run"})
s.assertDryRunOutputWithDeprecation(t, output, true)
// Check that webhook settings are mentioned in the output
require.Contains(t, output, "would've applied webhook settings for unassigned hosts")
// Apply the configuration (non-dry-run)
output = fleetctltest.RunAppForTest(t,
[]string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", noTeamFilePath})
s.assertRealRunOutputWithDeprecation(t, output, true)
// Verify the output mentions webhook settings were applied
require.Contains(t, output, "applying webhook settings for unassigned hosts")
require.Contains(t, output, "applied webhook settings for unassigned hosts")
// Verify webhook settings were actually applied by checking the database
verifyNoTeamWebhookSettings(ctx, t, s.DS, fleet.FailingPoliciesWebhookSettings{
Enable: true,
DestinationURL: "https://example.com/no-team-webhook",
HostBatchSize: 50,
PolicyIDs: []uint{1, 2, 3},
})
// Test updating webhook settings
const noTeamTemplateUpdatedWebhook = `
name: Unassigned
policies:
- name: No Team Test Policy
query: SELECT 1 FROM osquery_info WHERE version = '0.0.0';
description: Test policy for no team
resolution: This is a test
controls:
software:
settings:
webhook_settings:
failing_policies_webhook:
enable_failing_policies_webhook: false
destination_url: https://updated.example.com/webhook
host_batch_size: 100
policy_ids:
- 4
- 5
`
noTeamFileUpdated, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = noTeamFileUpdated.WriteString(noTeamTemplateUpdatedWebhook)
require.NoError(t, err)
err = noTeamFileUpdated.Close()
require.NoError(t, err)
noTeamFilePathUpdated := filepath.Join(filepath.Dir(noTeamFileUpdated.Name()), "unassigned.yml")
err = os.Rename(noTeamFileUpdated.Name(), noTeamFilePathUpdated)
require.NoError(t, err)
// Apply the updated configuration
output = fleetctltest.RunAppForTest(t,
[]string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", noTeamFilePathUpdated})
// Verify the output still mentions webhook settings were applied
require.Contains(t, output, "applying webhook settings for unassigned hosts")
require.Contains(t, output, "applied webhook settings for unassigned hosts")
// Verify webhook settings were updated
verifyNoTeamWebhookSettings(ctx, t, s.DS, fleet.FailingPoliciesWebhookSettings{
Enable: false,
DestinationURL: "https://updated.example.com/webhook",
HostBatchSize: 100,
PolicyIDs: []uint{4, 5},
})
// Test removing webhook settings entirely
const noTeamTemplateNoWebhook = `
name: Unassigned
policies:
- name: No Team Test Policy
query: SELECT 1 FROM osquery_info WHERE version = '0.0.0';
description: Test policy for no team
resolution: This is a test
controls:
software:
`
noTeamFileNoWebhook, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = noTeamFileNoWebhook.WriteString(noTeamTemplateNoWebhook)
require.NoError(t, err)
err = noTeamFileNoWebhook.Close()
require.NoError(t, err)
noTeamFilePathNoWebhook := filepath.Join(filepath.Dir(noTeamFileNoWebhook.Name()), "unassigned.yml")
err = os.Rename(noTeamFileNoWebhook.Name(), noTeamFilePathNoWebhook)
require.NoError(t, err)
// Apply configuration without webhook settings
output = fleetctltest.RunAppForTest(t,
[]string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", noTeamFilePathNoWebhook})
// Verify webhook settings are mentioned as being applied (they're applied as nil to clear)
require.Contains(t, output, "applying webhook settings for unassigned hosts")
require.Contains(t, output, "applied webhook settings for unassigned hosts")
// Verify webhook settings were cleared
verifyNoTeamWebhookSettings(ctx, t, s.DS, fleet.FailingPoliciesWebhookSettings{
Enable: false,
})
// Test case: team_settings exists but webhook_settings is nil
// First, set webhook settings again
output = fleetctltest.RunAppForTest(t,
[]string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", noTeamFilePath})
require.Contains(t, output, "applied webhook settings for unassigned hosts")
// Verify webhook was set
webhookSettings = getNoTeamWebhookSettings(ctx, t, s.DS)
require.True(t, webhookSettings.Enable)
// Now apply config with team_settings but no webhook_settings
const noTeamTemplateTeamSettingsNoWebhook = `
name: Unassigned
policies:
- name: No Team Test Policy
query: SELECT 1 FROM osquery_info WHERE version = '0.0.0';
description: Test policy for no team
resolution: This is a test
controls:
software:
settings:
`
noTeamFileTeamNoWebhook, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = noTeamFileTeamNoWebhook.WriteString(noTeamTemplateTeamSettingsNoWebhook)
require.NoError(t, err)
err = noTeamFileTeamNoWebhook.Close()
require.NoError(t, err)
noTeamFilePathTeamNoWebhook := filepath.Join(filepath.Dir(noTeamFileTeamNoWebhook.Name()), "unassigned.yml")
err = os.Rename(noTeamFileTeamNoWebhook.Name(), noTeamFilePathTeamNoWebhook)
require.NoError(t, err)
// Apply configuration with team_settings but no webhook_settings
output = fleetctltest.RunAppForTest(t,
[]string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", noTeamFilePathTeamNoWebhook})
// Verify webhook settings are cleared
require.Contains(t, output, "applying webhook settings for unassigned hosts")
require.Contains(t, output, "applied webhook settings for unassigned hosts")
// Verify webhook settings are disabled
verifyNoTeamWebhookSettings(ctx, t, s.DS, fleet.FailingPoliciesWebhookSettings{
Enable: false,
})
// Test case: webhook_settings exists but failing_policies_webhook is nil
// First, set webhook settings again
output = fleetctltest.RunAppForTest(t,
[]string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", noTeamFilePath})
require.Contains(t, output, "applied webhook settings for unassigned hosts")
// Verify webhook was set
webhookSettings = getNoTeamWebhookSettings(ctx, t, s.DS)
require.True(t, webhookSettings.Enable)
// Now apply config with webhook_settings but no failing_policies_webhook
const noTeamTemplateWebhookNoFailing = `
name: Unassigned
policies:
- name: No Team Test Policy
query: SELECT 1 FROM osquery_info WHERE version = '0.0.0';
description: Test policy for no team
resolution: This is a test
controls:
software:
settings:
webhook_settings:
`
noTeamFileWebhookNoFailing, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = noTeamFileWebhookNoFailing.WriteString(noTeamTemplateWebhookNoFailing)
require.NoError(t, err)
err = noTeamFileWebhookNoFailing.Close()
require.NoError(t, err)
noTeamFilePathWebhookNoFailing := filepath.Join(filepath.Dir(noTeamFileWebhookNoFailing.Name()), "unassigned.yml")
err = os.Rename(noTeamFileWebhookNoFailing.Name(), noTeamFilePathWebhookNoFailing)
require.NoError(t, err)
// Apply configuration with webhook_settings but no failing_policies_webhook
output = fleetctltest.RunAppForTest(t,
[]string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", noTeamFilePathWebhookNoFailing})
// Verify webhook settings are cleared
require.Contains(t, output, "applying webhook settings for unassigned hosts")
require.Contains(t, output, "applied webhook settings for unassigned hosts")
// Verify webhook settings are disabled
verifyNoTeamWebhookSettings(ctx, t, s.DS, fleet.FailingPoliciesWebhookSettings{
Enable: false,
})
}
func (s *enterpriseIntegrationGitopsTestSuite) TestRemoveCustomSettingsFromDefaultYAML() {
t := s.T()
ctx := context.Background()
user := s.createGitOpsUser(t)
fleetctlConfig := s.createFleetctlConfig(t, user)
// Set the required environment variables
t.Setenv("FLEET_URL", s.Server.URL)
// setup custom settings profile
profileFile, err := os.CreateTemp(t.TempDir(), "*.mobileconfig")
require.NoError(t, err)
_, err = profileFile.WriteString(test.GenerateMDMAppleProfile("test", "test", uuid.NewString()))
require.NoError(t, err)
err = profileFile.Close()
require.NoError(t, err)
// global file setup with custom settings
const (
globalTemplateWithCustomSettings = `
agent_options:
controls:
apple_settings:
configuration_profiles:
- path: %s
org_settings:
server_settings:
server_url: $FLEET_URL
org_info:
org_name: Fleet
secrets:
policies:
reports:
`
)
globalFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = globalFile.WriteString(fmt.Sprintf(globalTemplateWithCustomSettings, profileFile.Name()))
require.NoError(t, err)
err = globalFile.Close()
require.NoError(t, err)
s.assertDryRunOutput(t, fleetctltest.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "--dry-run"}))
s.assertRealRunOutput(t, fleetctltest.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name()}))
profiles, err := s.DS.ListMDMAppleConfigProfiles(ctx, nil)
require.NoError(t, err)
require.Equal(t, 1, len(profiles))
// global file setup without custom settings
const (
globalTemplateWithoutCustomSettings = `
agent_options:
controls:
org_settings:
server_settings:
server_url: $FLEET_URL
org_info:
org_name: Fleet
secrets:
policies:
reports:
`
)
globalFile, err = os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = globalFile.WriteString(globalTemplateWithoutCustomSettings)
require.NoError(t, err)
err = globalFile.Close()
require.NoError(t, err)
s.assertDryRunOutput(t, fleetctltest.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "--dry-run"}))
s.assertRealRunOutput(t, fleetctltest.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name()}))
// Check profile does not exist
profiles, err = s.DS.ListMDMAppleConfigProfiles(ctx, nil)
require.NoError(t, err)
require.Equal(t, 0, len(profiles))
}
func (s *enterpriseIntegrationGitopsTestSuite) TestMacOSSetup() {
t := s.T()
ctx := context.Background()
originalAppConfig, err := s.DS.AppConfig(ctx)
require.NoError(t, err)
t.Cleanup(func() {
err := s.DS.SaveAppConfig(ctx, originalAppConfig)
require.NoError(t, err)
})
user := s.createGitOpsUser(t)
fleetctlConfig := s.createFleetctlConfig(t, user)
bootstrapServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "testdata/signed.pkg")
}))
defer bootstrapServer.Close()
const (
globalConfig = `
agent_options:
org_settings:
server_settings:
server_url: $FLEET_URL
org_info:
org_name: Fleet
secrets:
policies:
reports:
`
globalConfigOnly = `
agent_options:
controls:
setup_experience:
macos_bootstrap_package: %s
macos_manual_agent_install: %t
org_settings:
server_settings:
server_url: $FLEET_URL
org_info:
org_name: Fleet
secrets:
policies:
reports:
`
noTeamConfig = `name: Unassigned
controls:
setup_experience:
macos_bootstrap_package: %s
macos_manual_agent_install: true
policies:
software:
`
teamConfig = `
controls:
setup_experience:
macos_bootstrap_package: %s
macos_manual_agent_install: %t
software:
reports:
policies:
agent_options:
name: %s
settings:
secrets: [{"secret":"enroll_secret"}]
`
)
globalFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = globalFile.WriteString(globalConfig)
require.NoError(t, err)
err = globalFile.Close()
require.NoError(t, err)
noTeamFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = noTeamFile.WriteString(fmt.Sprintf(noTeamConfig, bootstrapServer.URL))
require.NoError(t, err)
err = noTeamFile.Close()
require.NoError(t, err)
noTeamFilePath := filepath.Join(filepath.Dir(noTeamFile.Name()), "unassigned.yml")
err = os.Rename(noTeamFile.Name(), noTeamFilePath)
require.NoError(t, err)
teamName := uuid.NewString()
teamFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = teamFile.WriteString(fmt.Sprintf(teamConfig, bootstrapServer.URL, true, teamName))
require.NoError(t, err)
err = teamFile.Close()
require.NoError(t, err)
teamFileClear, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = teamFileClear.WriteString(fmt.Sprintf(teamConfig, bootstrapServer.URL, false, teamName))
require.NoError(t, err)
err = teamFileClear.Close()
require.NoError(t, err)
globalFileOnlySet, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = globalFileOnlySet.WriteString(fmt.Sprintf(globalConfigOnly, bootstrapServer.URL, true))
require.NoError(t, err)
err = globalFileOnlySet.Close()
require.NoError(t, err)
globalFileOnlyClear, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = globalFileOnlyClear.WriteString(fmt.Sprintf(globalConfigOnly, bootstrapServer.URL, false))
require.NoError(t, err)
err = globalFileOnlyClear.Close()
require.NoError(t, err)
// Set the required environment variables
t.Setenv("FLEET_URL", s.Server.URL)
// Apply configs
s.assertDryRunOutput(t, fleetctltest.RunAppForTest(t,
[]string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", noTeamFilePath, "-f", teamFile.Name(), "--dry-run"}))
s.assertRealRunOutput(t, fleetctltest.RunAppForTest(t,
[]string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", noTeamFilePath, "-f", teamFile.Name()}))
appConfig, err := s.DS.AppConfig(ctx)
require.NoError(t, err)
assert.True(t, appConfig.MDM.MacOSSetup.ManualAgentInstall.Value)
team, err := s.DS.TeamByName(ctx, teamName)
require.NoError(t, err)
assert.True(t, team.Config.MDM.MacOSSetup.ManualAgentInstall.Value)
// Apply global configs without no-team
s.assertDryRunOutput(t, fleetctltest.RunAppForTest(t,
[]string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFileOnlyClear.Name(), "-f", teamFileClear.Name(), "--dry-run"}))
s.assertRealRunOutput(t, fleetctltest.RunAppForTest(t,
[]string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFileOnlyClear.Name(), "-f", teamFileClear.Name()}))
appConfig, err = s.DS.AppConfig(ctx)
require.NoError(t, err)
assert.False(t, appConfig.MDM.MacOSSetup.ManualAgentInstall.Value)
team, err = s.DS.TeamByName(ctx, teamName)
require.NoError(t, err)
assert.False(t, team.Config.MDM.MacOSSetup.ManualAgentInstall.Value)
// Apply global configs only
s.assertDryRunOutput(t, fleetctltest.RunAppForTest(t,
[]string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFileOnlySet.Name(), "--dry-run"}))
s.assertRealRunOutput(t, fleetctltest.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFileOnlySet.Name()}))
appConfig, err = s.DS.AppConfig(ctx)
require.NoError(t, err)
assert.True(t, appConfig.MDM.MacOSSetup.ManualAgentInstall.Value)
}
func (s *enterpriseIntegrationGitopsTestSuite) TestFleetGitOpsDeletesNonManagedLabels() {
t := s.T()
ctx := context.Background()
// Set the required environment variables
t.Setenv("FLEET_SERVER_URL", s.Server.URL)
t.Setenv("ORG_NAME", "Around the block")
user := s.createGitOpsUser(t)
fleetctlConfig := s.createFleetctlConfig(t, user)
var someUser fleet.User
for _, u := range s.Users {
someUser = u
break
}
// 'nonManagedLabel' is associated with a software installer and is
// not managed in the ops file so it should be deleted.
nonManagedLabel, err := s.DS.NewLabel(ctx, &fleet.Label{
Name: t.Name(),
Query: "bye bye label",
})
require.NoError(t, err)
installer, err := fleet.NewTempFileReader(strings.NewReader("echo"), t.TempDir)
require.NoError(t, err)
_, _, err = s.DS.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
InstallScript: "install zoo",
InstallerFile: installer,
StorageID: uuid.NewString(),
Filename: "zoo.pkg",
Title: "zoo",
Source: "apps",
Version: "0.0.1",
UserID: someUser.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{
LabelScope: fleet.LabelScopeIncludeAny,
ByName: map[string]fleet.LabelIdent{nonManagedLabel.Name: {
LabelID: nonManagedLabel.ID,
LabelName: nonManagedLabel.Name,
}},
},
})
require.NoError(t, err)
opsFile := path.Join("..", "..", "fleetctl", "testdata", "gitops", "global_config_no_paths.yml")
_ = fleetctltest.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", opsFile})
// Check label was removed successfully
result, err := s.DS.LabelIDsByName(ctx, []string{nonManagedLabel.Name}, fleet.TeamFilter{})
require.NoError(t, err)
require.Empty(t, result)
}
func (s *enterpriseIntegrationGitopsTestSuite) TestMacOSSetupScriptWithFleetSecret() {
t := s.T()
ctx := context.Background()
user := s.createGitOpsUser(t)
fleetctlConfig := s.createFleetctlConfig(t, user)
const secretName = "MY_SECRET"
const secretValue = "my-secret-value"
// Set the required environment variables
t.Setenv("FLEET_URL", s.Server.URL)
t.Setenv("FLEET_SECRET_"+secretName, secretValue)
// Create a script file that uses the fleet secret
scriptFile, err := os.CreateTemp(t.TempDir(), "*.sh")
require.NoError(t, err)
_, err = scriptFile.WriteString(`echo "Using secret: $FLEET_SECRET_` + secretName)
require.NoError(t, err)
err = scriptFile.Close()
require.NoError(t, err)
// Create a no-team file with the script
const noTeamTemplate = `name: Unassigned
policies:
controls:
setup_experience:
macos_script: %s
software:
`
noTeamFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = noTeamFile.WriteString(fmt.Sprintf(noTeamTemplate, scriptFile.Name()))
require.NoError(t, err)
err = noTeamFile.Close()
require.NoError(t, err)
noTeamFilePath := filepath.Join(filepath.Dir(noTeamFile.Name()), "unassigned.yml")
err = os.Rename(noTeamFile.Name(), noTeamFilePath)
require.NoError(t, err)
// Create a global file
globalFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = globalFile.WriteString(`
agent_options:
controls:
org_settings:
server_settings:
server_url: $FLEET_URL
org_info:
org_name: Fleet
secrets:
policies:
reports:
`)
require.NoError(t, err)
// Apply the configs
s.assertDryRunOutput(t, fleetctltest.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", noTeamFilePath, "--dry-run"}))
s.assertRealRunOutput(t, fleetctltest.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", noTeamFilePath}))
// Verify the script was saved
_, err = s.DS.GetSetupExperienceScript(ctx, nil)
require.NoError(t, err)
// Verify the secret was saved
secretVariables, err := s.DS.GetSecretVariables(ctx, []string{secretName})
require.NoError(t, err)
require.Equal(t, secretVariables[0].Name, secretName)
require.Equal(t, secretVariables[0].Value, secretValue)
}
// TestEnvSubstitutionInProfiles tests that only FLEET_SECRET_ prefixed env vars are saved as secrets
func (s *enterpriseIntegrationGitopsTestSuite) TestEnvSubstitutionInProfiles() {
t := s.T()
ctx := t.Context()
tempDir := t.TempDir()
// Create a test configuration profile with both valid and invalid secret references
profileContent := `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PayloadContent</key>
<array>
<dict>
<key>PayloadDisplayName</key>
<string>Test Profile</string>
<key>PayloadIdentifier</key>
<string>com.fleet.test.env</string>
<key>PayloadType</key>
<string>Configuration</string>
<key>PayloadUUID</key>
<string>12345678-1234-1234-1234-123456789012</string>
<key>PayloadVersion</key>
<integer>1</integer>
<key>TestSecretValue</key>
<string>$FLEET_SECRET_TEST_SECRET</string>
<key>TestInvalidSecret</key>
<string>$FLEET_DUO_CERTIFICATE_SECRET</string>
<key>TestPlainValue</key>
<string>$HOME</string>
</dict>
</array>
<key>PayloadDisplayName</key>
<string>Test Profile</string>
<key>PayloadIdentifier</key>
<string>com.fleet.test.env</string>
<key>PayloadType</key>
<string>Configuration</string>
<key>PayloadUUID</key>
<string>12345678-1234-1234-1234-123456789012</string>
<key>PayloadVersion</key>
<integer>1</integer>
</dict>
</plist>`
// Write the profile to a file
profilePath := filepath.Join(tempDir, "test-profile.mobileconfig")
err := os.WriteFile(profilePath, []byte(profileContent), 0o644) //nolint:gosec // test code
require.NoError(t, err)
// Create a GitOps config file that references the profile
// Note: Environment variables in the YAML config itself get expanded,
// but not in the referenced profile files
gitopsConfig := fmt.Sprintf(`
org_settings:
server_settings:
server_url: %s
secrets:
- secret: test_secret
agent_options:
config:
decorators:
load:
- SELECT uuid AS host_uuid FROM system_info;
controls:
apple_settings:
configuration_profiles:
- path: %s
reports: []
policies: []
`, s.Server.URL, profilePath)
configPath := filepath.Join(tempDir, "gitops.yml")
err = os.WriteFile(configPath, []byte(gitopsConfig), 0o644) //nolint:gosec // test code
require.NoError(t, err)
// Create a GitOps user
gitOpsUser := s.createGitOpsUser(t)
fleetctlConfig := s.createFleetctlConfig(t, gitOpsUser)
// Set the environment variable for the valid secret
t.Setenv("FLEET_SECRET_TEST_SECRET", "super_secret_value_123")
t.Setenv("FLEET_DUO_CERTIFICATE_SECRET", "should_not_be_saved")
t.Setenv("HOME", "also_not_saved")
// Run GitOps dry-run - should fail without the required secret
// First, unset the environment variable to trigger the error
_ = os.Unsetenv("FLEET_SECRET_TEST_SECRET")
_, err = fleetctltest.RunAppNoChecks([]string{"gitops", "--config", fleetctlConfig.Name(), "-f", configPath, "--dry-run"})
require.ErrorContains(t, err, "FLEET_SECRET_TEST_SECRET")
// Set the env var again and run for real
t.Setenv("FLEET_SECRET_TEST_SECRET", "super_secret_value_123")
_ = fleetctltest.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", configPath})
// Verify that the secret was saved to the server
secrets, err := s.DS.GetSecretVariables(ctx, []string{"TEST_SECRET"})
require.NoError(t, err)
require.Len(t, secrets, 1)
assert.Equal(t, "TEST_SECRET", secrets[0].Name)
assert.Equal(t, "super_secret_value_123", secrets[0].Value)
// Verify that non-FLEET_SECRET_ variables were NOT saved
notSaved, err := s.DS.GetSecretVariables(ctx, []string{"DUO_CERTIFICATE_SECRET", "HOME"})
require.NoError(t, err)
assert.Empty(t, notSaved, "Non-FLEET_SECRET_ variables should not be saved")
// Verify that the profile content has the expected substitutions:
// - $FLEET_SECRET_* variables should remain as-is (substituted at delivery time)
// - Other env vars should be expanded during GitOps
profiles, err := s.DS.ListMDMAppleConfigProfiles(ctx, nil)
require.NoError(t, err)
foundProfile := false
for _, profile := range profiles {
t.Logf("Found profile: %s", profile.Name)
if strings.Contains(profile.Name, "test-profile") || strings.Contains(profile.Identifier, "com.fleet.test.env") {
foundProfile = true
// $FLEET_SECRET_* variables should NOT be expanded (they're expanded at delivery time)
assert.Contains(t, string(profile.Mobileconfig), "$FLEET_SECRET_TEST_SECRET")
// Non-FLEET_SECRET_/FLEET_VAR_ variables SHOULD be expanded during GitOps
assert.Contains(t, string(profile.Mobileconfig), "should_not_be_saved") // Value of $FLEET_DUO_CERTIFICATE_SECRET
assert.Contains(t, string(profile.Mobileconfig), "also_not_saved") // Value of $HOME
// The original variable names should NOT be present
assert.NotContains(t, string(profile.Mobileconfig), "$FLEET_DUO_CERTIFICATE_SECRET")
assert.NotContains(t, string(profile.Mobileconfig), "$HOME")
break
}
}
assert.True(t, foundProfile, "Profile should be uploaded to the server")
}
// TestFleetSecretInDataTag tests that FLEET_SECRET_ variables in <data> tags of Apple profiles
// are handled properly.
func (s *enterpriseIntegrationGitopsTestSuite) TestFleetSecretInDataTag() {
t := s.T()
tempDir := t.TempDir()
ctx := context.Background()
// Sample certificate in base64 format (this is a dummy test certificate)
testCertBase64 := `MIIDaTCCAlGgAwIBAgIUNQLezMUpmZK18DcLKt/XTRcLlK8wDQYJKoZIhvcNAQELBQAwRDEfMB0GA1UEAwwWRHVtbXkgVGVzdCBDZXJ0aWZpY2F0ZTEUMBIGA1UECgwLRXhhbXBsZSBPcmcxCzAJBgNVBAYTAlVTMB4XDTI1MDgxOTE3NDMwN1oXDTI2MDgxOTE3NDMwN1owRDEfMB0GA1UEAwwWRHVtbXkgVGVzdCBDZXJ0aWZpY2F0ZTEUMBIGA1UECgwLRXhhbXBsZSBPcmcxCzAJBgNVBAYTAlVTMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA07Np/w5WpFVLlMKX3dZSxwo+c2uwP2glTN0HA5c/6UOQRR9c91yoGGJsD4pfqhtIMSTFw7po3n/PjhGDe/WH+utK+ZIcD0nGD6SvmOggyoohHs81eIOjJAEJjxzhk7eLTVpUI2EnPe/24ei/dgkK59As9qQyH/y+CoR8JIYbNCJH5YLC2Pa44V84QWa2I5DHKUKrUXo9WsrRp1N1JjyaG/6hxLBJZ69e0QTrxxScboreRqVUR6oIEJRTchB+rDG5dxXzCQE6/F8N3qR76t23wd3CLmrcXoEc1P2P331Qzi0KXNXjdJFf0plmfRkT/IWgfM81Vfon1QwENwRSBNmPfQIDAQABo1MwUTAdBgNVHQ4EFgQU9q7SDfQRbJ31snRt2sZzx5sdEpYwHwYDVR0jBBgwFoAU9q7SDfQRbJ31snRt2sZzx5sdEpYwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAYwH42JP45SnZejSF74OcYt8fp08jCWHOiFC3QEo3cROXVn6AWjEbzuQpOxRWF9EizNA83c4E6I+kQVztiuv9bUKGuLFeYb9lUZe8HOvH+j22MtGvZrDPygsJc8TavdkxAsu6OiNQZrYiCFzixkKS9b5p/1B93GBh62OFnV1nUBS8PzAZhOAyJ8UcEhr+GNzZG99/wOkcB0uwxmIb8x8sB3KnQ0qef/qnmgeWxlJlDc/SZ2/4PgtaluZ+noDfNPzaQn4eJNnBz0OTqZ9yuKALeE1WHk8U13zSdc1GNVLhXOrEHegPK5bBmA/lpIQ6HrkwUX7MJ3vK0AD3LjaTzXltDQ==`
// Create a test team first
team, err := s.DS.NewTeam(ctx, &fleet.Team{
Name: "Test Team for Secret in Data Tag",
})
require.NoError(t, err)
// Create a profile with $FLEET_SECRET_DUO_CERTIFICATE in a <data> tag
// This mimics the real-world scenario where the certificate should be base64 encoded
profileContent := `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PayloadContent</key>
<array>
<dict>
<key>PayloadType</key>
<string>com.apple.security.root</string>
<key>PayloadVersion</key>
<integer>1</integer>
<key>PayloadIdentifier</key>
<string>com.example.test.cert</string>
<key>PayloadUUID</key>
<string>11111111-2222-3333-4444-555555555555</string>
<key>PayloadDisplayName</key>
<string>Test Root Certificate</string>
<key>PayloadContent</key>
<data>$FLEET_SECRET_DUO_CERTIFICATE</data>
</dict>
</array>
<key>PayloadType</key>
<string>Configuration</string>
<key>PayloadVersion</key>
<integer>1</integer>
<key>PayloadIdentifier</key>
<string>com.example.test.profile</string>
<key>PayloadUUID</key>
<string>aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee</string>
<key>PayloadDisplayName</key>
<string>Test MDM Profile with Base64</string>
</dict>
</plist>`
// Write the profile to a file
profilePath := filepath.Join(tempDir, "rootcert-secret.mobileconfig")
err = os.WriteFile(profilePath, []byte(profileContent), 0o644) //nolint:gosec
require.NoError(t, err)
// Create a team GitOps config file that references the profile
teamConfig := fmt.Sprintf(`
name: %s
settings:
secrets:
- secret: test_secret
agent_options:
config:
decorators:
load:
- SELECT uuid AS host_uuid FROM system_info;
controls:
apple_settings:
configuration_profiles:
- path: %s
reports:
policies:
software:
`, team.Name, profilePath)
configPath := filepath.Join(tempDir, "team-gitops.yml")
err = os.WriteFile(configPath, []byte(teamConfig), 0o644) //nolint:gosec
require.NoError(t, err)
// Create a GitOps user
gitOpsUser := s.createGitOpsUser(t)
fleetctlConfig := s.createFleetctlConfig(t, gitOpsUser)
// Set the environment variable with the base64-encoded certificate
t.Setenv("FLEET_SECRET_DUO_CERTIFICATE", testCertBase64)
// The fix expands FLEET_SECRET_ variables for validation only, allowing the profile to be parsed
_, err = fleetctltest.RunAppNoChecks([]string{"gitops", "--config", fleetctlConfig.Name(), "-f", configPath, "--dry-run"})
require.NoError(t, err, "GitOps dry-run should succeed with the fix")
// Also test without dry-run to confirm it works
_, err = fleetctltest.RunAppNoChecks([]string{"gitops", "--config", fleetctlConfig.Name(), "-f", configPath})
require.NoError(t, err, "GitOps should succeed with the fix")
// Verify that the profile stored on the server still has the unexpanded variable
profiles, err := s.DS.ListMDMAppleConfigProfiles(ctx, &team.ID)
require.NoError(t, err)
require.Len(t, profiles, 1)
// The stored profile should still contain the unexpanded variable, not the actual secret
require.Contains(t, string(profiles[0].Mobileconfig), "$FLEET_SECRET_DUO_CERTIFICATE",
"Profile should still contain unexpanded FLEET_SECRET variable")
}
func (s *enterpriseIntegrationGitopsTestSuite) TestAddManualLabels() {
t := s.T()
ctx := context.Background()
user := fleet.User{
Name: "Admin User",
Email: uuid.NewString() + "@example.com",
GlobalRole: ptr.String(fleet.RoleAdmin),
}
require.NoError(t, user.SetPassword(test.GoodPassword, 10, 10))
_, err := s.DS.NewUser(context.Background(), &user)
require.NoError(t, err)
fleetctlConfig := s.createFleetctlConfig(t, user)
// Add some hosts
host1, err := s.DS.NewHost(context.Background(), &fleet.Host{
UUID: "uuid-1",
Hostname: "host1",
Platform: "linux",
HardwareSerial: "serial1",
})
require.NoError(t, err)
host2, err := s.DS.NewHost(context.Background(), &fleet.Host{
UUID: "uuid-2",
Hostname: "host2",
Platform: "linux",
HardwareSerial: "serial2",
})
require.NoError(t, err)
host3, err := s.DS.NewHost(context.Background(), &fleet.Host{
UUID: "uuid-3",
Hostname: "host3",
Platform: "linux",
HardwareSerial: "serial3",
})
require.NoError(t, err)
host4, err := s.DS.NewHost(context.Background(), &fleet.Host{
UUID: "uuid-4",
Hostname: "host4",
Platform: "linux",
HardwareSerial: "serial4",
})
require.NoError(t, err)
// Add a host whose UUID starts with the ID of host4 (probably ID 4,
// but get it from the record just in case.)
// host4 should _not_ be added to the label (see issue #34236).
host5, err := s.DS.NewHost(context.Background(), &fleet.Host{
UUID: fmt.Sprintf("%duuid-5", host4.ID),
Hostname: "dummy",
Platform: "linux",
HardwareSerial: "dummy",
})
require.NoError(t, err)
// Create a global file
globalFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = globalFile.WriteString(fmt.Sprintf(`
agent_options:
controls:
org_settings:
secrets:
- secret: test_secret
policies:
reports:
labels:
- name: my-label
label_membership_type: manual
hosts:
- %s
- %s
- %d
- %s
- dummy
`, host1.Hostname, host2.HardwareSerial, host3.ID, host5.UUID))
require.NoError(t, err)
// Apply the configs
s.assertDryRunOutput(t, fleetctltest.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "--dry-run"}))
s.assertRealRunOutput(t, fleetctltest.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name()}))
// Verify the label was created and has the correct hosts
labels, err := s.DS.LabelsByName(ctx, []string{"my-label"}, fleet.TeamFilter{})
require.NoError(t, err)
require.Len(t, labels, 1)
label := labels["my-label"]
// Get the hosts for the label
labelHosts, err := s.DS.ListHostsInLabel(ctx, fleet.TeamFilter{User: &user}, label.ID, fleet.HostListOptions{})
require.NoError(t, err)
require.Len(t, labelHosts, 4)
// Get the IDs of the hosts
var labelHostIDs []uint
for _, h := range labelHosts {
labelHostIDs = append(labelHostIDs, h.ID)
}
// Verify the correct hosts were added to the label
require.ElementsMatch(t, labelHostIDs, []uint{host1.ID, host2.ID, host3.ID, host5.ID})
}
func (s *enterpriseIntegrationGitopsTestSuite) TestManualLabelOmitHostsPreservesMembership() {
t := s.T()
ctx := context.Background()
user := fleet.User{
Name: "Admin User",
Email: uuid.NewString() + "@example.com",
GlobalRole: ptr.String(fleet.RoleAdmin),
}
require.NoError(t, user.SetPassword(test.GoodPassword, 10, 10))
_, err := s.DS.NewUser(context.Background(), &user)
require.NoError(t, err)
fleetctlConfig := s.createFleetctlConfig(t, user)
host1, err := s.DS.NewHost(ctx, &fleet.Host{
UUID: "omit-hosts-uuid-1",
Hostname: "omit-hosts-host1",
Platform: "linux",
HardwareSerial: "omit-hosts-serial1",
})
require.NoError(t, err)
host2, err := s.DS.NewHost(ctx, &fleet.Host{
UUID: "omit-hosts-uuid-2",
Hostname: "omit-hosts-host2",
Platform: "linux",
HardwareSerial: "omit-hosts-serial2",
})
require.NoError(t, err)
globalFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
// Step 1: Apply a manual label with hosts.
_, err = globalFile.WriteString(fmt.Sprintf(`
agent_options:
controls:
org_settings:
secrets:
- secret: test_secret
policies:
reports:
labels:
- name: preserve-membership-label
description: A manual label
label_membership_type: manual
hosts:
- %s
- %s
`, host1.Hostname, host2.Hostname))
require.NoError(t, err)
s.assertRealRunOutput(t, fleetctltest.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name()}))
labels, err := s.DS.LabelsByName(ctx, []string{"preserve-membership-label"}, fleet.TeamFilter{})
require.NoError(t, err)
label := labels["preserve-membership-label"]
require.NotNil(t, label)
labelHosts, err := s.DS.ListHostsInLabel(ctx, fleet.TeamFilter{User: &user}, label.ID, fleet.HostListOptions{})
require.NoError(t, err)
require.Len(t, labelHosts, 2)
// Step 2: Re-apply the same label without the hosts key.
require.NoError(t, os.WriteFile(globalFile.Name(), []byte(`
agent_options:
controls:
org_settings:
secrets:
- secret: test_secret
policies:
reports:
labels:
- name: preserve-membership-label
description: A manual label
label_membership_type: manual
`), 0o644))
s.assertRealRunOutput(t, fleetctltest.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name()}))
// Verify the hosts are still attached.
labelHosts, err = s.DS.ListHostsInLabel(ctx, fleet.TeamFilter{User: &user}, label.ID, fleet.HostListOptions{})
require.NoError(t, err)
require.Len(t, labelHosts, 2, "omitting hosts key should preserve existing membership")
var hostIDs []uint
for _, h := range labelHosts {
hostIDs = append(hostIDs, h.ID)
}
require.ElementsMatch(t, hostIDs, []uint{host1.ID, host2.ID})
// Step 3: Re-apply with empty hosts to clear membership.
require.NoError(t, os.WriteFile(globalFile.Name(), []byte(`
agent_options:
controls:
org_settings:
secrets:
- secret: test_secret
policies:
reports:
labels:
- name: preserve-membership-label
description: A manual label
label_membership_type: manual
hosts: []
`), 0o644))
s.assertRealRunOutput(t, fleetctltest.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name()}))
// Verify hosts were cleared.
labelHosts, err = s.DS.ListHostsInLabel(ctx, fleet.TeamFilter{User: &user}, label.ID, fleet.HostListOptions{})
require.NoError(t, err)
require.Len(t, labelHosts, 0, "empty hosts list should clear membership")
}
func (s *enterpriseIntegrationGitopsTestSuite) TestIPASoftwareInstallers() {
t := s.T()
ctx := context.Background()
user := s.createGitOpsUser(t)
fleetctlConfig := s.createFleetctlConfig(t, user)
lbl, err := s.DS.NewLabel(ctx, &fleet.Label{Name: "Label1", Query: "SELECT 1"})
require.NoError(t, err)
require.NotZero(t, lbl.ID)
const (
globalTemplate = `
agent_options:
controls:
org_settings:
server_settings:
server_url: $FLEET_URL
org_info:
org_name: Fleet
secrets:
policies:
reports:
labels:
- name: Label1
label_membership_type: dynamic
query: SELECT 1
`
noTeamTemplate = `name: Unassigned
controls:
policies:
software:
packages:
%s
`
teamTemplate = `
controls:
software:
packages:
%s
reports:
policies:
agent_options:
name: %s
settings:
secrets: [{"secret":"enroll_secret"}]
`
)
globalFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = globalFile.WriteString(globalTemplate)
require.NoError(t, err)
err = globalFile.Close()
require.NoError(t, err)
// create an .ipa software for the no-team config
noTeamFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = noTeamFile.WriteString(fmt.Sprintf(noTeamTemplate, `
- url: ${SOFTWARE_INSTALLER_URL}/ipa_test.ipa
self_service: true
`))
require.NoError(t, err)
err = noTeamFile.Close()
require.NoError(t, err)
noTeamFilePath := filepath.Join(filepath.Dir(noTeamFile.Name()), "unassigned.yml")
err = os.Rename(noTeamFile.Name(), noTeamFilePath)
require.NoError(t, err)
// Set the required environment variables
t.Setenv("FLEET_URL", s.Server.URL)
testing_utils.StartSoftwareInstallerServer(t)
s.assertDryRunOutput(t, fleetctltest.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", noTeamFilePath, "--dry-run"}))
s.assertRealRunOutput(t, fleetctltest.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", noTeamFilePath}))
// the ipa installer was created for no team
titles, _, _, err := s.DS.ListSoftwareTitles(ctx, fleet.SoftwareTitleListOptions{AvailableForInstall: true, TeamID: ptr.Uint(0)},
fleet.TeamFilter{User: test.UserAdmin})
require.NoError(t, err)
require.Len(t, titles, 2)
var sources, platforms []string
for _, title := range titles {
require.Equal(t, "ipa_test", title.Name)
require.NotNil(t, title.BundleIdentifier)
require.Equal(t, "com.ipa-test.ipa-test", *title.BundleIdentifier)
sources = append(sources, title.Source)
require.NotNil(t, title.SoftwarePackage)
platforms = append(platforms, title.SoftwarePackage.Platform)
require.Equal(t, "ipa_test.ipa", title.SoftwarePackage.Name)
meta, err := s.DS.GetInHouseAppMetadataByTeamAndTitleID(ctx, nil, title.ID)
require.NoError(t, err)
require.True(t, meta.SelfService)
require.Empty(t, meta.LabelsExcludeAny)
require.Empty(t, meta.LabelsIncludeAny)
require.Empty(t, meta.LabelsIncludeAll)
}
require.ElementsMatch(t, []string{"ios_apps", "ipados_apps"}, sources)
require.ElementsMatch(t, []string{"ios", "ipados"}, platforms)
// create a dummy install script, should be ignored for ipa apps
scriptFile, err := os.CreateTemp(t.TempDir(), "*.sh")
require.NoError(t, err)
_, err = scriptFile.WriteString(`echo "dummy install script"`)
require.NoError(t, err)
err = scriptFile.Close()
require.NoError(t, err)
// create an .ipa software for the team config
teamName := uuid.NewString()
teamFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = teamFile.WriteString(fmt.Sprintf(teamTemplate, `
- url: ${SOFTWARE_INSTALLER_URL}/ipa_test.ipa
self_service: false
install_script:
path: `+scriptFile.Name()+`
labels_include_any:
- Label1
`, teamName))
require.NoError(t, err)
err = teamFile.Close()
require.NoError(t, err)
s.assertDryRunOutput(t, fleetctltest.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", teamFile.Name(), "--dry-run"}))
s.assertRealRunOutput(t, fleetctltest.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", teamFile.Name()}))
// get the team ID
team, err := s.DS.TeamByName(ctx, teamName)
require.NoError(t, err)
// the ipa installer was created for the team
titles, _, _, err = s.DS.ListSoftwareTitles(ctx, fleet.SoftwareTitleListOptions{AvailableForInstall: true, TeamID: &team.ID},
fleet.TeamFilter{User: test.UserAdmin})
require.NoError(t, err)
require.Len(t, titles, 2)
sources, platforms = []string{}, []string{}
for _, title := range titles {
require.Equal(t, "ipa_test", title.Name)
require.NotNil(t, title.BundleIdentifier)
require.Equal(t, "com.ipa-test.ipa-test", *title.BundleIdentifier)
sources = append(sources, title.Source)
require.NotNil(t, title.SoftwarePackage)
platforms = append(platforms, title.SoftwarePackage.Platform)
require.Equal(t, "ipa_test.ipa", title.SoftwarePackage.Name)
meta, err := s.DS.GetInHouseAppMetadataByTeamAndTitleID(ctx, &team.ID, title.ID)
require.NoError(t, err)
require.False(t, meta.SelfService)
require.Empty(t, meta.LabelsExcludeAny)
require.Empty(t, meta.LabelsIncludeAll)
require.Len(t, meta.LabelsIncludeAny, 1)
require.Equal(t, lbl.ID, meta.LabelsIncludeAny[0].LabelID)
require.Empty(t, meta.InstallScript) // install script should be ignored for ipa apps
}
require.ElementsMatch(t, []string{"ios_apps", "ipados_apps"}, sources)
require.ElementsMatch(t, []string{"ios", "ipados"}, platforms)
// update the team config to clear the label condition
err = os.WriteFile(teamFile.Name(), []byte(fmt.Sprintf(teamTemplate, `
- url: ${SOFTWARE_INSTALLER_URL}/ipa_test.ipa
labels_include_any:
`, teamName)), 0o644)
require.NoError(t, err)
s.assertDryRunOutput(t, fleetctltest.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", teamFile.Name(), "--dry-run"}))
s.assertRealRunOutput(t, fleetctltest.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", teamFile.Name()}))
// the ipa installer was created for the team
titles, _, _, err = s.DS.ListSoftwareTitles(ctx, fleet.SoftwareTitleListOptions{AvailableForInstall: true, TeamID: &team.ID},
fleet.TeamFilter{User: test.UserAdmin})
require.NoError(t, err)
require.Len(t, titles, 2)
sources, platforms = []string{}, []string{}
for _, title := range titles {
require.Equal(t, "ipa_test", title.Name)
require.NotNil(t, title.BundleIdentifier)
require.Equal(t, "com.ipa-test.ipa-test", *title.BundleIdentifier)
sources = append(sources, title.Source)
require.NotNil(t, title.SoftwarePackage)
platforms = append(platforms, title.SoftwarePackage.Platform)
require.Equal(t, "ipa_test.ipa", title.SoftwarePackage.Name)
meta, err := s.DS.GetInHouseAppMetadataByTeamAndTitleID(ctx, &team.ID, title.ID)
require.NoError(t, err)
require.False(t, meta.SelfService)
require.Empty(t, meta.LabelsExcludeAny)
require.Empty(t, meta.LabelsIncludeAny)
require.Empty(t, meta.LabelsIncludeAll)
}
require.ElementsMatch(t, []string{"ios_apps", "ipados_apps"}, sources)
require.ElementsMatch(t, []string{"ios", "ipados"}, platforms)
// update the team config to clear all installers
err = os.WriteFile(teamFile.Name(), []byte(fmt.Sprintf(teamTemplate, "", teamName)), 0o644)
require.NoError(t, err)
s.assertDryRunOutput(t, fleetctltest.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", teamFile.Name(), "--dry-run"}))
s.assertRealRunOutput(t, fleetctltest.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", teamFile.Name()}))
titles, _, _, err = s.DS.ListSoftwareTitles(ctx, fleet.SoftwareTitleListOptions{AvailableForInstall: true, TeamID: &team.ID},
fleet.TeamFilter{User: test.UserAdmin})
require.NoError(t, err)
require.Len(t, titles, 0)
}
// TestGitOpsSoftwareDisplayName tests that display names for software packages and VPP apps
// are properly applied via GitOps.
func (s *enterpriseIntegrationGitopsTestSuite) TestGitOpsSoftwareDisplayName() {
t := s.T()
ctx := context.Background()
user := s.createGitOpsUser(t)
fleetctlConfig := s.createFleetctlConfig(t, user)
const (
globalTemplate = `
agent_options:
controls:
org_settings:
server_settings:
server_url: $FLEET_URL
org_info:
org_name: Fleet
secrets:
policies:
reports:
`
noTeamTemplate = `name: Unassigned
controls:
policies:
software:
packages:
- url: ${SOFTWARE_INSTALLER_URL}/ruby.deb
display_name: Custom Ruby Name
`
teamTemplate = `
controls:
software:
packages:
- url: ${SOFTWARE_INSTALLER_URL}/ruby.deb
display_name: Team Custom Ruby
reports:
policies:
agent_options:
name: %s
settings:
secrets: [{"secret":"enroll_secret"}]
`
)
globalFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = globalFile.WriteString(globalTemplate)
require.NoError(t, err)
err = globalFile.Close()
require.NoError(t, err)
noTeamFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = noTeamFile.WriteString(noTeamTemplate)
require.NoError(t, err)
err = noTeamFile.Close()
require.NoError(t, err)
noTeamFilePath := filepath.Join(filepath.Dir(noTeamFile.Name()), "unassigned.yml")
err = os.Rename(noTeamFile.Name(), noTeamFilePath)
require.NoError(t, err)
teamName := uuid.NewString()
teamFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = teamFile.WriteString(fmt.Sprintf(teamTemplate, teamName))
require.NoError(t, err)
err = teamFile.Close()
require.NoError(t, err)
// Set the required environment variables
t.Setenv("FLEET_URL", s.Server.URL)
testing_utils.StartSoftwareInstallerServer(t)
// Apply configs
s.assertDryRunOutput(t, fleetctltest.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", noTeamFilePath, "-f", teamFile.Name(), "--dry-run"}))
s.assertRealRunOutput(t, fleetctltest.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", noTeamFilePath, "-f", teamFile.Name()}))
// get the team ID
team, err := s.DS.TeamByName(ctx, teamName)
require.NoError(t, err)
// Verify display name for no team
noTeamTitles, _, _, err := s.DS.ListSoftwareTitles(ctx, fleet.SoftwareTitleListOptions{AvailableForInstall: true, TeamID: ptr.Uint(0)},
fleet.TeamFilter{User: test.UserAdmin})
require.NoError(t, err)
require.Len(t, noTeamTitles, 1)
require.NotNil(t, noTeamTitles[0].SoftwarePackage)
noTeamTitleID := noTeamTitles[0].ID
// Verify the display name is stored in the database for no team
var noTeamDisplayName string
mysqltest.ExecAdhocSQL(t, s.DS, func(q sqlx.ExtContext) error {
return sqlx.GetContext(ctx, q, &noTeamDisplayName,
"SELECT display_name FROM software_title_display_names WHERE team_id = ? AND software_title_id = ?",
0, noTeamTitleID)
})
require.Equal(t, "Custom Ruby Name", noTeamDisplayName)
// Verify display name for team
teamTitles, _, _, err := s.DS.ListSoftwareTitles(ctx, fleet.SoftwareTitleListOptions{TeamID: &team.ID}, fleet.TeamFilter{User: test.UserAdmin})
require.NoError(t, err)
require.Len(t, teamTitles, 1)
require.NotNil(t, teamTitles[0].SoftwarePackage)
teamTitleID := teamTitles[0].ID
// Verify the display name is stored in the database for team
var teamDisplayName string
mysqltest.ExecAdhocSQL(t, s.DS, func(q sqlx.ExtContext) error {
return sqlx.GetContext(ctx, q, &teamDisplayName,
"SELECT display_name FROM software_title_display_names WHERE team_id = ? AND software_title_id = ?",
team.ID, teamTitleID)
})
require.Equal(t, "Team Custom Ruby", teamDisplayName)
}
// TestGitOpsSoftwareIcons tests that custom icons for software packages
// and fleet maintained apps are properly applied via GitOps.
func (s *enterpriseIntegrationGitopsTestSuite) TestGitOpsSoftwareIcons() {
t := s.T()
ctx := context.Background()
user := s.createGitOpsUser(t)
fleetctlConfig := s.createFleetctlConfig(t, user)
const (
globalTemplate = `
agent_options:
controls:
org_settings:
server_settings:
server_url: $FLEET_URL
org_info:
org_name: Fleet
secrets:
policies:
reports:
`
noTeamTemplate = `name: Unassigned
controls:
policies:
software:
packages:
- url: ${SOFTWARE_INSTALLER_URL}/ruby.deb
icon:
path: %s/testdata/gitops/lib/icon.png
fleet_maintained_apps:
- slug: foo/darwin
icon:
path: %s/testdata/gitops/lib/icon.png
`
teamTemplate = `
controls:
software:
packages:
- url: ${SOFTWARE_INSTALLER_URL}/ruby.deb
icon:
path: %s/testdata/gitops/lib/icon.png
fleet_maintained_apps:
- slug: foo/darwin
icon:
path: %s/testdata/gitops/lib/icon.png
reports:
policies:
agent_options:
name: %s
settings:
secrets: [{"secret":"enroll_secret"}]
`
)
// Get the absolute path to the directory of this test file
_, currentFile, _, ok := runtime.Caller(0)
require.True(t, ok, "failed to get runtime caller info")
dirPath := filepath.Dir(currentFile)
dirPath = filepath.Join(dirPath, "../../fleetctl")
dirPath, err := filepath.Abs(filepath.Clean(dirPath))
require.NoError(t, err)
globalFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = globalFile.WriteString(globalTemplate)
require.NoError(t, err)
err = globalFile.Close()
require.NoError(t, err)
noTeamFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = fmt.Fprintf(noTeamFile, noTeamTemplate, dirPath, dirPath)
require.NoError(t, err)
err = noTeamFile.Close()
require.NoError(t, err)
noTeamFilePath := filepath.Join(filepath.Dir(noTeamFile.Name()), "unassigned.yml")
err = os.Rename(noTeamFile.Name(), noTeamFilePath)
require.NoError(t, err)
teamName := uuid.NewString()
teamFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = fmt.Fprintf(teamFile, teamTemplate, dirPath, dirPath, teamName)
require.NoError(t, err)
err = teamFile.Close()
require.NoError(t, err)
// Set the required environment variables
t.Setenv("FLEET_URL", s.Server.URL)
testing_utils.StartSoftwareInstallerServer(t)
// Mock server to serve fleet maintained app installer
installerBytes := []byte("foo")
installerServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(installerBytes)
}))
defer installerServer.Close()
// Mock server to serve fleet maintained app manifest
manifestServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var versions []*ma.FMAManifestApp
versions = append(versions, &ma.FMAManifestApp{
Version: "6.0",
Queries: ma.FMAQueries{
Exists: "SELECT 1 FROM osquery_info;",
},
InstallerURL: installerServer.URL + "/foo.pkg",
InstallScriptRef: "foobaz",
UninstallScriptRef: "foobaz",
SHA256: "no_check", // See ma.noCheckHash
})
manifest := ma.FMAManifestFile{
Versions: versions,
Refs: map[string]string{
"foobaz": "Hello World!",
},
}
err := json.NewEncoder(w).Encode(manifest)
require.NoError(t, err)
}))
t.Cleanup(manifestServer.Close)
dev_mode.SetOverride("FLEET_DEV_MAINTAINED_APPS_BASE_URL", manifestServer.URL, t)
mysqltest.ExecAdhocSQL(t, s.DS, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx, `INSERT INTO fleet_maintained_apps (name, slug, platform, unique_identifier)
VALUES ('foo', 'foo/darwin', 'darwin', 'com.example.foo')`)
return err
})
// Apply configs
s.assertDryRunOutput(t, fleetctltest.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", noTeamFilePath, "-f", teamFile.Name(), "--dry-run"}))
s.assertRealRunOutput(t, fleetctltest.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", noTeamFilePath, "-f", teamFile.Name()}))
// Get the team ID
team, err := s.DS.TeamByName(ctx, teamName)
require.NoError(t, err)
// Verify titles were added for no team
noTeamTitles, _, _, err := s.DS.ListSoftwareTitles(ctx, fleet.SoftwareTitleListOptions{AvailableForInstall: true, TeamID: ptr.Uint(0)},
fleet.TeamFilter{User: test.UserAdmin})
require.NoError(t, err)
require.Len(t, noTeamTitles, 2)
require.NotNil(t, noTeamTitles[0].SoftwarePackage)
require.NotNil(t, noTeamTitles[1].SoftwarePackage)
noTeamTitleIDs := []uint{noTeamTitles[0].ID, noTeamTitles[1].ID}
// Verify the custom icon is stored in the database for no team
var noTeamIconFilenames []string
mysqltest.ExecAdhocSQL(t, s.DS, func(q sqlx.ExtContext) error {
stmt, args, err := sqlx.In("SELECT filename FROM software_title_icons WHERE team_id = ? AND software_title_id IN (?)", 0, noTeamTitleIDs)
if err != nil {
return err
}
return sqlx.SelectContext(ctx, q, &noTeamIconFilenames, stmt, args...)
})
require.Len(t, noTeamIconFilenames, 2)
require.Equal(t, "icon.png", noTeamIconFilenames[0])
require.Equal(t, "icon.png", noTeamIconFilenames[1])
// Verify titles were added for team
teamTitles, _, _, err := s.DS.ListSoftwareTitles(ctx, fleet.SoftwareTitleListOptions{TeamID: &team.ID}, fleet.TeamFilter{User: test.UserAdmin})
require.NoError(t, err)
require.Len(t, teamTitles, 2)
require.NotNil(t, teamTitles[0].SoftwarePackage)
require.NotNil(t, teamTitles[1].SoftwarePackage)
teamTitleIDs := []uint{teamTitles[0].ID, teamTitles[1].ID}
// Verify the custom icon is stored in the database for team
var teamIconFilenames []string
mysqltest.ExecAdhocSQL(t, s.DS, func(q sqlx.ExtContext) error {
stmt, args, err := sqlx.In("SELECT filename FROM software_title_icons WHERE team_id = ? AND software_title_id IN (?)", team.ID, teamTitleIDs)
if err != nil {
return err
}
return sqlx.SelectContext(ctx, q, &teamIconFilenames, stmt, args...)
})
require.Len(t, teamIconFilenames, 2)
require.Equal(t, "icon.png", teamIconFilenames[0])
require.Equal(t, "icon.png", teamIconFilenames[1])
}
// TestGitOpsIconRecoversAfterClearingStaleHash documents the recovery path
// when an icon row's storage_id refers to bytes that are no longer in the
// store. The planner short-circuits any subsequent gitops run because the
// row's hash still matches the local YAML; clearing the row's storage_id
// forces the next run to re-upload.
func (s *enterpriseIntegrationGitopsTestSuite) TestGitOpsIconRecoversAfterClearingStaleHash() {
t := s.T()
ctx := context.Background()
user := s.createGitOpsUser(t)
fleetctlConfig := s.createFleetctlConfig(t, user)
const (
globalTemplate = `
agent_options:
controls:
org_settings:
server_settings:
server_url: $FLEET_URL
org_info:
org_name: Fleet
secrets:
policies:
reports:
`
teamTemplate = `
controls:
software:
packages:
- url: ${SOFTWARE_INSTALLER_URL}/ruby.deb
icon:
path: %s/testdata/gitops/lib/icon.png
reports:
policies:
agent_options:
name: %s
settings:
secrets: [{"secret":"enroll_secret"}]
`
)
// Resolve the absolute path to the testdata icon used by the YAML.
_, currentFile, _, ok := runtime.Caller(0)
require.True(t, ok, "failed to get runtime caller info")
dirPath := filepath.Dir(currentFile)
dirPath = filepath.Join(dirPath, "../../fleetctl")
dirPath, err := filepath.Abs(filepath.Clean(dirPath))
require.NoError(t, err)
globalFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = globalFile.WriteString(globalTemplate)
require.NoError(t, err)
require.NoError(t, globalFile.Close())
teamName := uuid.NewString()
teamFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = fmt.Fprintf(teamFile, teamTemplate, dirPath, teamName)
require.NoError(t, err)
require.NoError(t, teamFile.Close())
t.Setenv("FLEET_URL", s.Server.URL)
testing_utils.StartSoftwareInstallerServer(t)
firstRunOut := fleetctltest.RunAppForTest(t, []string{
"gitops", "--config", fleetctlConfig.Name(),
"-f", globalFile.Name(), "-f", teamFile.Name(),
})
s.assertRealRunOutput(t, firstRunOut)
require.Contains(t, firstRunOut, "set icons on 1 software title")
team, err := s.DS.TeamByName(ctx, teamName)
require.NoError(t, err)
titles, _, _, err := s.DS.ListSoftwareTitles(ctx,
fleet.SoftwareTitleListOptions{TeamID: &team.ID},
fleet.TeamFilter{User: test.UserAdmin})
require.NoError(t, err)
require.Len(t, titles, 1)
var storageID string
mysqltest.ExecAdhocSQL(t, s.DS, func(q sqlx.ExtContext) error {
return sqlx.GetContext(ctx, q, &storageID,
"SELECT storage_id FROM software_title_icons WHERE team_id = ? AND software_title_id = ?",
team.ID, titles[0].ID)
})
require.NotEmpty(t, storageID)
iconPath := filepath.Join(s.iconDir, "software-title-icons", storageID)
info, err := os.Stat(iconPath)
require.NoError(t, err)
originalSize := info.Size()
require.Positive(t, originalSize)
// Truncate the bytes on disk while leaving the icon row intact.
require.NoError(t, os.Truncate(iconPath, 0))
// A second gitops run with the unchanged YAML silently no-ops because
// the planner short-circuits before any API call. This pins down that
// known limitation; the recovery below is what restores the icon.
secondRunOut := fleetctltest.RunAppForTest(t, []string{
"gitops", "--config", fleetctlConfig.Name(),
"-f", globalFile.Name(), "-f", teamFile.Name(),
})
s.assertRealRunOutput(t, secondRunOut)
require.Contains(t, secondRunOut, "set icons on 0 software titles")
info, err = os.Stat(iconPath)
require.NoError(t, err)
require.Equal(t, int64(0), info.Size())
// Recovery: clearing storage_id forces a hash mismatch and re-upload
// on the next run.
mysqltest.ExecAdhocSQL(t, s.DS, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx,
"UPDATE software_title_icons SET storage_id = '' WHERE team_id = ? AND software_title_id = ?",
team.ID, titles[0].ID)
return err
})
thirdRunOut := fleetctltest.RunAppForTest(t, []string{
"gitops", "--config", fleetctlConfig.Name(),
"-f", globalFile.Name(), "-f", teamFile.Name(),
})
s.assertRealRunOutput(t, thirdRunOut)
require.Contains(t, thirdRunOut, "set icons on 1 software title")
info, err = os.Stat(iconPath)
require.NoError(t, err)
require.Equal(t, originalSize, info.Size())
}
// TestGitOpsIconUpdateRecoversWhenBytesAreMissing exercises the gitops
// client's fallback from a metadata-only icon update to a full upload
// when the server reports the bytes for a known hash are missing. Two
// titles in the same team share an icon. After the first run we mutate
// one title's icon row to point at a hash with no bytes and truncate
// the real bytes; the next gitops run forces that title through the
// update path with a hash whose bytes are missing, and the client must
// recover by re-uploading.
func (s *enterpriseIntegrationGitopsTestSuite) TestGitOpsIconUpdateRecoversWhenBytesAreMissing() {
t := s.T()
ctx := context.Background()
user := s.createGitOpsUser(t)
fleetctlConfig := s.createFleetctlConfig(t, user)
const (
globalTemplate = `
agent_options:
controls:
org_settings:
server_settings:
server_url: $FLEET_URL
org_info:
org_name: Fleet
secrets:
policies:
reports:
`
teamTemplate = `
controls:
software:
packages:
- url: ${SOFTWARE_INSTALLER_URL}/ruby.deb
icon:
path: %s/testdata/gitops/lib/icon.png
- url: ${SOFTWARE_INSTALLER_URL}/vim.deb
icon:
path: %s/testdata/gitops/lib/icon.png
reports:
policies:
agent_options:
name: %s
settings:
secrets: [{"secret":"enroll_secret"}]
`
)
_, currentFile, _, ok := runtime.Caller(0)
require.True(t, ok, "failed to get runtime caller info")
dirPath := filepath.Dir(currentFile)
dirPath = filepath.Join(dirPath, "../../fleetctl")
dirPath, err := filepath.Abs(filepath.Clean(dirPath))
require.NoError(t, err)
globalFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = globalFile.WriteString(globalTemplate)
require.NoError(t, err)
require.NoError(t, globalFile.Close())
teamName := uuid.NewString()
teamFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = fmt.Fprintf(teamFile, teamTemplate, dirPath, dirPath, teamName)
require.NoError(t, err)
require.NoError(t, teamFile.Close())
t.Setenv("FLEET_URL", s.Server.URL)
testing_utils.StartSoftwareInstallerServer(t)
firstRunOut := fleetctltest.RunAppForTest(t, []string{
"gitops", "--config", fleetctlConfig.Name(),
"-f", globalFile.Name(), "-f", teamFile.Name(),
})
s.assertRealRunOutput(t, firstRunOut)
require.Contains(t, firstRunOut, "set icons on 2 software titles")
team, err := s.DS.TeamByName(ctx, teamName)
require.NoError(t, err)
titles, _, _, err := s.DS.ListSoftwareTitles(ctx,
fleet.SoftwareTitleListOptions{TeamID: &team.ID},
fleet.TeamFilter{User: test.UserAdmin})
require.NoError(t, err)
require.Len(t, titles, 2)
var storageIDs []string
mysqltest.ExecAdhocSQL(t, s.DS, func(q sqlx.ExtContext) error {
return sqlx.SelectContext(ctx, q, &storageIDs,
"SELECT DISTINCT storage_id FROM software_title_icons WHERE team_id = ?", team.ID)
})
require.Len(t, storageIDs, 1)
storageID := storageIDs[0]
iconPath := filepath.Join(s.iconDir, "software-title-icons", storageID)
info, err := os.Stat(iconPath)
require.NoError(t, err)
originalSize := info.Size()
require.Positive(t, originalSize)
// Force divergence: redirect one title's icon row to a fake hash
// (no bytes will exist at that storage id) and truncate the real
// bytes so the integrity check on the genuine hash also fails. On
// the next gitops run, the title with the fake storage_id will be
// routed through IconsToUpdate (its server hash differs from the
// local hash, but the local hash is in UploadedHashes from the
// other title's row), and the server will refuse the metadata-only
// update because the bytes for the local hash are gone.
const fakeHash = "deadbeef0000000000000000000000000000000000000000000000000000beef"
mysqltest.ExecAdhocSQL(t, s.DS, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx,
"UPDATE software_title_icons SET storage_id = ? WHERE team_id = ? AND software_title_id = ?",
fakeHash, team.ID, titles[0].ID)
return err
})
require.NoError(t, os.Truncate(iconPath, 0))
secondRunOut := fleetctltest.RunAppForTest(t, []string{
"gitops", "--config", fleetctlConfig.Name(),
"-f", globalFile.Name(), "-f", teamFile.Name(),
})
s.assertRealRunOutput(t, secondRunOut)
info, err = os.Stat(iconPath)
require.NoError(t, err)
require.Equal(t, originalSize, info.Size())
var afterStorageIDs []string
mysqltest.ExecAdhocSQL(t, s.DS, func(q sqlx.ExtContext) error {
return sqlx.SelectContext(ctx, q, &afterStorageIDs,
"SELECT DISTINCT storage_id FROM software_title_icons WHERE team_id = ?", team.ID)
})
require.Len(t, afterStorageIDs, 1)
require.Equal(t, storageID, afterStorageIDs[0])
}
// TestGitOpsTeamLabels tests operations around team labels
func (s *enterpriseIntegrationGitopsTestSuite) TestGitOpsTeamLabels() {
t := s.T()
ctx := context.Background()
user := s.createGitOpsUser(t)
fleetCfg := s.createFleetctlConfig(t, user)
globalFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
// -----------------------------------------------------------------
// First, let's validate that we can add labels to the global scope
// -----------------------------------------------------------------
require.NoError(t, os.WriteFile(globalFile.Name(), []byte(`
agent_options:
controls:
org_settings:
secrets:
- secret: test_secret
policies:
reports:
labels:
- name: global-label-one
label_membership_type: dynamic
query: SELECT 1
- name: global-label-two
label_membership_type: dynamic
query: SELECT 1
`), 0o644))
s.assertDryRunOutput(t, fleetctltest.RunAppForTest(t, []string{"gitops", "--config", fleetCfg.Name(), "-f", globalFile.Name(), "--dry-run"}))
s.assertRealRunOutput(t, fleetctltest.RunAppForTest(t, []string{"gitops", "--config", fleetCfg.Name(), "-f", globalFile.Name()}))
expected := make(map[string]uint)
expected["global-label-one"] = 0
expected["global-label-two"] = 0
got := labelTeamIDResult(t, s, ctx)
require.True(t, maps.Equal(expected, got))
// ---------------------------------------------------------------
// Now, let's validate that we can add and remove labels in a team
// ---------------------------------------------------------------
// TeamOne already exists
teamOneName := uuid.NewString()
teamOne, err := s.DS.NewTeam(context.Background(), &fleet.Team{Name: teamOneName})
require.NoError(t, err)
teamOneFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
require.NoError(t, os.WriteFile(teamOneFile.Name(), fmt.Appendf(nil,
`
controls:
software:
reports:
policies:
agent_options:
name: %s
settings:
secrets: [{"secret":"enroll_secret"}]
labels:
- name: team-one-label-one
label_membership_type: dynamic
query: SELECT 2
- name: team-one-label-two
label_membership_type: dynamic
query: SELECT 3
`, teamOneName), 0o644))
s.assertDryRunOutput(t, fleetctltest.RunAppForTest(t, []string{"gitops", "--config", fleetCfg.Name(), "-f", teamOneFile.Name(), "--dry-run"}))
s.assertRealRunOutput(t, fleetctltest.RunAppForTest(t, []string{"gitops", "--config", fleetCfg.Name(), "-f", teamOneFile.Name()}))
got = labelTeamIDResult(t, s, ctx)
expected = make(map[string]uint)
expected["global-label-one"] = 0
expected["global-label-two"] = 0
expected["team-one-label-one"] = teamOne.ID
expected["team-one-label-two"] = teamOne.ID
require.True(t, maps.Equal(expected, got))
// Try removing one label from teamOne
require.NoError(t, os.WriteFile(teamOneFile.Name(), fmt.Appendf(nil,
`
controls:
software:
reports:
policies:
agent_options:
name: %s
settings:
secrets: [{"secret":"enroll_secret"}]
labels:
- name: team-one-label-one
label_membership_type: dynamic
query: SELECT 2
`, teamOneName), 0o644))
s.assertRealRunOutput(t, fleetctltest.RunAppForTest(t, []string{"gitops", "--config", fleetCfg.Name(), "-f", globalFile.Name(), "-f", teamOneFile.Name()}))
expected = make(map[string]uint)
expected["global-label-one"] = 0
expected["global-label-two"] = 0
expected["team-one-label-one"] = teamOne.ID
got = labelTeamIDResult(t, s, ctx)
require.True(t, maps.Equal(expected, got))
// ------------------------------------------------
// Finally, let's validate that we can move labels around
// ------------------------------------------------
require.NoError(t, os.WriteFile(globalFile.Name(), []byte(`
agent_options:
controls:
org_settings:
secrets:
- secret: test_secret
policies:
reports:
labels:
- name: global-label-one
label_membership_type: dynamic
query: SELECT 1
`), 0o644))
require.NoError(t, os.WriteFile(teamOneFile.Name(), fmt.Appendf(nil,
`
controls:
software:
reports:
policies:
agent_options:
name: %s
settings:
secrets: [{"secret":"enroll_secret"}]
labels:
- name: team-one-label-two
label_membership_type: dynamic
query: SELECT 3
- name: global-label-two
label_membership_type: dynamic
query: SELECT 1
`, teamOneName), 0o644))
teamTwoName := uuid.NewString()
teamTwoFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
require.NoError(t, os.WriteFile(teamTwoFile.Name(), fmt.Appendf(nil, `
controls:
software:
reports:
policies:
agent_options:
name: %s
settings:
secrets: [{"secret":"enroll_secret2"}]
labels:
- name: team-one-label-one
label_membership_type: dynamic
query: SELECT 2
`, teamTwoName), 0o644))
s.assertDryRunOutput(t, fleetctltest.RunAppForTest(t, []string{"gitops", "--config", fleetCfg.Name(), "-f", globalFile.Name(), "-f", teamOneFile.Name(), "-f", teamTwoFile.Name(), "--dry-run"}))
// TODO: Seems like we require two passes to achieve equilibrium?
s.assertRealRunOutput(t, fleetctltest.RunAppForTest(t, []string{"gitops", "--config", fleetCfg.Name(), "-f", globalFile.Name(), "-f", teamOneFile.Name(), "-f", teamTwoFile.Name()}))
s.assertRealRunOutput(t, fleetctltest.RunAppForTest(t, []string{"gitops", "--config", fleetCfg.Name(), "-f", globalFile.Name(), "-f", teamOneFile.Name(), "-f", teamTwoFile.Name()}))
teamTwo, err := s.DS.TeamByName(ctx, teamTwoName)
require.NoError(t, err)
got = labelTeamIDResult(t, s, ctx)
expected = make(map[string]uint)
expected["global-label-one"] = 0
expected["team-one-label-two"] = teamOne.ID
expected["global-label-two"] = teamOne.ID
expected["team-one-label-one"] = teamTwo.ID
require.True(t, maps.Equal(expected, got))
}
// Tests a gitops setup where every team runs from an independent repo. Multiple repos are simulated by
// copying over the example repository multiple times.
func (s *enterpriseIntegrationGitopsTestSuite) TestGitOpsTeamLabelsMultipleRepos() {
t := s.T()
ctx := context.Background()
var users []fleet.User
var cfgPaths []*os.File
var reposDir []string
for range 2 {
user := s.createGitOpsUser(t)
users = append(users, user)
cfg := s.createFleetctlConfig(t, user)
cfgPaths = append(cfgPaths, cfg)
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)
reposDir = append(reposDir, repoDir)
}
// Set the required environment variables
t.Setenv("FLEET_URL", s.Server.URL)
t.Setenv("FLEET_GLOBAL_ENROLL_SECRET", "global_enroll_secret")
t.Setenv("FLEET_WORKSTATIONS_ENROLL_SECRET", "workstations_enroll_secret")
t.Setenv("FLEET_WORKSTATIONS_CANARY_ENROLL_SECRET", "workstations_canary_enroll_secret")
type tmplParams struct {
Name string
Queries string
Labels string
}
teamCfgTmpl, err := template.New("t1").Parse(`
controls:
software:
reports:{{ .Queries }}
policies:
labels:{{ .Labels }}
agent_options:
name:{{ .Name }}
settings:
secrets: [{"secret":"{{ .Name}}_secret"}]
`)
require.NoError(t, err)
// --------------------------------------------------
// First, lets simulate adding a new team per repo
// --------------------------------------------------
for i, repo := range reposDir {
globalFile := path.Join(repo, "default.yml")
newTeamCfgFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
require.NoError(t, teamCfgTmpl.Execute(newTeamCfgFile, tmplParams{
Name: fmt.Sprintf(" team-%d", i),
Queries: fmt.Sprintf("\n - name: query-%d\n query: SELECT 1", i),
Labels: fmt.Sprintf("\n - name: label-%d\n label_membership_type: dynamic\n query: SELECT 1", i),
}))
args := []string{"gitops", "--config", cfgPaths[i].Name(), "-f", globalFile, "-f", newTeamCfgFile.Name()}
s.assertRealRunOutputWithDeprecation(t, fleetctltest.RunAppForTest(t, args), true)
}
for i, user := range users {
team, err := s.DS.TeamByName(ctx, fmt.Sprintf("team-%d", i))
require.NoError(t, err)
require.NotNil(t, team)
queries, _, _, _, err := s.DS.ListQueries(ctx, fleet.ListQueryOptions{TeamID: &team.ID})
require.NoError(t, err)
require.Len(t, queries, 1)
require.Equal(t, fmt.Sprintf("query-%d", i), queries[0].Name)
require.Equal(t, "SELECT 1", queries[0].Query)
require.NotNil(t, queries[0].TeamID)
require.Equal(t, *queries[0].TeamID, team.ID)
require.NotNil(t, queries[0].AuthorID)
require.Equal(t, *queries[0].AuthorID, user.ID)
label, err := s.DS.LabelByName(ctx, fmt.Sprintf("label-%d", i), fleet.TeamFilter{User: &fleet.User{ID: user.ID}})
require.NoError(t, err)
require.NotNil(t, label)
require.NotNil(t, label.TeamID)
require.Equal(t, *label.TeamID, team.ID)
require.NotNil(t, label.AuthorID)
require.Equal(t, *label.AuthorID, user.ID)
}
// -----------------------------------------------------------------
// Then, lets simulate a mutation by dropping the labels on team one
// -----------------------------------------------------------------
for i, repo := range reposDir {
globalFile := path.Join(repo, "default.yml")
newTeamCfgFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
params := tmplParams{
Name: fmt.Sprintf(" team-%d", i),
Queries: fmt.Sprintf("\n - name: query-%d\n query: SELECT 1", i),
}
if i != 0 {
params.Labels = fmt.Sprintf("\n - name: label-%d\n label_membership_type: dynamic\n query: SELECT 1", i)
}
require.NoError(t, teamCfgTmpl.Execute(newTeamCfgFile, params))
args := []string{"gitops", "--config", cfgPaths[i].Name(), "-f", globalFile, "-f", newTeamCfgFile.Name()}
s.assertRealRunOutputWithDeprecation(t, fleetctltest.RunAppForTest(t, args), true)
}
for i, user := range users {
team, err := s.DS.TeamByName(ctx, fmt.Sprintf("team-%d", i))
require.NoError(t, err)
require.NotNil(t, team)
queries, _, _, _, err := s.DS.ListQueries(ctx, fleet.ListQueryOptions{TeamID: &team.ID})
require.NoError(t, err)
require.Len(t, queries, 1)
require.Equal(t, fmt.Sprintf("query-%d", i), queries[0].Name)
require.Equal(t, "SELECT 1", queries[0].Query)
require.NotNil(t, queries[0].TeamID)
require.Equal(t, *queries[0].TeamID, team.ID)
require.NotNil(t, queries[0].AuthorID)
require.Equal(t, *queries[0].AuthorID, user.ID)
label, err := s.DS.LabelByName(ctx, fmt.Sprintf("label-%d", i), fleet.TeamFilter{User: &fleet.User{ID: user.ID}})
if i == 0 {
require.Error(t, err)
require.Nil(t, label)
} else {
require.NoError(t, err)
require.NotNil(t, label)
require.NotNil(t, label.TeamID)
require.Equal(t, *label.TeamID, team.ID)
require.NotNil(t, label.AuthorID)
require.Equal(t, *label.AuthorID, user.ID)
}
}
}
func labelTeamIDResult(t *testing.T, s *enterpriseIntegrationGitopsTestSuite, ctx context.Context) map[string]uint {
type labelResult struct {
Name string `db:"name"`
TeamID *uint `db:"team_id"`
}
var result []labelResult
mysqltest.ExecAdhocSQL(t, s.DS, func(q sqlx.ExtContext) error {
require.NoError(t, sqlx.SelectContext(ctx, q, &result, "SELECT name, team_id FROM labels WHERE label_type = 0"))
return nil
})
got := make(map[string]uint)
for _, r := range result {
var teamID uint
if r.TeamID != nil {
teamID = *r.TeamID
}
got[r.Name] = teamID
}
return got
}
// captureLabelActivities replaces the suite's activity mock with a recorder.
// It returns a function that returns and resets the captured label activities.
// Cleanup restores the previous NewActivityFunc.
func (s *enterpriseIntegrationGitopsTestSuite) captureLabelActivities(t *testing.T) func() []activity_api.ActivityDetails {
t.Helper()
require.NotNil(t, s.activityMock, "activity mock should be wired up via TestServerOpts.ActivityMock")
prev := s.activityMock.NewActivityFunc
var (
mu sync.Mutex
captured []activity_api.ActivityDetails
)
s.activityMock.NewActivityFunc = func(ctx context.Context, user *activity_api.User, a activity_api.ActivityDetails) error {
switch a.(type) {
case fleet.ActivityTypeCreatedLabel, fleet.ActivityTypeEditedLabel, fleet.ActivityTypeDeletedLabel:
mu.Lock()
captured = append(captured, a)
mu.Unlock()
}
if prev != nil {
return prev(ctx, user, a)
}
return nil
}
t.Cleanup(func() { s.activityMock.NewActivityFunc = prev })
return func() []activity_api.ActivityDetails {
mu.Lock()
defer mu.Unlock()
out := captured
captured = nil
return out
}
}
func (s *enterpriseIntegrationGitopsTestSuite) TestGitOpsLabelActivities() {
t := s.T()
ctx := context.Background()
user := s.createGitOpsUser(t)
fleetCfg := s.createFleetctlConfig(t, user)
teamName := uuid.NewString()
_, err := s.DS.NewTeam(ctx, &fleet.Team{Name: teamName})
require.NoError(t, err)
// Hosts to assign to the manual label in later phases.
for _, h := range []string{"label-act-host-1", "label-act-host-2"} {
_, err := s.DS.NewHost(ctx, &fleet.Host{
UUID: h,
Hostname: h,
Platform: "linux",
HardwareSerial: h,
})
require.NoError(t, err)
}
globalFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
teamFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
writeGlobal := func(body string) {
require.NoError(t, os.WriteFile(globalFile.Name(), []byte(`
agent_options:
controls:
org_settings:
secrets:
- secret: test_secret
policies:
reports:
labels:
`+body), 0o644))
}
writeTeam := func(body string) {
require.NoError(t, os.WriteFile(teamFile.Name(), fmt.Appendf(nil, `
controls:
software:
reports:
policies:
agent_options:
name: %s
settings:
secrets: [{"secret":"enroll_secret"}]
labels:
%s`, teamName, body), 0o644))
}
apply := func() {
s.assertRealRunOutput(t, fleetctltest.RunAppForTest(t, []string{
"gitops", "--config", fleetCfg.Name(),
"-f", globalFile.Name(), "-f", teamFile.Name(),
}))
}
flush := s.captureLabelActivities(t)
// Phase 1: initial apply creates one global and one team label.
writeGlobal(` - name: lbl-global
description: original-global
label_membership_type: dynamic
query: SELECT 1
`)
writeTeam(` - name: lbl-team
description: original-team
label_membership_type: dynamic
query: SELECT 2
`)
apply()
got := flush()
require.Len(t, got, 2, "expected created_label for global + team")
byName := map[string]fleet.ActivityTypeCreatedLabel{}
for _, a := range got {
c, ok := a.(fleet.ActivityTypeCreatedLabel)
require.True(t, ok, "expected created_label, got %T", a)
byName[c.Name] = c
}
require.Contains(t, byName, "lbl-global")
require.Contains(t, byName, "lbl-team")
require.Nil(t, byName["lbl-global"].FleetID, "global label should have nil fleet_id")
require.NotNil(t, byName["lbl-team"].FleetID, "team label should have a fleet_id")
require.NotNil(t, byName["lbl-team"].FleetName)
require.Equal(t, teamName, *byName["lbl-team"].FleetName)
// Phase 2: re-apply identical specs — no activity should fire.
apply()
require.Empty(t, flush(), "no-op apply should produce no activity")
// Phase 3: edit the global label's description and the team label's query.
writeGlobal(` - name: lbl-global
description: edited-global
label_membership_type: dynamic
query: SELECT 1
`)
writeTeam(` - name: lbl-team
description: original-team
label_membership_type: dynamic
query: SELECT 99
`)
apply()
got = flush()
require.Len(t, got, 2, "expected edited_label for both edits")
editedNames := map[string]struct{}{}
for _, a := range got {
e, ok := a.(fleet.ActivityTypeEditedLabel)
require.True(t, ok, "expected edited_label, got %T", a)
editedNames[e.Name] = struct{}{}
}
require.Contains(t, editedNames, "lbl-global")
require.Contains(t, editedNames, "lbl-team")
// Phase 4: change lbl-global from dynamic to manual (membership_type swap)
// and assign one host. Should emit a single edited_label for lbl-global.
writeGlobal(` - name: lbl-global
description: edited-global
label_membership_type: manual
hosts:
- label-act-host-1
`)
writeTeam(` - name: lbl-team
description: original-team
label_membership_type: dynamic
query: SELECT 99
`)
apply()
got = flush()
require.Len(t, got, 1, "expected edited_label for membership_type change")
e, ok := got[0].(fleet.ActivityTypeEditedLabel)
require.True(t, ok, "expected edited_label, got %T", got[0])
require.Equal(t, "lbl-global", e.Name)
// Phase 5: extend the manual label's host list. Should emit a single
// edited_label even though all other fields stayed the same.
writeGlobal(` - name: lbl-global
description: edited-global
label_membership_type: manual
hosts:
- label-act-host-1
- label-act-host-2
`)
apply()
got = flush()
require.Len(t, got, 1, "expected edited_label for host list change")
e, ok = got[0].(fleet.ActivityTypeEditedLabel)
require.True(t, ok, "expected edited_label, got %T", got[0])
require.Equal(t, "lbl-global", e.Name)
// Phase 5b: re-apply same host list — must be a no-op.
apply()
require.Empty(t, flush(), "no-op host list should produce no activity")
// Phase 6: add a host_vitals label alongside the existing labels.
writeGlobal(` - name: lbl-global
description: edited-global
label_membership_type: manual
hosts:
- label-act-host-1
- label-act-host-2
- name: lbl-host-vitals
description: vitals-label
label_membership_type: host_vitals
criteria:
vital: end_user_idp_group
value: original-group
`)
apply()
got = flush()
require.Len(t, got, 1, "expected created_label for host_vitals label")
c, ok := got[0].(fleet.ActivityTypeCreatedLabel)
require.True(t, ok, "expected created_label, got %T", got[0])
require.Equal(t, "lbl-host-vitals", c.Name)
// Phase 7: update the host_vitals criteria — should emit edited_label.
writeGlobal(` - name: lbl-global
description: edited-global
label_membership_type: manual
hosts:
- label-act-host-1
- label-act-host-2
- name: lbl-host-vitals
description: vitals-label
label_membership_type: host_vitals
criteria:
vital: end_user_idp_group
value: updated-group
`)
apply()
got = flush()
require.Len(t, got, 1, "expected edited_label for criteria change")
e, ok = got[0].(fleet.ActivityTypeEditedLabel)
require.True(t, ok, "expected edited_label, got %T", got[0])
require.Equal(t, "lbl-host-vitals", e.Name)
// Phase 7b: re-apply the same criteria — must be a no-op.
apply()
require.Empty(t, flush(), "no-op criteria re-apply should produce no activity")
// Phase 8: remove all labels from the spec — gitops issues delete calls
// per name, which should each emit a deleted_label activity.
writeGlobal("")
writeTeam("")
apply()
got = flush()
require.Len(t, got, 3, "expected deleted_label for all three labels")
deletedNames := map[string]struct{}{}
for _, a := range got {
d, ok := a.(fleet.ActivityTypeDeletedLabel)
require.True(t, ok, "expected deleted_label, got %T", a)
deletedNames[d.Name] = struct{}{}
}
require.Contains(t, deletedNames, "lbl-global")
require.Contains(t, deletedNames, "lbl-team")
require.Contains(t, deletedNames, "lbl-host-vitals")
}
// captureDeletedPolicyActivities replaces the suite's activity mock with a
// recorder for deleted_policy activities. It returns a function that returns
// and resets the captured activities. Cleanup restores the previous
// NewActivityFunc.
func (s *enterpriseIntegrationGitopsTestSuite) captureDeletedPolicyActivities(t *testing.T) func() []fleet.ActivityTypeDeletedPolicy {
t.Helper()
require.NotNil(t, s.activityMock, "activity mock should be wired up via TestServerOpts.ActivityMock")
prev := s.activityMock.NewActivityFunc
var (
mu sync.Mutex
captured []fleet.ActivityTypeDeletedPolicy
)
s.activityMock.NewActivityFunc = func(ctx context.Context, user *activity_api.User, a activity_api.ActivityDetails) error {
if d, ok := a.(fleet.ActivityTypeDeletedPolicy); ok {
mu.Lock()
captured = append(captured, d)
mu.Unlock()
}
if prev != nil {
return prev(ctx, user, a)
}
return nil
}
t.Cleanup(func() { s.activityMock.NewActivityFunc = prev })
return func() []fleet.ActivityTypeDeletedPolicy {
mu.Lock()
defer mu.Unlock()
out := captured
captured = nil
return out
}
}
// setupDarwinFMA inserts a darwin FMA record and starts a per-FMA installer
// server. Returns the slug and the installer server URL. The caller is
// responsible for wiring a manifest server (FLEET_DEV_MAINTAINED_APPS_BASE_URL)
// that serves a manifest for /<slug>.json — calling this helper twice within
// the same test requires a single combined manifest server because
// dev_mode.SetOverride only supports one base URL at a time.
func (s *enterpriseIntegrationGitopsTestSuite) setupDarwinFMA(t *testing.T) (slug, installerURL string) {
t.Helper()
suffix := strings.ReplaceAll(uuid.NewString(), "-", "")
slug = fmt.Sprintf("foo%s/darwin", suffix)
mysqltest.ExecAdhocSQL(t, s.DS, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(t.Context(),
`INSERT INTO fleet_maintained_apps (name, slug, platform, unique_identifier)
VALUES (?, ?, 'darwin', ?)`, "foo"+suffix, slug, "com.example.foo"+suffix)
return err
})
installerServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("foo"))
}))
t.Cleanup(installerServer.Close)
return slug, installerServer.URL
}
func (s *enterpriseIntegrationGitopsTestSuite) TestGitOpsRemovedFMAEmitsPolicyDeletedActivities() {
t := s.T()
ctx := context.Background()
user := s.createGitOpsUser(t)
fleetctlConfig := s.createFleetctlConfig(t, user)
t.Setenv("FLEET_URL", s.Server.URL)
sharedSlug, sharedInstaller := s.setupDarwinFMA(t)
patchAndInstallSlug, patchAndInstallInstaller := s.setupDarwinFMA(t)
teamName := uuid.NewString()
manifestFor := func(installerURL string) ma.FMAManifestFile {
return ma.FMAManifestFile{
Versions: []*ma.FMAManifestApp{{
Version: "1.0",
Queries: ma.FMAQueries{Exists: "SELECT 1 FROM osquery_info;"},
InstallerURL: installerURL + "/foo.pkg",
InstallScriptRef: "fooscript",
UninstallScriptRef: "fooscript",
SHA256: "no_check", // See ma.noCheckHash
}},
Refs: map[string]string{"fooscript": "echo hello"},
}
}
manifestServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var installerURL string
switch r.URL.Path {
case "/" + sharedSlug + ".json":
installerURL = sharedInstaller
case "/" + patchAndInstallSlug + ".json":
installerURL = patchAndInstallInstaller
default:
http.NotFound(w, r)
return
}
_ = json.NewEncoder(w).Encode(manifestFor(installerURL))
}))
t.Cleanup(manifestServer.Close)
dev_mode.SetOverride("FLEET_DEV_MAINTAINED_APPS_BASE_URL", manifestServer.URL, t)
const globalConfig = `
agent_options:
controls:
org_settings:
server_settings:
server_url: $FLEET_URL
org_info:
org_name: Fleet
secrets:
policies:
reports:
`
teamWithFMAAndPolicies := fmt.Sprintf(`
controls:
software:
fleet_maintained_apps:
- slug: %s
- slug: %s
policies:
- name: install-policy
query: SELECT 1
install_software:
fleet_maintained_app_slug: %s
- name: patch-policy
type: patch
fleet_maintained_app_slug: %s
- name: patch-and-install-policy
type: patch
fleet_maintained_app_slug: %s
install_software:
fleet_maintained_app_slug: %s
agent_options:
name: %s
settings:
secrets: [{"secret":"enroll_secret"}]
reports:
`, sharedSlug, patchAndInstallSlug, sharedSlug, sharedSlug, patchAndInstallSlug, patchAndInstallSlug, teamName)
teamEmpty := fmt.Sprintf(`
controls:
software:
policies:
agent_options:
name: %s
settings:
secrets: [{"secret":"enroll_secret"}]
reports:
`, teamName)
globalFile := filepath.Join(t.TempDir(), "global.yml")
require.NoError(t, os.WriteFile(globalFile, []byte(globalConfig), 0o644))
teamFile := filepath.Join(t.TempDir(), "team.yml")
require.NoError(t, os.WriteFile(teamFile, []byte(teamWithFMAAndPolicies), 0o644))
s.assertRealRunOutput(t, fleetctltest.RunAppForTest(t, []string{
"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile, "-f", teamFile,
}))
team, err := s.DS.TeamByName(ctx, teamName)
require.NoError(t, err)
pols, err := s.DS.ListMergedTeamPolicies(ctx, team.ID, fleet.ListOptions{}, "", "")
require.NoError(t, err)
require.Len(t, pols, 3)
policyIDsByName := map[string]uint{}
for _, p := range pols {
policyIDsByName[p.Name] = p.ID
}
require.Contains(t, policyIDsByName, "install-policy")
require.Contains(t, policyIDsByName, "patch-policy")
require.Contains(t, policyIDsByName, "patch-and-install-policy")
flush := s.captureDeletedPolicyActivities(t)
require.NoError(t, os.WriteFile(teamFile, []byte(teamEmpty), 0o644))
s.assertRealRunOutput(t, fleetctltest.RunAppForTest(t, []string{
"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile, "-f", teamFile,
}))
pols, err = s.DS.ListMergedTeamPolicies(ctx, team.ID, fleet.ListOptions{}, "", "")
require.NoError(t, err)
require.Empty(t, pols, "all policies should be removed after FMA installer is removed")
deletedIDs := map[uint]bool{}
for _, d := range flush() {
deletedIDs[d.ID] = true
}
for _, name := range []string{"install-policy", "patch-policy", "patch-and-install-policy"} {
require.True(t, deletedIDs[policyIDsByName[name]],
"expected deleted_policy activity for %q (id=%d), got activities for IDs %v",
name, policyIDsByName[name], deletedIDs)
}
}
// TestGitOpsVPPAppAutoUpdate tests that auto-update settings for VPP apps (iOS/iPadOS)
// are properly applied via GitOps.
func (s *enterpriseIntegrationGitopsTestSuite) TestGitOpsVPPAppAutoUpdate() {
t := s.T()
ctx := context.Background()
user := s.createGitOpsUser(t)
fleetctlConfig := s.createFleetctlConfig(t, user)
// Create a global VPP token (location is "Jungle")
test.CreateInsertGlobalVPPToken(t, s.DS)
// Generate team name upfront since we need it in the global template
teamName := uuid.NewString()
// The global template includes VPP token assignment to the team
// The location "Jungle" comes from test.CreateInsertGlobalVPPToken
globalTemplate := fmt.Sprintf(`
agent_options:
controls:
org_settings:
server_settings:
server_url: $FLEET_URL
org_info:
org_name: Fleet
secrets:
mdm:
volume_purchasing_program:
- location: Jungle
teams:
- %s
policies:
reports:
`, teamName)
teamTemplate := `
controls:
software:
app_store_apps:
- app_store_id: "2"
platform: ios
self_service: false
auto_update_enabled: true
auto_update_window_start: "02:00"
auto_update_window_end: "06:00"
- app_store_id: "2"
platform: ipados
self_service: false
auto_update_enabled: true
auto_update_window_start: "03:00"
auto_update_window_end: "07:00"
reports:
policies:
agent_options:
name: %s
settings:
secrets: [{"secret":"enroll_secret"}]
`
globalFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = globalFile.WriteString(globalTemplate)
require.NoError(t, err)
err = globalFile.Close()
require.NoError(t, err)
teamFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = teamFile.WriteString(fmt.Sprintf(teamTemplate, teamName))
require.NoError(t, err)
err = teamFile.Close()
require.NoError(t, err)
t.Setenv("FLEET_URL", s.Server.URL)
testing_utils.StartAndServeVPPServer(t)
dryRunOutput := fleetctltest.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", teamFile.Name(), "--dry-run"})
require.Contains(t, dryRunOutput, "gitops dry run succeeded")
realRunOutput := fleetctltest.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", teamFile.Name()})
require.Contains(t, realRunOutput, "gitops succeeded")
team, err := s.DS.TeamByName(ctx, teamName)
require.NoError(t, err)
// Verify VPP apps were added
titles, _, _, err := s.DS.ListSoftwareTitles(ctx, fleet.SoftwareTitleListOptions{AvailableForInstall: true, TeamID: &team.ID},
fleet.TeamFilter{User: test.UserAdmin})
require.NoError(t, err)
require.Len(t, titles, 2) // One for iOS, one for iPadOS
// Verify auto-update schedules were created in the database
type autoUpdateSchedule struct {
TitleID uint `db:"title_id"`
TeamID uint `db:"team_id"`
Enabled bool `db:"enabled"`
StartTime string `db:"start_time"`
EndTime string `db:"end_time"`
}
var schedules []autoUpdateSchedule
mysqltest.ExecAdhocSQL(t, s.DS, func(q sqlx.ExtContext) error {
return sqlx.SelectContext(ctx, q, &schedules,
`SELECT title_id, team_id, enabled, start_time, end_time
FROM software_update_schedules
WHERE team_id = ?
ORDER BY title_id`, team.ID)
})
require.Len(t, schedules, 2)
for _, schedule := range schedules {
require.Equal(t, team.ID, schedule.TeamID)
require.True(t, schedule.Enabled)
var foundTitle *fleet.SoftwareTitleListResult
for i := range titles {
if titles[i].ID == schedule.TitleID {
foundTitle = &titles[i]
break
}
}
require.NotNil(t, foundTitle, "should find title for schedule")
// Verify the correct start/end times based on source
switch foundTitle.Source {
case "ios_apps":
require.Equal(t, "02:00", schedule.StartTime)
require.Equal(t, "06:00", schedule.EndTime)
case "ipados_apps":
require.Equal(t, "03:00", schedule.StartTime)
require.Equal(t, "07:00", schedule.EndTime)
default:
t.Fatalf("unexpected source: %s", foundTitle.Source)
}
}
// Now apply a config without auto-update fields for the iPadOS app and verify they're cleared
teamTemplateNoAutoUpdate := `
controls:
software:
app_store_apps:
- app_store_id: "2"
platform: ios
self_service: false
auto_update_enabled: true
auto_update_window_start: "02:00"
auto_update_window_end: "06:00"
- app_store_id: "2"
platform: ipados
self_service: false
reports:
policies:
agent_options:
name: %s
settings:
secrets: [{"secret":"enroll_secret"}]
`
teamFileNoAutoUpdate, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = teamFileNoAutoUpdate.WriteString(fmt.Sprintf(teamTemplateNoAutoUpdate, teamName))
require.NoError(t, err)
err = teamFileNoAutoUpdate.Close()
require.NoError(t, err)
// Apply the updated config
realRunOutput = fleetctltest.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", teamFileNoAutoUpdate.Name()})
require.Contains(t, realRunOutput, "gitops succeeded")
// Verify auto-update schedules: iOS should still have settings, iPadOS should be disabled
var updatedSchedules []autoUpdateSchedule
mysqltest.ExecAdhocSQL(t, s.DS, func(q sqlx.ExtContext) error {
return sqlx.SelectContext(ctx, q, &updatedSchedules,
`SELECT title_id, team_id, enabled, start_time, end_time
FROM software_update_schedules
WHERE team_id = ?
ORDER BY title_id`, team.ID)
})
require.Len(t, updatedSchedules, 2)
for _, schedule := range updatedSchedules {
var foundTitle *fleet.SoftwareTitleListResult
for i := range titles {
if titles[i].ID == schedule.TitleID {
foundTitle = &titles[i]
break
}
}
require.NotNil(t, foundTitle, "should find title for schedule")
switch foundTitle.Source {
case "ios_apps":
// iOS app should still have auto-update enabled
require.True(t, schedule.Enabled)
require.Equal(t, "02:00", schedule.StartTime)
require.Equal(t, "06:00", schedule.EndTime)
case "ipados_apps":
// iPadOS app should now have auto-update disabled (fields removed from config)
// but the previous start/end times should still be preserved in the database
require.False(t, schedule.Enabled)
require.Equal(t, "03:00", schedule.StartTime)
require.Equal(t, "07:00", schedule.EndTime)
default:
t.Fatalf("unexpected source: %s", foundTitle.Source)
}
}
}
// TestFleetDesktopSettingsBrowserAlternativeHost tests that user can mutate the fleet_desktop.alternative_browser_host
// setting via GitOps.
func (s *enterpriseIntegrationGitopsTestSuite) TestFleetDesktopSettingsBrowserAlternativeHost() {
t := s.T()
ctx := context.Background()
user := s.createGitOpsUser(t)
fleetCfg := s.createFleetctlConfig(t, user)
type tmplParams struct {
AlternativeBrowserHost string
}
globalCfgTpl, err := template.New("t1").Parse(`
agent_options:
controls:
reports:
policies:
org_settings:
secrets:
- secret: test_secret
fleet_desktop:
{{ .AlternativeBrowserHost }}
`)
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")
t.Setenv("FLEET_WORKSTATIONS_ENROLL_SECRET", "workstations_enroll_secret")
t.Setenv("FLEET_WORKSTATIONS_CANARY_ENROLL_SECRET", "workstations_canary_enroll_secret")
testCases := []struct {
Name string
AlternativeBrowserHost string
Expected string
ShouldError bool
}{
{
Name: "custom",
AlternativeBrowserHost: `alternative_browser_host: "example1.com"`,
Expected: "example1.com",
},
{
Name: "empty value",
AlternativeBrowserHost: `alternative_browser_host: ""`,
Expected: "",
},
{
Name: "invalid value",
AlternativeBrowserHost: `alternative_browser_host: "http://example2.com"`,
ShouldError: true,
},
}
for _, testCase := range testCases {
t.Run(testCase.Name, func(t *testing.T) {
globalCfgFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
require.NoError(t, globalCfgTpl.Execute(globalCfgFile, tmplParams{
AlternativeBrowserHost: testCase.AlternativeBrowserHost,
}))
if testCase.ShouldError {
fleetctltest.RunAppCheckErr(t, []string{"gitops", "--config", fleetCfg.Name(), "-f", globalCfgFile.Name()}, "applying fleet config: PATCH /api/latest/fleet/config received status 422 Validation Failed: must be a valid hostname or IP address")
} else {
s.assertDryRunOutput(t, fleetctltest.RunAppForTest(t, []string{"gitops", "--config", fleetCfg.Name(), "-f", globalCfgFile.Name(), "--dry-run"}))
s.assertRealRunOutput(t, fleetctltest.RunAppForTest(t, []string{"gitops", "--config", fleetCfg.Name(), "-f", globalCfgFile.Name()}))
}
storedCfg, err := s.DS.AppConfig(ctx)
require.NoError(t, err)
require.NotNil(t, storedCfg)
require.Equal(t, testCase.Expected, storedCfg.FleetDesktop.AlternativeBrowserHost)
})
}
}
func (s *enterpriseIntegrationGitopsTestSuite) TestSpecialCaseTeamsVPPApps() {
t := s.T()
ctx := context.Background()
user := s.createGitOpsUser(t)
fleetctlConfig := s.createFleetctlConfig(t, user)
// Create a global VPP token (location is "Jungle")
test.CreateInsertGlobalVPPToken(t, s.DS)
// Generate team name upfront since we need it in the global template
teamName := uuid.NewString()
// The global template includes VPP token assignment to the team
// The location "Jungle" comes from test.CreateInsertGlobalVPPToken
globalTemplate := `agent_options:
controls:
org_settings:
server_settings:
server_url: $FLEET_URL
org_info:
org_name: Fleet
secrets:
- secret: foobar
mdm:
volume_purchasing_program:
- location: Jungle
teams:
- %s
policies:
reports:
`
teamTemplate := `
controls:
software:
app_store_apps:
- app_store_id: "2"
platform: ios
self_service: false
auto_update_enabled: true
auto_update_window_start: "02:00"
auto_update_window_end: "06:00"
- app_store_id: "2"
platform: ipados
self_service: false
auto_update_enabled: true
auto_update_window_start: "03:00"
auto_update_window_end: "07:00"
reports:
policies:
agent_options:
name: %s
settings:
%s
`
testCases := []struct {
specialCase string
teamName string
teamSettings string
}{
{
specialCase: "All teams",
teamName: teamName,
teamSettings: `secrets: [{"secret":"enroll_secret"}]`,
},
{
specialCase: "No team",
teamName: "Unassigned",
},
}
for _, tc := range testCases {
t.Run(tc.specialCase, func(t *testing.T) {
globalFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
globalYAML := fmt.Sprintf(globalTemplate, tc.specialCase)
_, err = globalFile.WriteString(globalYAML)
require.NoError(t, err)
err = globalFile.Close()
require.NoError(t, err)
teamFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = fmt.Fprintf(teamFile, teamTemplate, tc.teamName, tc.teamSettings)
require.NoError(t, err)
err = teamFile.Close()
require.NoError(t, err)
teamFileName := teamFile.Name()
if tc.specialCase == "No team" {
noTeamFilePath := filepath.Join(filepath.Dir(teamFile.Name()), "unassigned.yml")
err = os.Rename(teamFile.Name(), noTeamFilePath)
require.NoError(t, err)
teamFileName = noTeamFilePath
}
t.Setenv("FLEET_URL", s.Server.URL)
testing_utils.StartAndServeVPPServer(t)
dryRunOutput := fleetctltest.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", teamFileName, "--dry-run"})
require.Contains(t, dryRunOutput, "gitops dry run succeeded")
realRunOutput := fleetctltest.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", teamFileName})
require.Contains(t, realRunOutput, "gitops succeeded")
var teamID uint
if tc.specialCase != "No team" {
team, err := s.DS.TeamByName(ctx, teamName)
require.NoError(t, err)
teamID = team.ID
}
// Verify VPP apps were added
titles, _, _, err := s.DS.ListSoftwareTitles(ctx, fleet.SoftwareTitleListOptions{AvailableForInstall: true, TeamID: &teamID},
fleet.TeamFilter{User: test.UserAdmin})
require.NoError(t, err)
require.Len(t, titles, 2) // One for iOS, one for iPadOS
})
}
}
func (s *enterpriseIntegrationGitopsTestSuite) TestDisallowSoftwareSetupExperience() {
t := s.T()
ctx := context.Background()
originalAppConfig, err := s.DS.AppConfig(ctx)
require.NoError(t, err)
t.Cleanup(func() {
err = s.DS.SaveAppConfig(ctx, originalAppConfig)
require.NoError(t, err)
})
user := s.createGitOpsUser(t)
fleetctlConfig := s.createFleetctlConfig(t, user)
test.CreateInsertGlobalVPPToken(t, s.DS)
teamName := uuid.NewString()
bootstrapServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "testdata/signed.pkg")
}))
defer bootstrapServer.Close()
// The global template includes VPP token assignment to the team
// The location "Jungle" comes from test.CreateInsertGlobalVPPToken
globalTemplate := `agent_options:
org_settings:
server_settings:
server_url: $FLEET_URL
org_info:
org_name: Fleet
secrets:
- secret: foobar
mdm:
volume_purchasing_program:
- location: Jungle
teams:
- %s
policies:
controls:
queries:
`
testVPP := `
controls:
setup_experience:
macos_bootstrap_package: %s
macos_manual_agent_install: true
software:
app_store_apps:
- app_store_id: "2"
platform: darwin
setup_experience: true
- app_store_id: "2"
platform: ios
setup_experience: true
- app_store_id: "2"
platform: ipados
setup_experience: true
queries:
policies:
agent_options:
name: %s
team_settings:
%s
`
//nolint:gosec // test code
testPackages := `
controls:
setup_experience:
macos_bootstrap_package: %s
macos_manual_agent_install: true
software:
app_store_apps:
packages:
- url: ${SOFTWARE_INSTALLER_URL}/dummy_installer.pkg
setup_experience: true
queries:
policies:
agent_options:
name: %s
team_settings:
%s
`
testCases := []struct {
VPPTeam string
testName string
teamName string
teamTemplate string
teamSettings string
errContains *string
}{
{
testName: "All VPP with setup experience",
VPPTeam: "All teams",
teamName: teamName,
teamTemplate: testVPP,
teamSettings: `secrets: [{"secret":"enroll_secret"}]`,
errContains: ptr.String("Couldn't edit software."),
},
{
testName: "Packages fail",
VPPTeam: "All teams",
teamName: teamName,
teamTemplate: testPackages,
teamSettings: `secrets: [{"secret":"enroll_secret"}]`,
errContains: new(`"setup_experience" cannot be used for macOS software if "macos_manual_agent_install" is enabled.`),
},
{
testName: "No team VPP",
VPPTeam: "No team",
teamName: "Unassigned",
teamTemplate: testVPP,
errContains: ptr.String("Couldn't edit software."),
},
{
testName: "No team Installers",
VPPTeam: "No team",
teamName: "Unassigned",
teamTemplate: testPackages,
errContains: new(`"setup_experience" cannot be used for macOS software if "macos_manual_agent_install" is enabled.`),
},
// left out more possible combinations of setup experience being set for different platforms
}
for _, tc := range testCases {
t.Run(tc.testName, func(t *testing.T) {
globalFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
globalYAML := fmt.Sprintf(globalTemplate, tc.VPPTeam)
_, err = globalFile.WriteString(globalYAML)
require.NoError(t, err)
err = globalFile.Close()
require.NoError(t, err)
teamFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = fmt.Fprintf(teamFile, tc.teamTemplate, bootstrapServer.URL, tc.teamName, tc.teamSettings)
require.NoError(t, err)
err = teamFile.Close()
require.NoError(t, err)
teamFileName := teamFile.Name()
if tc.VPPTeam == "No team" {
noTeamFilePath := filepath.Join(filepath.Dir(teamFile.Name()), "unassigned.yml")
err = os.Rename(teamFile.Name(), noTeamFilePath)
require.NoError(t, err)
teamFileName = noTeamFilePath
}
t.Setenv("FLEET_URL", s.Server.URL)
testing_utils.StartSoftwareInstallerServer(t)
testing_utils.StartAndServeVPPServer(t)
// Don't attempt dry runs because they would not actually create the team, so the config would not be found
_, err = fleetctltest.RunAppNoChecks([]string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", teamFileName})
if tc.errContains != nil {
require.ErrorContains(t, err, *tc.errContains)
} else {
require.NoError(t, err)
}
})
}
}
func (s *enterpriseIntegrationGitopsTestSuite) TestConfigurationProfileEscaping() {
t := s.T()
ctx := context.Background()
tempDir := t.TempDir()
user := s.createGitOpsUser(t)
fleetctlConfig := s.createFleetctlConfig(t, user)
// Get the testdata mobileconfig profile
_, currentFile, _, ok := runtime.Caller(0)
require.True(t, ok)
profilePath := filepath.Join(filepath.Dir(currentFile), "testdata", "apple-profile.mobileconfig")
require.FileExists(t, profilePath)
const secretPasswordValue = "custom&password<tag>"
t.Setenv("FLEET_SECRET_PASSWORD", secretPasswordValue)
t.Setenv("API_KEY", "my-api-key&value")
t.Setenv("FLEET_URL", s.Server.URL)
gitopsConfig := fmt.Sprintf(`
org_settings:
server_settings:
server_url: %s
org_info:
org_name: Fleet
secrets:
- secret: test_secret
agent_options:
controls:
apple_settings:
configuration_profiles:
- path: %s
policies:
reports:
`, s.Server.URL, profilePath)
configPath := filepath.Join(tempDir, "gitops.yml")
require.NoError(t, os.WriteFile(configPath, []byte(gitopsConfig), 0o644)) //nolint:gosec
// Run gitops
_ = fleetctltest.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", configPath})
// Verify the stored profile in the DB, based on my testing these vars are okay as they are not host-specific and we can avoid enrolling a host and checking the host specific payload.
profiles, err := s.DS.ListMDMAppleConfigProfiles(ctx, nil)
require.NoError(t, err)
var storedProfile *fleet.MDMAppleConfigProfile
for _, p := range profiles {
if strings.Contains(p.Identifier, "fleet.9913C522") {
storedProfile = p
break
}
}
require.NotNil(t, storedProfile, "should find the uploaded profile")
profileBody := string(storedProfile.Mobileconfig)
// $FLEET_SECRET_PASSWORD should NOT be expanded, as it will be expanded server-side
assert.Contains(t, profileBody, "$FLEET_SECRET_PASSWORD",
"stored profile should still contain the FLEET_SECRET_ placeholder")
// $API_KEY should be expanded and its value should have been escaped during gitops
assert.Contains(t, profileBody, "my-api-key&amp;value",
"stored profile should have the expanded $API_KEY value")
assert.NotContains(t, profileBody, "$API_KEY",
"stored profile should not contain the $API_KEY variable reference")
// Verify the secret was saved to the server unescaped, to avoid double encoding secrets.
secrets, err := s.DS.GetSecretVariables(ctx, []string{"PASSWORD"})
require.NoError(t, err)
require.Len(t, secrets, 1)
assert.Equal(t, secretPasswordValue, secrets[0].Value,
"secret should be stored as the raw value (not XML-escaped)")
}
// TestJSONConfigurationProfileEscaping covers issue #38013 — JSON profiles
// (Apple DDM declarations) must have variable values JSON-escaped at expansion
// time, while the underlying secret is still stored on the server unescaped.
func (s *enterpriseIntegrationGitopsTestSuite) TestJSONConfigurationProfileEscaping() {
t := s.T()
ctx := context.Background()
tempDir := t.TempDir()
user := s.createGitOpsUser(t)
fleetctlConfig := s.createFleetctlConfig(t, user)
const (
declIdentifier = "com.fleetdm.json.escape.test"
secretPasswordName = "JSON_ESCAPE_PASSWORD"
// Values contain characters that break naive JSON string interpolation
// (double quote, backslash, and XML-significant chars for completeness).
secretPasswordValue = `custom"password\tag&<>` //nolint:gosec // G101: test fixture, not a credential
apiKeyValue = `my"api&key\v` //nolint:gosec // G101: test fixture, not a credential
)
t.Setenv("FLEET_SECRET_"+secretPasswordName, secretPasswordValue)
t.Setenv("API_KEY", apiKeyValue)
t.Setenv("FLEET_URL", s.Server.URL)
declPath := filepath.Join(tempDir, "decl.json")
declBody := fmt.Sprintf(`{
"Type": "com.apple.configuration.management.test",
"Identifier": %q,
"Payload": {
"Password": "$FLEET_SECRET_%s",
"ApiKey": "$API_KEY"
}
}`, declIdentifier, secretPasswordName)
require.NoError(t, os.WriteFile(declPath, []byte(declBody), 0o644)) //nolint:gosec
gitopsConfig := fmt.Sprintf(`
org_settings:
server_settings:
server_url: %s
org_info:
org_name: Fleet
secrets:
- secret: json_escape_test_secret
agent_options:
controls:
macos_settings:
custom_settings:
- path: %s
policies:
reports:
`, s.Server.URL, declPath)
configPath := filepath.Join(tempDir, "gitops.yml")
require.NoError(t, os.WriteFile(configPath, []byte(gitopsConfig), 0o644)) //nolint:gosec
// Before the fix, this run would fail with
// "Declaration profiles should include valid JSON."
_ = fleetctltest.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", configPath})
// Retrieve the stored declaration by listing profiles and matching on identifier.
profs, _, err := s.DS.ListMDMConfigProfiles(ctx, nil, fleet.ListOptions{})
require.NoError(t, err)
var declUUID string
for _, p := range profs {
if p.Platform == "darwin" && p.Identifier == declIdentifier {
declUUID = p.ProfileUUID
break
}
}
require.NotEmpty(t, declUUID, "uploaded declaration should be listed")
decl, err := s.DS.GetMDMAppleDeclaration(ctx, declUUID)
require.NoError(t, err)
stored := string(decl.RawJSON)
// Stored bytes must be valid JSON — this is the regression guard for #38013.
var parsed map[string]any
require.NoError(t, json.Unmarshal(decl.RawJSON, &parsed),
"stored declaration must be valid JSON")
// $FLEET_SECRET_* placeholder must be stored literally; it is expanded
// server-side at delivery time so secrets are never double-encoded.
assert.Contains(t, stored, "$FLEET_SECRET_"+secretPasswordName,
"stored declaration should still contain the FLEET_SECRET_ placeholder")
// $API_KEY must be JSON-escaped: `"` → `\"`, `\` → `\\`.
payload, ok := parsed["Payload"].(map[string]any)
require.True(t, ok, "Payload should be an object")
assert.Equal(t, apiKeyValue, payload["ApiKey"],
"ApiKey should round-trip to the raw env var value after JSON parse")
assert.NotContains(t, stored, "$API_KEY",
"stored declaration should not contain the unexpanded $API_KEY reference")
// The custom secret must be stored raw so server-side expansion doesn't double-encode it.
dbSecrets, err := s.DS.GetSecretVariables(ctx, []string{secretPasswordName})
require.NoError(t, err)
require.Len(t, dbSecrets, 1)
assert.Equal(t, secretPasswordValue, dbSecrets[0].Value,
"secret should be stored as the raw value (not JSON-escaped)")
}
// TestGitOpsSoftwareWithEnvVarInstalledByPolicy tests that a software package
// with an environment variable in the URL can be referenced by a policy to be
// installed automatically.
func (s *enterpriseIntegrationGitopsTestSuite) TestGitOpsSoftwareWithEnvVarInstalledByPolicy() {
t := s.T()
ctx := t.Context()
user := s.createGitOpsUser(t)
fleetctlConfig := s.createFleetctlConfig(t, user)
const (
globalTemplate = `
agent_options:
controls:
org_settings:
server_settings:
server_url: $FLEET_URL
org_info:
org_name: Fleet
secrets:
policies:
reports:
`
noTeamTemplate = `name: Unassigned
controls:
policies:
- description: Test policy.
install_software:
package_path: ./lib/ruby.yml
name: Install ruby
platform: linux
query: SELECT 1 FROM file WHERE path = "/usr/local/bin/ruby";
resolution: Install ruby.
software:
packages:
- path: ./lib/ruby.yml
`
packageTemplate = `- url: ${CUSTOM_SOFTWARE_INSTALLER_URL}/ruby.deb`
fleetTemplate = `
controls:
software:
packages:
- path: ./lib/ruby.yml
reports:
policies:
- description: Test policy.
install_software:
package_path: ./lib/ruby.yml
name: Install team ruby
platform: linux
query: SELECT 1 FROM file WHERE path = "/usr/local/bin/ruby";
resolution: Install ruby.
agent_options:
name: %s
settings:
secrets: [{"secret":"enroll_secret"}]
`
)
tempDir := t.TempDir()
globalFile := filepath.Join(tempDir, "global.yml")
err := os.WriteFile(globalFile, []byte(globalTemplate), 0o644) //nolint:gosec
require.NoError(t, err)
noTeamFile := filepath.Join(tempDir, "unassigned.yml")
err = os.WriteFile(noTeamFile, []byte(noTeamTemplate), 0o644)
require.NoError(t, err)
fleetName := uuid.NewString()
fleetFile := filepath.Join(tempDir, "fleet.yml")
err = os.WriteFile(fleetFile, fmt.Appendf(nil, fleetTemplate, fleetName), 0o644)
require.NoError(t, err)
pkgFile := filepath.Join(tempDir, "lib", "ruby.yml")
err = os.MkdirAll(filepath.Dir(pkgFile), 0o755)
require.NoError(t, err)
err = os.WriteFile(pkgFile, []byte(packageTemplate), 0o644)
require.NoError(t, err)
// Set the required environment variables
t.Setenv("FLEET_URL", s.Server.URL)
testing_utils.StartSoftwareInstallerServer(t)
// Apply configs, installer URL env var is not defined yet
_, err = fleetctltest.RunAppNoChecks([]string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile, "-f", noTeamFile, "-f", fleetFile, "--dry-run"})
require.ErrorContains(t, err, `environment variable "CUSTOM_SOFTWARE_INSTALLER_URL" not set`)
_, err = fleetctltest.RunAppNoChecks([]string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile, "-f", noTeamFile, "-f", fleetFile})
require.ErrorContains(t, err, `environment variable "CUSTOM_SOFTWARE_INSTALLER_URL" not set`)
// define the URL env var and apply again, should succeed
t.Setenv("CUSTOM_SOFTWARE_INSTALLER_URL", os.Getenv("SOFTWARE_INSTALLER_URL"))
s.assertDryRunOutput(t, fleetctltest.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile, "-f", noTeamFile, "-f", fleetFile, "--dry-run"}))
s.assertRealRunOutput(t, fleetctltest.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile, "-f", noTeamFile, "-f", fleetFile}))
// no-team has a ruby custom installer
titles, _, _, err := s.DS.ListSoftwareTitles(ctx, fleet.SoftwareTitleListOptions{AvailableForInstall: true, TeamID: ptr.Uint(0)}, fleet.TeamFilter{User: test.UserAdmin})
require.NoError(t, err)
require.Len(t, titles, 1)
require.Equal(t, "ruby", titles[0].Name)
require.NotNil(t, titles[0].SoftwarePackage)
installer, err := s.DS.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, nil, titles[0].ID, false)
require.NoError(t, err)
tmPols, err := s.DS.ListMergedTeamPolicies(ctx, 0, fleet.ListOptions{}, "", "")
require.NoError(t, err)
require.Len(t, tmPols, 1)
require.Equal(t, "Install ruby", tmPols[0].Name)
require.NotNil(t, tmPols[0].SoftwareInstallerID)
require.Equal(t, installer.InstallerID, *tmPols[0].SoftwareInstallerID)
// Get the team ID
tm, err := s.DS.TeamByName(ctx, fleetName)
require.NoError(t, err)
// team has a ruby custom installer
titles, _, _, err = s.DS.ListSoftwareTitles(ctx, fleet.SoftwareTitleListOptions{AvailableForInstall: true, TeamID: &tm.ID}, fleet.TeamFilter{User: test.UserAdmin})
require.NoError(t, err)
require.Len(t, titles, 1)
require.Equal(t, "ruby", titles[0].Name)
require.NotNil(t, titles[0].SoftwarePackage)
installer, err = s.DS.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, &tm.ID, titles[0].ID, false)
require.NoError(t, err)
tmPols, err = s.DS.ListMergedTeamPolicies(ctx, tm.ID, fleet.ListOptions{}, "", "")
require.NoError(t, err)
require.Len(t, tmPols, 1)
require.Equal(t, "Install team ruby", tmPols[0].Name)
require.NotNil(t, tmPols[0].SoftwareInstallerID)
require.Equal(t, installer.InstallerID, *tmPols[0].SoftwareInstallerID)
}
// TestOmittedTopLevelKeysGlobal verifies that omitting top-level keys from a global
// gitops file clears the corresponding settings (e.g. policies, agent_options).
func (s *enterpriseIntegrationGitopsTestSuite) TestOmittedTopLevelKeysGlobal() {
t := s.T()
ctx := context.Background()
user := s.createGitOpsUser(t)
fleetctlConfig := s.createFleetctlConfig(t, user)
t.Setenv("FLEET_URL", s.Server.URL)
// Step 1: Apply a full global config with policies, agent_options, controls, and reports.
const fullGlobalConfig = `
org_settings:
server_settings:
server_url: $FLEET_URL
org_info:
org_name: Fleet
secrets:
- secret: boofar
agent_options:
config:
options:
pack_delimiter: /
controls:
enable_disk_encryption: true
policies:
- name: Test Global Policy
query: SELECT 1;
reports:
- name: Test Global Report
query: SELECT 1;
automations_enabled: false
labels:
- name: Test Global Label
label_membership_type: dynamic
query: SELECT 1
`
fullFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = fullFile.WriteString(fullGlobalConfig)
require.NoError(t, err)
require.NoError(t, fullFile.Close())
s.assertRealRunOutput(t, fleetctltest.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", fullFile.Name()}))
// Verify policy, agent_options, controls, and reports were applied.
policies, err := s.DS.ListGlobalPolicies(ctx, fleet.ListOptions{}, "")
require.NoError(t, err)
require.Len(t, policies, 1)
require.Equal(t, "Test Global Policy", policies[0].Name)
appCfg, err := s.DS.AppConfig(ctx)
require.NoError(t, err)
require.NotNil(t, appCfg.AgentOptions)
require.Contains(t, string(*appCfg.AgentOptions), "pack_delimiter")
require.True(t, appCfg.MDM.EnableDiskEncryption.Value)
queries, _, _, _, err := s.DS.ListQueries(ctx, fleet.ListQueryOptions{})
require.NoError(t, err)
require.Len(t, queries, 1)
require.Equal(t, "Test Global Report", queries[0].Name)
globalSecrets, err := s.DS.GetEnrollSecrets(ctx, nil)
require.NoError(t, err)
require.Len(t, globalSecrets, 1)
require.Equal(t, "boofar", globalSecrets[0].Secret)
labels, err := s.DS.LabelsByName(ctx, []string{"Test Global Label"}, fleet.TeamFilter{})
require.NoError(t, err)
require.Len(t, labels, 1)
// Step 2: Apply a minimal global config that omits policies, agent_options, reports, labels.
const minimalGlobalConfig = `
controls:
org_settings:
server_settings:
server_url: $FLEET_URL
org_info:
org_name: Fleet
`
minimalFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = minimalFile.WriteString(minimalGlobalConfig)
require.NoError(t, err)
require.NoError(t, minimalFile.Close())
s.assertRealRunOutput(t, fleetctltest.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", minimalFile.Name()}))
// Verify policies were cleared.
policies, err = s.DS.ListGlobalPolicies(ctx, fleet.ListOptions{}, "")
require.NoError(t, err)
require.Len(t, policies, 0)
appCfg, err = s.DS.AppConfig(ctx)
require.NoError(t, err)
// Verify agent_options were cleared (set to null).
require.Nil(t, appCfg.AgentOptions)
// Verify controls were cleared (disk encryption reverts to false).
require.False(t, appCfg.MDM.EnableDiskEncryption.Value)
// Verify reports were cleared.
queries, _, _, _, err = s.DS.ListQueries(ctx, fleet.ListQueryOptions{})
require.NoError(t, err)
require.Len(t, queries, 0)
// Verify secrets are cleared.
globalSecrets, err = s.DS.GetEnrollSecrets(ctx, nil)
require.NoError(t, err)
require.Len(t, globalSecrets, 0)
// Verify labels are cleared.
labels, err = s.DS.LabelsByName(ctx, []string{"Test Global Label"}, fleet.TeamFilter{})
require.NoError(t, err)
require.Len(t, labels, 0)
}
// TestGitOpsPolicyLabelScopes verifies that policies applied via GitOps can combine
// one include scope (any/all) with one exclude scope (any/all) — including the premium
// labels_exclude_all scope — and that conflicting include/exclude scopes are rejected.
func (s *enterpriseIntegrationGitopsTestSuite) TestGitOpsPolicyLabelScopes() {
t := s.T()
ctx := t.Context()
user := s.createGitOpsUser(t)
fleetctlConfig := s.createFleetctlConfig(t, user)
t.Setenv("FLEET_URL", s.Server.URL)
tempDir := t.TempDir()
fleetName := "Label Scopes " + uuid.NewString()
// The global file defines the labels referenced by both the global and team
// policies, plus a global policy that combines an include scope (any) with the
// premium exclude scope (all).
globalConfig := `
org_settings:
server_settings:
server_url: $FLEET_URL
org_info:
org_name: Fleet
secrets:
- secret: label_scopes_secret
agent_options:
controls:
reports:
labels:
- name: Label A
label_membership_type: dynamic
query: SELECT 1
- name: Label B
label_membership_type: dynamic
query: SELECT 1
- name: Label C
label_membership_type: dynamic
query: SELECT 1
policies:
- name: Global Combined Scope
query: SELECT 1;
labels_include_any:
- Label A
labels_exclude_all:
- Label B
- Label C
`
globalFile := filepath.Join(tempDir, "global.yml")
require.NoError(t, os.WriteFile(globalFile, []byte(globalConfig), 0o644)) //nolint:gosec
// The team file defines a team policy that combines an include scope (all) with
// an exclude scope (any), referencing the globally-defined labels.
teamConfig := fmt.Sprintf(`
name: %s
controls:
software:
reports:
agent_options:
settings:
secrets:
- secret: label_scopes_team_secret
policies:
- name: Team Combined Scope
query: SELECT 1;
labels_include_all:
- Label A
- Label B
labels_exclude_any:
- Label C
`, fleetName)
teamFile := filepath.Join(tempDir, "team.yml")
require.NoError(t, os.WriteFile(teamFile, []byte(teamConfig), 0o644)) //nolint:gosec
s.assertRealRunOutput(t, fleetctltest.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile, "-f", teamFile}))
// The global policy persisted both its include_any and exclude_all scopes.
globalPolicies, err := s.DS.ListGlobalPolicies(ctx, fleet.ListOptions{}, "")
require.NoError(t, err)
require.Len(t, globalPolicies, 1)
gp := globalPolicies[0]
require.Equal(t, "Global Combined Scope", gp.Name)
require.Len(t, gp.LabelsIncludeAny, 1)
require.Equal(t, "Label A", gp.LabelsIncludeAny[0].LabelName)
require.Empty(t, gp.LabelsIncludeAll)
require.Empty(t, gp.LabelsExcludeAny)
require.Len(t, gp.LabelsExcludeAll, 2)
require.ElementsMatch(t, []string{"Label B", "Label C"}, []string{gp.LabelsExcludeAll[0].LabelName, gp.LabelsExcludeAll[1].LabelName})
// The team policy persisted both its include_all and exclude_any scopes.
tm, err := s.DS.TeamByName(ctx, fleetName)
require.NoError(t, err)
teamPolicies, _, err := s.DS.ListTeamPolicies(ctx, tm.ID, fleet.ListOptions{}, fleet.ListOptions{}, "", "")
require.NoError(t, err)
require.Len(t, teamPolicies, 1)
tp := teamPolicies[0]
require.Equal(t, "Team Combined Scope", tp.Name)
require.Empty(t, tp.LabelsIncludeAny)
require.Len(t, tp.LabelsIncludeAll, 2)
require.ElementsMatch(t, []string{"Label A", "Label B"}, []string{tp.LabelsIncludeAll[0].LabelName, tp.LabelsIncludeAll[1].LabelName})
require.Len(t, tp.LabelsExcludeAny, 1)
require.Equal(t, "Label C", tp.LabelsExcludeAny[0].LabelName)
require.Empty(t, tp.LabelsExcludeAll)
// A policy that specifies two include scopes is rejected.
conflictConfig := `
org_settings:
server_settings:
server_url: $FLEET_URL
org_info:
org_name: Fleet
secrets:
- secret: label_scopes_secret
agent_options:
controls:
reports:
labels:
- name: Label A
label_membership_type: dynamic
query: SELECT 1
- name: Label B
label_membership_type: dynamic
query: SELECT 1
policies:
- name: Conflicting Scope
query: SELECT 1;
labels_include_any:
- Label A
labels_include_all:
- Label B
`
conflictFile := filepath.Join(tempDir, "conflict.yml")
require.NoError(t, os.WriteFile(conflictFile, []byte(conflictConfig), 0o644)) //nolint:gosec
_, err = fleetctltest.RunAppNoChecks([]string{"gitops", "--config", fleetctlConfig.Name(), "-f", conflictFile, "--dry-run"})
require.ErrorContains(t, err, "at most one of labels_include_any or labels_include_all")
}
// TestOmittedTopLevelKeysFleet verifies that omitting top-level keys from a fleet
// gitops file clears the corresponding settings (e.g. policies, agent_options, settings).
func (s *enterpriseIntegrationGitopsTestSuite) TestOmittedTopLevelKeysFleet() {
t := s.T()
ctx := context.Background()
user := s.createGitOpsUser(t)
fleetctlConfig := s.createFleetctlConfig(t, user)
t.Setenv("FLEET_URL", s.Server.URL)
testing_utils.StartSoftwareInstallerServer(t)
fleetName := "Test Omitted Keys " + uuid.NewString()
// Step 1: Apply a full fleet config with policies, agent_options, controls, features, reports, and software.
fullFleetConfig := fmt.Sprintf(`
name: %s
settings:
secrets:
- secret: foobar
features:
enable_host_users: false
agent_options:
config:
options:
pack_delimiter: /
controls:
enable_disk_encryption: true
policies:
- name: Test Fleet Policy
query: SELECT 1;
reports:
- name: Test Fleet Report
query: SELECT 1;
automations_enabled: false
software:
packages:
- url: ${SOFTWARE_INSTALLER_URL}/ruby.deb
labels:
- name: Test Fleet Label
label_membership_type: dynamic
query: SELECT 1
`, fleetName)
fullFleetFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = fullFleetFile.WriteString(fullFleetConfig)
require.NoError(t, err)
require.NoError(t, fullFleetFile.Close())
s.assertRealRunOutput(t, fleetctltest.RunAppForTest(t, []string{
"gitops", "--config", fleetctlConfig.Name(), "-f", fullFleetFile.Name(),
}))
// Verify policy, agent_options, controls, features, and reports were applied.
fl, err := s.DS.TeamByName(ctx, fleetName)
require.NoError(t, err)
flPols, err := s.DS.ListMergedTeamPolicies(ctx, fl.ID, fleet.ListOptions{}, "", "")
require.NoError(t, err)
require.Len(t, flPols, 1)
require.Equal(t, "Test Fleet Policy", flPols[0].Name)
require.NotNil(t, fl.Config.AgentOptions)
require.Contains(t, string(*fl.Config.AgentOptions), "pack_delimiter")
require.True(t, fl.Config.MDM.EnableDiskEncryption)
require.False(t, fl.Config.Features.EnableHostUsers)
flQueries, _, _, _, err := s.DS.ListQueries(ctx, fleet.ListQueryOptions{TeamID: &fl.ID})
require.NoError(t, err)
require.Len(t, flQueries, 1)
require.Equal(t, "Test Fleet Report", flQueries[0].Name)
flSecrets, err := s.DS.GetEnrollSecrets(ctx, &fl.ID)
require.NoError(t, err)
require.Len(t, flSecrets, 1)
require.Equal(t, "foobar", flSecrets[0].Secret)
titles, _, _, err := s.DS.ListSoftwareTitles(ctx, fleet.SoftwareTitleListOptions{AvailableForInstall: true, TeamID: &fl.ID},
fleet.TeamFilter{User: test.UserAdmin})
require.NoError(t, err)
require.Len(t, titles, 1)
// Step 2: Apply a minimal fleet config that omits policies, agent_options, controls, reports, software, settings.
minimalFleetConfig := fmt.Sprintf(`
name: %s
`, fleetName)
minimalFleetFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = minimalFleetFile.WriteString(minimalFleetConfig)
require.NoError(t, err)
require.NoError(t, minimalFleetFile.Close())
s.assertRealRunOutput(t, fleetctltest.RunAppForTest(t, []string{
"gitops", "--config", fleetctlConfig.Name(), "-f", minimalFleetFile.Name(),
}))
// Verify policies were cleared.
flPols, err = s.DS.ListMergedTeamPolicies(ctx, fl.ID, fleet.ListOptions{}, "", "")
require.NoError(t, err)
require.Len(t, flPols, 0)
fl, err = s.DS.TeamByName(ctx, fleetName)
require.NoError(t, err)
// Verify agent_options were cleared (set to null).
require.Nil(t, fl.Config.AgentOptions)
// Verify controls were cleared (disk encryption reverts to false).
require.False(t, fl.Config.MDM.EnableDiskEncryption)
// Verify features reverted to defaults (enable_host_users defaults to true).
require.True(t, fl.Config.Features.EnableHostUsers)
// Verify reports were cleared.
flQueries, _, _, _, err = s.DS.ListQueries(ctx, fleet.ListQueryOptions{TeamID: &fl.ID})
require.NoError(t, err)
require.Len(t, flQueries, 0)
// Verify secrets are cleared.
flSecrets, err = s.DS.GetEnrollSecrets(ctx, &fl.ID)
require.NoError(t, err)
require.Len(t, flSecrets, 0)
// Verify software was cleared.
titles, _, _, err = s.DS.ListSoftwareTitles(ctx, fleet.SoftwareTitleListOptions{AvailableForInstall: true, TeamID: &fl.ID},
fleet.TeamFilter{User: test.UserAdmin})
require.NoError(t, err)
require.Len(t, titles, 0)
// Verify labels are cleared.
labels, err := s.DS.LabelsByName(ctx, []string{"Test Fleet Label"}, fleet.TeamFilter{TeamID: &fl.ID})
require.NoError(t, err)
require.Len(t, labels, 0)
}
// TestFMALabelsIncludeAll tests that labels_include_all is correctly applied and
// cleared for Fleet Maintained Apps via gitops, for both no-team and a specific team.
func (s *enterpriseIntegrationGitopsTestSuite) TestFMALabelsIncludeAll() {
t := s.T()
ctx := context.Background()
user := s.createGitOpsUser(t)
fleetctlConfig := s.createFleetctlConfig(t, user)
slug := fmt.Sprintf("foo%s/darwin", t.Name())
lblName := "Label1" + t.Name()
const (
globalTemplate = `
agent_options:
labels:
- name: %s
label_membership_type: dynamic
query: SELECT 1
controls:
org_settings:
server_settings:
server_url: $FLEET_URL
org_info:
org_name: Fleet
secrets:
policies:
reports:
`
noTeamTemplate = `name: Unassigned
controls:
policies:
software:
fleet_maintained_apps:
- slug: %s
%s
`
teamTemplate = `
controls:
software:
fleet_maintained_apps:
- slug: %s
%s
reports:
policies:
agent_options:
name: %s
settings:
secrets: [{"secret":"enroll_secret"}]
`
)
const noLabels = ""
withLabelsIncludeAll := fmt.Sprintf(`
labels_include_all:
- %s
`, lblName)
globalFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = fmt.Fprintf(globalFile, globalTemplate, lblName)
require.NoError(t, err)
err = globalFile.Close()
require.NoError(t, err)
noTeamFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = fmt.Fprintf(noTeamFile, noTeamTemplate, slug, withLabelsIncludeAll)
require.NoError(t, err)
err = noTeamFile.Close()
require.NoError(t, err)
noTeamFilePath := filepath.Join(filepath.Dir(noTeamFile.Name()), "unassigned.yml")
err = os.Rename(noTeamFile.Name(), noTeamFilePath)
require.NoError(t, err)
teamName := uuid.NewString()
teamFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = fmt.Fprintf(teamFile, teamTemplate, slug, withLabelsIncludeAll, teamName)
require.NoError(t, err)
err = teamFile.Close()
require.NoError(t, err)
// Set the required environment variables
t.Setenv("FLEET_URL", s.Server.URL)
testing_utils.StartSoftwareInstallerServer(t)
// Mock server to serve FMA installer bytes
installerServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("foo"))
}))
defer installerServer.Close()
// Mock server to serve the FMA manifest
manifestServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
versions := []*ma.FMAManifestApp{
{
Version: "1.0",
Queries: ma.FMAQueries{
Exists: "SELECT 1 FROM osquery_info;",
},
InstallerURL: installerServer.URL + "/foo.pkg",
InstallScriptRef: "fooscript",
UninstallScriptRef: "fooscript",
SHA256: "no_check",
},
}
manifest := ma.FMAManifestFile{
Versions: versions,
Refs: map[string]string{"fooscript": "echo hello"},
}
err := json.NewEncoder(w).Encode(manifest)
require.NoError(t, err)
}))
defer manifestServer.Close()
dev_mode.SetOverride("FLEET_DEV_MAINTAINED_APPS_BASE_URL", manifestServer.URL, t)
// Insert the FMA record so gitops can resolve the slug
mysqltest.ExecAdhocSQL(t, s.DS, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx,
`INSERT INTO fleet_maintained_apps (name, slug, platform, unique_identifier)
VALUES (?, ?, 'darwin', ?)`, "foo"+t.Name(), slug, `com.example.foo`+t.Name())
return err
})
// Apply configs — dry-run first, then real run
s.assertDryRunOutput(t, fleetctltest.RunAppForTest(t, []string{
"gitops", "--config", fleetctlConfig.Name(),
"-f", globalFile.Name(), "-f", noTeamFilePath, "-f", teamFile.Name(),
"--dry-run",
}))
s.assertRealRunOutput(t, fleetctltest.RunAppForTest(t, []string{
"gitops", "--config", fleetctlConfig.Name(),
"-f", globalFile.Name(), "-f", noTeamFilePath, "-f", teamFile.Name(),
}))
// Retrieve the team so we have its ID
team, err := s.DS.TeamByName(ctx, teamName)
require.NoError(t, err)
// Locate the FMA installer for no-team and assert labels_include_all is set
noTeamTitles, _, _, err := s.DS.ListSoftwareTitles(ctx,
fleet.SoftwareTitleListOptions{AvailableForInstall: true, TeamID: ptr.Uint(0)},
fleet.TeamFilter{User: test.UserAdmin})
require.NoError(t, err)
require.Len(t, noTeamTitles, 1)
noTeamTitleID := noTeamTitles[0].ID
noTeamMeta, err := s.DS.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, nil, noTeamTitleID, false)
require.NoError(t, err)
require.Empty(t, noTeamMeta.LabelsIncludeAny)
require.Empty(t, noTeamMeta.LabelsExcludeAny)
require.Len(t, noTeamMeta.LabelsIncludeAll, 1)
require.Equal(t, lblName, noTeamMeta.LabelsIncludeAll[0].LabelName)
// Locate the FMA installer for the team and assert labels_include_all is set
teamTitles, _, _, err := s.DS.ListSoftwareTitles(ctx,
fleet.SoftwareTitleListOptions{TeamID: &team.ID},
fleet.TeamFilter{User: test.UserAdmin})
require.NoError(t, err)
require.Len(t, teamTitles, 1)
teamTitleID := teamTitles[0].ID
teamMeta, err := s.DS.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, &team.ID, teamTitleID, false)
require.NoError(t, err)
require.Empty(t, teamMeta.LabelsIncludeAny)
require.Empty(t, teamMeta.LabelsExcludeAny)
require.Len(t, teamMeta.LabelsIncludeAll, 1)
require.Equal(t, lblName, teamMeta.LabelsIncludeAll[0].LabelName)
// Now re-apply without labels_include_all and confirm they are cleared
err = os.WriteFile(noTeamFilePath, fmt.Appendf(nil, noTeamTemplate, slug, noLabels), 0o644)
require.NoError(t, err)
err = os.WriteFile(teamFile.Name(), fmt.Appendf(nil, teamTemplate, slug, noLabels, teamName), 0o644)
require.NoError(t, err)
s.assertDryRunOutput(t, fleetctltest.RunAppForTest(t, []string{
"gitops", "--config", fleetctlConfig.Name(),
"-f", globalFile.Name(), "-f", noTeamFilePath, "-f", teamFile.Name(),
"--dry-run",
}))
s.assertRealRunOutput(t, fleetctltest.RunAppForTest(t, []string{
"gitops", "--config", fleetctlConfig.Name(),
"-f", globalFile.Name(), "-f", noTeamFilePath, "-f", teamFile.Name(),
}))
// Labels should now be empty for no-team
noTeamMeta, err = s.DS.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, nil, noTeamTitleID, false)
require.NoError(t, err)
require.Empty(t, noTeamMeta.LabelsIncludeAny)
require.Empty(t, noTeamMeta.LabelsExcludeAny)
require.Empty(t, noTeamMeta.LabelsIncludeAll)
// Labels should now be empty for the team
teamMeta, err = s.DS.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, &team.ID, teamTitleID, false)
require.NoError(t, err)
require.Empty(t, teamMeta.LabelsIncludeAny)
require.Empty(t, teamMeta.LabelsExcludeAny)
require.Empty(t, teamMeta.LabelsIncludeAll)
}
func (s *enterpriseIntegrationGitopsTestSuite) TestFleetGitopsDDMUnsupportedFleetVariable() {
t := s.T()
user := s.createGitOpsUser(t)
fleetctlConfig := s.createFleetctlConfig(t, user)
// Create a DDM declaration with an unsupported Fleet variable
declDir := t.TempDir()
declFile := path.Join(declDir, "decl-unsupported-var.json")
err := os.WriteFile(declFile, []byte(`{
"Type": "com.apple.configuration.management.test",
"Identifier": "com.example.unsupported-var",
"Payload": {"Value": "$FLEET_VAR_BOZO"}
}`), 0o644)
require.NoError(t, err)
globalFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = globalFile.WriteString(fmt.Sprintf(`
agent_options:
controls:
macos_settings:
custom_settings:
- path: %s
org_settings:
server_settings:
server_url: $FLEET_URL
org_info:
org_name: Fleet
secrets:
policies:
queries:
`, declFile))
require.NoError(t, err)
t.Setenv("FLEET_URL", s.Server.URL)
// Applying a DDM declaration with an unsupported Fleet variable should fail
_, err = fleetctltest.RunAppNoChecks([]string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name()})
require.ErrorContains(t, err, "Fleet variable $FLEET_VAR_BOZO is not supported in DDM profiles")
}
func (s *enterpriseIntegrationGitopsTestSuite) TestManagedLocalAccount() {
t := s.T()
ctx := context.Background()
originalAppConfig, err := s.DS.AppConfig(ctx)
require.NoError(t, err)
t.Cleanup(func() {
require.NoError(t, s.DS.SaveAppConfig(ctx, originalAppConfig))
})
user := s.createGitOpsUser(t)
fleetctlConfig := s.createFleetctlConfig(t, user)
t.Setenv("FLEET_URL", s.Server.URL)
const (
globalConfig = `
agent_options:
controls:
org_settings:
server_settings:
server_url: $FLEET_URL
org_info:
org_name: Fleet
secrets:
policies:
reports:
`
noTeamConfig = `name: Unassigned
controls:
setup_experience:
enable_create_local_admin_account: true
end_user_local_account_type: "admin"
policies:
software:
`
teamConfig = `
controls:
setup_experience:
enable_create_local_admin_account: true
end_user_local_account_type: "admin"
software:
reports:
policies:
agent_options:
name: %s
settings:
secrets: [{"secret":"enroll_secret"}]
`
)
dir := t.TempDir()
write := func(name, body string) string {
p := filepath.Join(dir, name)
require.NoError(t, os.WriteFile(p, []byte(body), 0o644))
return p
}
globalFile := write("global.yml", globalConfig)
noTeamFile := write("unassigned.yml", noTeamConfig)
teamName := uuid.NewString()
teamFile := write("team.yml", fmt.Sprintf(teamConfig, teamName))
s.assertDryRunOutput(t, fleetctltest.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile, "-f", noTeamFile, "-f", teamFile, "--dry-run"}))
s.assertRealRunOutput(t, fleetctltest.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile, "-f", noTeamFile, "-f", teamFile}))
appConfig, err := s.DS.AppConfig(ctx)
require.NoError(t, err)
assert.True(t, appConfig.MDM.MacOSSetup.EnableManagedLocalAccount.Valid)
assert.True(t, appConfig.MDM.MacOSSetup.EnableManagedLocalAccount.Value)
assert.True(t, appConfig.MDM.MacOSSetup.EndUserLocalAccountType.Valid)
assert.Equal(t, "admin", appConfig.MDM.MacOSSetup.EndUserLocalAccountType.Value)
team, err := s.DS.TeamByName(ctx, teamName)
require.NoError(t, err)
assert.True(t, team.Config.MDM.MacOSSetup.EnableManagedLocalAccount.Valid)
assert.True(t, team.Config.MDM.MacOSSetup.EnableManagedLocalAccount.Value)
assert.True(t, team.Config.MDM.MacOSSetup.EndUserLocalAccountType.Valid)
assert.Equal(t, "admin", team.Config.MDM.MacOSSetup.EndUserLocalAccountType.Value)
}
// TestDryRunMacOSSetupScriptWithManualAgentInstallConflict tests that both
// dry-run and real gitops runs fail when manual_agent_install is true and a
// macos_script is configured. Regression test for
// https://github.com/fleetdm/fleet/issues/34464
func (s *enterpriseIntegrationGitopsTestSuite) TestDryRunMacOSSetupScriptWithManualAgentInstallConflict() {
t := s.T()
user := s.createGitOpsUser(t)
fleetctlConfig := s.createFleetctlConfig(t, user)
bootstrapServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "testdata/signed.pkg")
}))
defer bootstrapServer.Close()
t.Setenv("FLEET_URL", s.Server.URL)
// Create a setup experience script file
scriptFile, err := os.CreateTemp(t.TempDir(), "*.sh")
require.NoError(t, err)
_, err = scriptFile.WriteString(`echo "setup script"`)
require.NoError(t, err)
err = scriptFile.Close()
require.NoError(t, err)
// Global config
const globalTemplate = `
agent_options:
controls:
org_settings:
server_settings:
server_url: $FLEET_URL
org_info:
org_name: Fleet
secrets:
policies:
reports:
`
t.Run("team", func(t *testing.T) {
globalFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = globalFile.WriteString(globalTemplate)
require.NoError(t, err)
err = globalFile.Close()
require.NoError(t, err)
teamName := uuid.NewString()
teamTemplate := `
controls:
setup_experience:
macos_bootstrap_package: %s
macos_manual_agent_install: true
macos_script: %s
software:
reports:
policies:
agent_options:
name: %s
settings:
secrets: [{"secret":"enroll_secret"}]
`
teamFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = teamFile.WriteString(fmt.Sprintf(teamTemplate, bootstrapServer.URL, scriptFile.Name(), teamName))
require.NoError(t, err)
err = teamFile.Close()
require.NoError(t, err)
// Dry-run should fail with the manual_agent_install conflict
fleetctltest.RunAppCheckErr(t, []string{
"gitops", "--config", fleetctlConfig.Name(),
"-f", globalFile.Name(), "-f", teamFile.Name(),
"--dry-run",
}, "macos_manual_agent_install")
// Actual run should also fail with the same conflict
fleetctltest.RunAppCheckErr(t, []string{
"gitops", "--config", fleetctlConfig.Name(),
"-f", globalFile.Name(), "-f", teamFile.Name(),
}, "macos_manual_agent_install")
})
t.Run("no team", func(t *testing.T) {
globalFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = globalFile.WriteString(globalTemplate)
require.NoError(t, err)
err = globalFile.Close()
require.NoError(t, err)
noTeamTemplate := `name: Unassigned
policies:
controls:
setup_experience:
macos_manual_agent_install: true
macos_script: %s
software:
`
noTeamFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = noTeamFile.WriteString(fmt.Sprintf(noTeamTemplate, scriptFile.Name()))
require.NoError(t, err)
err = noTeamFile.Close()
require.NoError(t, err)
noTeamFilePath := filepath.Join(filepath.Dir(noTeamFile.Name()), "unassigned.yml")
err = os.Rename(noTeamFile.Name(), noTeamFilePath)
require.NoError(t, err)
// Dry-run should fail with the manual_agent_install conflict
fleetctltest.RunAppCheckErr(t, []string{
"gitops", "--config", fleetctlConfig.Name(),
"-f", globalFile.Name(), "-f", noTeamFilePath,
"--dry-run",
}, "macos_manual_agent_install")
// Actual run should also fail with the same conflict
fleetctltest.RunAppCheckErr(t, []string{
"gitops", "--config", fleetctlConfig.Name(),
"-f", globalFile.Name(), "-f", noTeamFilePath,
}, "macos_manual_agent_install")
})
}
// test vpp apps and packages dont validate labels on a dry run (issue #45844)
func (s *enterpriseIntegrationGitopsTestSuite) TestGitOpsTeamLabelAndSoftwareSameApply() {
t := s.T()
ctx := context.Background()
user := s.createGitOpsUser(t)
fleetctlConfig := s.createFleetctlConfig(t, user)
test.CreateInsertGlobalVPPToken(t, s.DS)
vppTokens, err := s.DS.ListVPPTokens(ctx)
require.NoError(t, err)
require.NotEmpty(t, vppTokens)
teamName := uuid.NewString()
team, err := s.DS.NewTeam(ctx, &fleet.Team{Name: teamName})
require.NoError(t, err)
_, err = s.DS.UpdateVPPTokenTeams(ctx, vppTokens[0].ID, []uint{team.ID})
require.NoError(t, err)
globalTemplate := fmt.Sprintf(`
agent_options:
controls:
org_settings:
server_settings:
server_url: $FLEET_URL
org_info:
org_name: Fleet
secrets:
mdm:
volume_purchasing_program:
- location: Jungle
teams:
- %s
policies:
reports:
`, teamName)
teamTemplate := `
controls:
software:
packages:
- url: ${SOFTWARE_INSTALLER_URL}/ruby.deb
labels_include_any:
- new-team-label
app_store_apps:
- app_store_id: "1"
platform: darwin
labels_include_any:
- new-team-label
reports:
policies:
agent_options:
name: %s
settings:
secrets: [{"secret":"enroll_secret"}]
labels:
- name: new-team-label
label_membership_type: manual
hosts: []
`
globalFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = globalFile.WriteString(globalTemplate)
require.NoError(t, err)
require.NoError(t, globalFile.Close())
teamFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = teamFile.WriteString(fmt.Sprintf(teamTemplate, teamName))
require.NoError(t, err)
require.NoError(t, teamFile.Close())
t.Setenv("FLEET_URL", s.Server.URL)
testing_utils.StartAndServeVPPServer(t)
testing_utils.StartSoftwareInstallerServer(t)
dryRunOutput := fleetctltest.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", teamFile.Name(), "--dry-run"})
require.Contains(t, dryRunOutput, "gitops dry run succeeded")
realRunOutput := fleetctltest.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", teamFile.Name()})
require.Contains(t, realRunOutput, "gitops succeeded")
}
func (s *enterpriseIntegrationGitopsTestSuite) TestMultiplePackagesRoundTrip() {
t := s.T()
ctx := context.Background()
testing_utils.StartSoftwareInstallerServer(t)
user := s.createGitOpsUser(t)
fleetctlConfig := s.createFleetctlConfig(t, user)
const teamName = "roundtrip_multi"
// Apply a team holding two custom packages of the same title (ruby, with
// different content per architecture).
// absolute path to a valid PNG fixture for the package icon
_, currentFile, _, ok := runtime.Caller(0)
require.True(t, ok)
iconPath, err := filepath.Abs(filepath.Join(filepath.Dir(currentFile), "../../fleetctl/testdata/gitops/lib/icon.png"))
require.NoError(t, err)
// gitops validates that referenced labels exist, so create one to scope a package to
_, err = s.DS.NewLabel(ctx, &fleet.Label{Name: "roundtrip_multi_label", Query: "SELECT 1"})
require.NoError(t, err)
applyDir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(applyDir, "fleets"), 0o755))
require.NoError(t, os.MkdirAll(filepath.Join(applyDir, "queries"), 0o755))
require.NoError(t, os.MkdirAll(filepath.Join(applyDir, "scripts"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(applyDir, "queries", "ruby-preinstall.yml"), []byte("- query: SELECT 1 FROM osquery_info\n"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(applyDir, "scripts", "ruby-postinstall.sh"), []byte("#!/bin/sh\necho postinstall\n"), 0o644))
teamFile := filepath.Join(applyDir, "fleets", "team.yml")
require.NoError(t, os.WriteFile(teamFile, fmt.Appendf(nil, `
name: %s
team_settings:
secrets:
- secret: roundtrip_secret
agent_options:
controls:
policies:
queries:
software:
packages:
- url: ${SOFTWARE_INSTALLER_URL}/ruby.deb
self_service: true
pre_install_query:
path: ../queries/ruby-preinstall.yml
labels_include_all:
- roundtrip_multi_label
icon:
path: %s
- url: ${SOFTWARE_INSTALLER_URL}/ruby_variant.deb
categories:
- "🔐 Security"
post_install_script:
path: ../scripts/ruby-postinstall.sh
`, teamName, iconPath), 0o644))
_, err = fleetctltest.RunAppNoChecks([]string{"gitops", "--config", fleetctlConfig.Name(), "-f", teamFile})
require.NoError(t, err)
team, err := s.DS.TeamByName(ctx, teamName)
require.NoError(t, err)
// find the title that ended up with both packages
titleID := uint(0)
titles, _, _, err := s.DS.ListSoftwareTitles(ctx, fleet.SoftwareTitleListOptions{TeamID: &team.ID}, fleet.TeamFilter{User: test.UserAdmin})
require.NoError(t, err)
for _, tl := range titles {
p, err := s.DS.GetSoftwarePackagesByTeamAndTitleID(ctx, &team.ID, tl.ID)
require.NoError(t, err)
if len(p) == 2 {
titleID = tl.ID
}
}
require.NotZero(t, titleID, "the two packages should resolve to one title")
// assertPackages checks the per-package fields that ride through apply and re-apply:
// ruby.deb keeps self_service, a pre-install query, and a scope label; ruby_variant.deb
// keeps a category and a post-install script.
assertPackages := func(pkgs []*fleet.SoftwareInstaller) {
require.True(t, pkgs[0].SelfService)
require.False(t, pkgs[1].SelfService)
require.Equal(t, "SELECT 1 FROM osquery_info", pkgs[0].PreInstallQuery)
require.Empty(t, pkgs[1].PreInstallQuery)
require.Len(t, pkgs[0].LabelsIncludeAll, 1)
require.Equal(t, "roundtrip_multi_label", pkgs[0].LabelsIncludeAll[0].LabelName)
require.Equal(t, "#!/bin/sh\necho postinstall\n", pkgs[1].PostInstallScript)
cats, err := s.DS.GetCategoriesForSoftwareInstallers(ctx, []uint{pkgs[1].InstallerID})
require.NoError(t, err)
require.Equal(t, []string{"🔐 Security"}, cats[pkgs[1].InstallerID])
// the icon is title-level, so it is fetched once from the title metadata
meta, err := s.DS.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, &team.ID, titleID, false)
require.NoError(t, err)
require.NotNil(t, meta.IconUrl)
}
// first-added first
pkgs, err := s.DS.GetSoftwarePackagesByTeamAndTitleID(ctx, &team.ID, titleID)
require.NoError(t, err)
require.Len(t, pkgs, 2)
firstID, secondID := pkgs[0].InstallerID, pkgs[1].InstallerID
assertPackages(pkgs)
// generate the team's config back out (needs an admin to read config), with real
// secrets so it re-applies cleanly
admin := fleet.User{
Name: "Admin " + uuid.NewString(),
Email: uuid.NewString() + "@example.com",
GlobalRole: new(fleet.RoleAdmin),
}
require.NoError(t, admin.SetPassword(test.GoodPassword, 10, 10))
_, err = s.DS.NewUser(ctx, &admin)
require.NoError(t, err)
adminConfig := s.createFleetctlConfig(t, admin)
genDir := t.TempDir()
_, err = fleetctltest.RunAppNoChecks([]string{"generate-gitops", "--config", adminConfig.Name(), "--fleet", teamName, "--dir", genDir, "--insecure"})
require.NoError(t, err)
genTeamFiles, err := filepath.Glob(filepath.Join(genDir, "fleets", "*.yml"))
require.NoError(t, err)
require.Len(t, genTeamFiles, 1)
// re-applying the generated output keeps the same two packages, ids, and per-package fields
_, err = fleetctltest.RunAppNoChecks([]string{"gitops", "--config", fleetctlConfig.Name(), "-f", genTeamFiles[0]})
require.NoError(t, err)
pkgs, err = s.DS.GetSoftwarePackagesByTeamAndTitleID(ctx, &team.ID, titleID)
require.NoError(t, err)
require.Len(t, pkgs, 2)
require.Equal(t, firstID, pkgs[0].InstallerID)
require.Equal(t, secondID, pkgs[1].InstallerID)
assertPackages(pkgs)
// generating again from the re-applied state yields byte-identical files: the
// round-trip is stable, so re-applying the generated config produced no diff
genDir2 := t.TempDir()
_, err = fleetctltest.RunAppNoChecks([]string{"generate-gitops", "--config", adminConfig.Name(), "--fleet", teamName, "--dir", genDir2, "--insecure"})
require.NoError(t, err)
readTree := func(dir string) map[string]string {
files := map[string]string{}
require.NoError(t, filepath.Walk(dir, func(p string, info os.FileInfo, err error) error {
require.NoError(t, err)
if info.IsDir() {
return nil
}
rel, err := filepath.Rel(dir, p)
require.NoError(t, err)
b, err := os.ReadFile(p) //nolint:gosec // reading generated files under t.TempDir()
require.NoError(t, err)
files[rel] = string(b)
return nil
}))
return files
}
require.Equal(t, readTree(genDir), readTree(genDir2))
}