fleet/cmd/fleetctl/integrationtest/gitops/gitops_enterprise_integration_test.go
Magnus Jensen a8c9e261d7
speed up macOS profile delivery for initial enrollments (#41960)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #34433 

It speeds up the cron, meaning fleetd, bootstrap and now profiles should
be sent within 10 seconds of being known to fleet, compared to the
previous 1 minute.

It's heavily based on my last PR, so the structure and changes are close
to identical, with some small differences.
**I did not do the redis key part in this PR, as I think that should
come in it's own PR, to avoid overlooking logic bugs with that code, and
since this one is already quite sized since we're moving core pieces of
code around.**

# Checklist for submitter

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

- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files)
for more information.


## Testing

- [x] Added/updated automated tests
- [x] QA'd all new/changed functionality manually


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

* **New Features**
* Faster macOS onboarding: device profiles are delivered and installed
as part of DEP enrollment, shortening initial setup.
* Improved profile handling: per-host profile preprocessing, secret
detection, and clearer failure marking.

* **Improvements**
  * Consolidated SCEP/NDES error messaging for clearer diagnostics.
  * Cron/work scheduling tuned to prioritize Apple MDM profile delivery.

* **Tests**
* Expanded MDM unit and integration tests, including
DeclarativeManagement handling.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-03-19 14:58:10 -05:00

4158 lines
137 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"
"github.com/fleetdm/fleet/v4/cmd/fleetctl/fleetctl"
"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/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"
"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/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
}
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
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 := service.RunServerForTestsWithDS(s.T(), s.DS, &serverConfig)
s.T().Setenv("FLEET_SERVER_ADDRESS", server.URL) // fleetctl always uses this env var in tests
s.Server = server
s.Users = users
s.fleetCfg = fleetCfg
appConf, err = s.DS.AppConfig(context.Background())
require.NoError(s.T(), err)
appConf.ServerSettings.ServerURL = server.URL
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()
mysql.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 "No team" (the others are deleted in ts.DS.DeleteTeam above).
mysql.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 "No team" (the others are deleted in ts.DS.DeleteTeam above).
mysql.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)
}
mysql.ExecAdhocSQL(t, s.DS, func(tx sqlx.ExtContext) error {
_, err := tx.ExecContext(ctx, "DELETE FROM vpp_apps;")
return err
})
mysql.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",
}
pattern := fmt.Sprintf("\\[([+\\-!])] would've (%s)", 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
}
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, fleetctl.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, fleetctl.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, "no-team.yml") {
s.assertDryRunOutputWithDeprecation(t, fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", fileName, "-f", globalFile, "--dry-run"}), true)
} else {
s.assertDryRunOutputWithDeprecation(t, fleetctl.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, fleetctl.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, fleetctl.RunAppForTest(t, args), true)
// Check that all the teams exist
teamsJSON := fleetctl.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, fleetctl.RunAppForTest(t, args), true)
// Check that only the right teams exist
teamsJSON = fleetctl.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, fleetctl.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, "no-team.yml") {
s.assertRealRunOutputWithDeprecation(t, fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", fileName, "-f", globalFile}), true)
} else {
s.assertRealRunOutputWithDeprecation(t, fleetctl.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, fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", teamFile.Name(), "--dry-run"}))
s.assertRealRunOutput(t, fleetctl.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))
mysql.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)
mysql.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, fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", teamFile.Name(), "--dry-run"}))
s.assertRealRunOutput(t, fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", teamFile.Name()}))
mysql.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
})
mysql.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:
macos_settings:
custom_settings:
- 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, fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "--dry-run"}))
s.assertRealRunOutput(t, fleetctl.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:
macos_settings:
custom_settings:
- 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, fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "--dry-run"}))
s.assertRealRunOutput(t, fleetctl.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:
macos_settings:
custom_settings:
org_settings:
server_settings:
server_url: $FLEET_URL
org_info:
org_name: Fleet
secrets:
policies:
reports:
`)
require.NoError(t, err)
s.assertDryRunOutput(t, fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "--dry-run"}))
s.assertRealRunOutput(t, fleetctl.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.
fleetctl.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 = fleetctl.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:
`)
fleetctl.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)
lbl, err := s.DS.NewLabel(ctx, &fleet.Label{Name: "Label1", Query: "SELECT 1"})
require.NoError(t, err)
require.NotZero(t, lbl.ID)
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:
controls:
macos_settings:
custom_settings:
- 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:
macos_settings:
custom_settings:
- 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, fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", teamFile.Name(), "--dry-run"}))
s.assertRealRunOutput(t, fleetctl.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, fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", teamFile.Name(), "--dry-run"}))
s.assertRealRunOutput(t, fleetctl.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)
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:
`
noTeamTemplate = `name: No team
controls:
policies:
software:
packages:
- url: ${SOFTWARE_INSTALLER_URL}/ruby.deb
%s
`
withLabelsIncludeAny = `
labels_include_any:
- Label1
`
emptyLabelsIncludeAny = `
labels_include_any:
`
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()), "no-team.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, fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", noTeamFilePath, "-f", teamFile.Name(), "--dry-run"}))
s.assertRealRunOutput(t, fleetctl.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)
// 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, fleetctl.RunAppForTest(t,
[]string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", noTeamFilePath, "-f", teamFile.Name(), "--dry-run"}))
s.assertRealRunOutput(t, fleetctl.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)
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)
}
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: No team
policies:
controls:
macos_setup:
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()), "no-team.yml")
err = os.Rename(noTeamFile.Name(), noTeamFilePath)
require.NoError(t, err)
s.assertDryRunOutput(t, fleetctl.RunAppForTest(t,
[]string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", noTeamFilePath, "--dry-run"}))
s.assertRealRunOutput(t, fleetctl.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)
_ = fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "--dry-run"})
_ = fleetctl.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: No team
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()), "no-team.yml")
err = os.Rename(noTeamFile.Name(), noTeamFilePath)
require.NoError(t, err)
// Test dry-run first
output := fleetctl.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 = fleetctl.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: No team
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()), "no-team.yml")
err = os.Rename(noTeamFileUpdated.Name(), noTeamFilePathUpdated)
require.NoError(t, err)
// Apply the updated configuration
output = fleetctl.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: No team
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()), "no-team.yml")
err = os.Rename(noTeamFileNoWebhook.Name(), noTeamFilePathNoWebhook)
require.NoError(t, err)
// Apply configuration without webhook settings
output = fleetctl.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 = fleetctl.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: No team
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()), "no-team.yml")
err = os.Rename(noTeamFileTeamNoWebhook.Name(), noTeamFilePathTeamNoWebhook)
require.NoError(t, err)
// Apply configuration with team_settings but no webhook_settings
output = fleetctl.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 = fleetctl.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: No team
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()), "no-team.yml")
err = os.Rename(noTeamFileWebhookNoFailing.Name(), noTeamFilePathWebhookNoFailing)
require.NoError(t, err)
// Apply configuration with webhook_settings but no failing_policies_webhook
output = fleetctl.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:
macos_settings:
custom_settings:
- 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, fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "--dry-run"}))
s.assertRealRunOutput(t, fleetctl.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, fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "--dry-run"}))
s.assertRealRunOutput(t, fleetctl.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:
macos_setup:
bootstrap_package: %s
manual_agent_install: %t
org_settings:
server_settings:
server_url: $FLEET_URL
org_info:
org_name: Fleet
secrets:
policies:
reports:
`
noTeamConfig = `name: No team
controls:
macos_setup:
bootstrap_package: %s
manual_agent_install: true
policies:
software:
`
teamConfig = `
controls:
macos_setup:
bootstrap_package: %s
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()), "no-team.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, fleetctl.RunAppForTest(t,
[]string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", noTeamFilePath, "-f", teamFile.Name(), "--dry-run"}))
s.assertRealRunOutput(t, fleetctl.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, fleetctl.RunAppForTest(t,
[]string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFileOnlyClear.Name(), "-f", teamFileClear.Name(), "--dry-run"}))
s.assertRealRunOutput(t, fleetctl.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, fleetctl.RunAppForTest(t,
[]string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFileOnlySet.Name(), "--dry-run"}))
s.assertRealRunOutput(t, fleetctl.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")
_ = fleetctl.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: No team
policies:
controls:
macos_setup:
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()), "no-team.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, fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", noTeamFilePath, "--dry-run"}))
s.assertRealRunOutput(t, fleetctl.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:
macos_settings:
custom_settings:
- 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 = fleetctl.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")
_ = fleetctl.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:
macos_settings:
custom_settings:
- 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 = fleetctl.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 = fleetctl.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, fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "--dry-run"}))
s.assertRealRunOutput(t, fleetctl.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) 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: No team
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()), "no-team.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, fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", noTeamFilePath, "--dry-run"}))
s.assertRealRunOutput(t, fleetctl.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.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, fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", teamFile.Name(), "--dry-run"}))
s.assertRealRunOutput(t, fleetctl.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.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, fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", teamFile.Name(), "--dry-run"}))
s.assertRealRunOutput(t, fleetctl.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.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, fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", teamFile.Name(), "--dry-run"}))
s.assertRealRunOutput(t, fleetctl.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: No team
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()), "no-team.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, fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", noTeamFilePath, "-f", teamFile.Name(), "--dry-run"}))
s.assertRealRunOutput(t, fleetctl.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
mysql.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
mysql.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: No team
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()), "no-team.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)
mysql.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, fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", noTeamFilePath, "-f", teamFile.Name(), "--dry-run"}))
s.assertRealRunOutput(t, fleetctl.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
mysql.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
mysql.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])
}
// 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, fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetCfg.Name(), "-f", globalFile.Name(), "--dry-run"}))
s.assertRealRunOutput(t, fleetctl.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, fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetCfg.Name(), "-f", teamOneFile.Name(), "--dry-run"}))
s.assertRealRunOutput(t, fleetctl.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, fleetctl.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, fleetctl.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, fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetCfg.Name(), "-f", globalFile.Name(), "-f", teamOneFile.Name(), "-f", teamTwoFile.Name()}))
s.assertRealRunOutput(t, fleetctl.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, fleetctl.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, fleetctl.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
mysql.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
}
// 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 := fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", teamFile.Name(), "--dry-run"})
require.Contains(t, dryRunOutput, "gitops dry run succeeded")
realRunOutput := fleetctl.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
mysql.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 = fleetctl.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
mysql.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 {
fleetctl.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, fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetCfg.Name(), "-f", globalCfgFile.Name(), "--dry-run"}))
s.assertRealRunOutput(t, fleetctl.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: "No team",
},
}
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()), "no-team.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 := fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", teamFileName, "--dry-run"})
require.Contains(t, dryRunOutput, "gitops dry run succeeded")
realRunOutput := fleetctl.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:
macos_setup:
bootstrap_package: %s
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:
macos_setup:
bootstrap_package: %s
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: ptr.String("Couldn't edit software."),
},
{
testName: "No team VPP",
VPPTeam: "No team",
teamName: "No team",
teamTemplate: testVPP,
errContains: ptr.String("Couldn't edit software."),
},
{
testName: "No team Installers",
VPPTeam: "No team",
teamName: "No team",
teamTemplate: testPackages,
errContains: ptr.String("Couldn't edit software."),
},
// 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()), "no-team.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 = fleetctl.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:
macos_settings:
custom_settings:
- 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
_ = fleetctl.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)")
}
// 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: No team
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, "no-team.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 = fleetctl.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 = fleetctl.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, fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile, "-f", noTeamFile, "-f", fleetFile, "--dry-run"}))
s.assertRealRunOutput(t, fleetctl.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, fleetctl.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, fleetctl.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 unchanged.
globalSecrets, err = s.DS.GetEnrollSecrets(ctx, nil)
require.NoError(t, err)
require.Len(t, globalSecrets, 1)
require.Equal(t, "boofar", globalSecrets[0].Secret)
// Verify labels are unchanged.
labels, err = s.DS.LabelsByName(ctx, []string{"Test Global Label"}, fleet.TeamFilter{})
require.NoError(t, err)
require.Len(t, labels, 1)
}
// 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
`, 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, fleetctl.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, fleetctl.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 unchanged.
flSecrets, err = s.DS.GetEnrollSecrets(ctx, &fl.ID)
require.NoError(t, err)
require.Len(t, flSecrets, 1)
require.Equal(t, "foobar", flSecrets[0].Secret)
// 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)
}