diff --git a/changes/27476-add-generate-gitops-cmd b/changes/27476-add-generate-gitops-cmd
new file mode 100644
index 0000000000..2c7ff4d1c6
--- /dev/null
+++ b/changes/27476-add-generate-gitops-cmd
@@ -0,0 +1 @@
+- Added "generate-gitops" command to fleetctl
\ No newline at end of file
diff --git a/cmd/fleetctl/fleetctl.go b/cmd/fleetctl/fleetctl.go
index fa1f8dd769..a7035d0ce8 100644
--- a/cmd/fleetctl/fleetctl.go
+++ b/cmd/fleetctl/fleetctl.go
@@ -110,6 +110,7 @@ func createApp(
upgradePacksCommand(),
runScriptCommand(),
gitopsCommand(),
+ generateGitopsCommand(),
}
return app
}
diff --git a/cmd/fleetctl/generate_gitops.go b/cmd/fleetctl/generate_gitops.go
new file mode 100644
index 0000000000..c53e1a5c61
--- /dev/null
+++ b/cmd/fleetctl/generate_gitops.go
@@ -0,0 +1,1294 @@
+package main
+
+import (
+ "bytes"
+ "fmt"
+ "os"
+ pathUtils "path"
+ "reflect"
+ "regexp"
+ "strings"
+ "unicode"
+
+ "github.com/fleetdm/fleet/v4/pkg/spec"
+ "github.com/fleetdm/fleet/v4/server/fleet"
+ "github.com/fleetdm/fleet/v4/server/ptr"
+ "github.com/fleetdm/fleet/v4/server/service"
+ "github.com/ghodss/yaml"
+ "github.com/urfave/cli/v2"
+)
+
+type SecretWarning struct {
+ Filename string
+ Key string
+}
+
+type Note struct {
+ Filename string
+ Note string
+}
+
+type Messages struct {
+ SecretWarnings []SecretWarning
+ Notes []Note
+}
+
+type Comment struct {
+ Filename string
+ Comment string
+ Token string
+}
+
+type FileToWrite struct {
+ Path string
+ Content map[string]interface{}
+}
+
+type Software struct {
+ Hash string
+ AppStoreId string
+ Comment string
+}
+
+type teamToProcess struct {
+ ID *uint
+ Team *fleet.Team
+}
+
+type generateGitopsClient interface {
+ GetAppConfig() (*fleet.EnrichedAppConfig, error)
+ GetEnrollSecretSpec() (*fleet.EnrollSecretSpec, error)
+ ListTeams(query string) ([]fleet.Team, error)
+ ListScripts(query string) ([]*fleet.Script, error)
+ ListConfigurationProfiles(teamID *uint) ([]*fleet.MDMConfigProfilePayload, error)
+ GetScriptContents(scriptID uint) ([]byte, error)
+ GetProfileContents(profileID string) ([]byte, error)
+ GetTeam(teamID uint) (*fleet.Team, error)
+ ListSoftwareTitles(query string) ([]fleet.SoftwareTitleListResult, error)
+ GetSoftwareTitleByID(ID uint, teamID *uint) (*fleet.SoftwareTitle, error)
+ GetPolicies(teamID *uint) ([]*fleet.Policy, error)
+ GetQueries(teamID *uint, name *string) ([]fleet.Query, error)
+ GetLabels() ([]*fleet.LabelSpec, error)
+ Me() (*fleet.User, error)
+}
+
+// Given a struct type and a field name, return the JSON field name.
+func jsonFieldName(t reflect.Type, fieldName string) string {
+ field, ok := t.FieldByName(fieldName)
+ if !ok {
+ panic(fieldName + " not found in " + t.Name())
+ }
+ tag := field.Tag.Get("json")
+ parts := strings.Split(tag, ",")
+ name := parts[0]
+
+ if name == "-" || name == "" {
+ panic(field.Name + " has no json tag")
+ }
+ return name
+}
+
+// Given a dot-separated path, return the value at that key in a map.
+func getValueAtKey(data map[string]interface{}, path string) (interface{}, bool) {
+ // Split the path into parts.
+ parts := strings.Split(path, ".")
+ var cur interface{} = data
+
+ // Keep traversing the map using the keys in the path.
+ for _, key := range parts {
+ mp, ok := cur.(map[string]interface{})
+ if !ok {
+ return nil, false
+ }
+ cur, ok = mp[key]
+ if !ok {
+ return nil, false
+ }
+ }
+ return cur, true
+}
+
+type GenerateGitopsCommand struct {
+ Client generateGitopsClient
+ CLI *cli.Context
+ Messages Messages
+ FilesToWrite map[string]interface{}
+ Comments []Comment
+ AppConfig *fleet.EnrichedAppConfig
+ SoftwareList map[uint]Software
+ ScriptList map[uint]string
+}
+
+func generateGitopsCommand() *cli.Command {
+ return &cli.Command{
+ Name: "generate-gitops",
+ Usage: "Generate GitOps configuration files for Fleet.",
+ Description: "This command generates GitOps configuration files for Fleet.",
+ Action: createGenerateGitopsAction(nil),
+ Flags: []cli.Flag{
+ configFlag(),
+ contextFlag(),
+ debugFlag(),
+ &cli.BoolFlag{
+ Name: "insecure",
+ Usage: "Output sensitive information in plaintext.",
+ Value: false,
+ },
+ &cli.StringFlag{
+ Name: "key",
+ Usage: "A key to output the config value for.",
+ },
+ &cli.StringFlag{
+ Name: "team",
+ Usage: "(Premium only) The team to output configuration for. Omit to export all configuration. Use 'global' to export global settings, or 'no-team' to export settings for No Team.",
+ },
+ &cli.StringFlag{
+ Name: "dir",
+ Usage: "The root directory to write the files to.",
+ },
+ &cli.BoolFlag{
+ Name: "print",
+ Usage: "Output to stdout instead of the specified directory.",
+ },
+ &cli.BoolFlag{
+ Name: "force",
+ Usage: "Overwrite existing files.",
+ },
+ },
+ }
+}
+
+// Create the action for the generate-gitops command, using a provided fleetClient.
+func createGenerateGitopsAction(fleetClient generateGitopsClient) func(*cli.Context) error {
+ return func(c *cli.Context) error {
+ var err error
+ if fleetClient == nil {
+ fleetClient, err = clientFromCLI(c)
+ if err != nil {
+ return err
+ }
+ }
+ cmd := &GenerateGitopsCommand{
+ Client: fleetClient,
+ CLI: c,
+ Messages: Messages{},
+ FilesToWrite: make(map[string]interface{}),
+ SoftwareList: make(map[uint]Software),
+ ScriptList: make(map[uint]string),
+ }
+ return cmd.Run()
+ }
+}
+
+// Execute the actual command.
+func (cmd *GenerateGitopsCommand) Run() error {
+ // Either "key" or "dir" must be specified.
+ if cmd.CLI.String("key") == "" && cmd.CLI.String("dir") == "" {
+ fmt.Fprintf(cmd.CLI.App.ErrWriter, "Either --dir or --key must be specified\n")
+ return nil
+ }
+ // But not both.
+ if cmd.CLI.String("key") != "" && cmd.CLI.String("dir") != "" {
+ fmt.Fprintf(cmd.CLI.App.ErrWriter, "Only one of --dir or --key may be specified\n")
+ return nil
+ }
+
+ var err error
+
+ // User must be global admin.
+ me, err := cmd.Client.Me()
+ if err != nil {
+ fmt.Fprintf(cmd.CLI.App.ErrWriter, "Error getting user: %s\n", err)
+ return ErrGeneric
+ }
+ if me.GlobalRole != nil && *me.GlobalRole != fleet.RoleAdmin {
+ fmt.Fprintf(cmd.CLI.App.ErrWriter, "You are not authorized to run this command. Please contact your administrator.\n")
+ return nil
+ }
+
+ // Validate directory is empty (or --force is set).
+ if cmd.CLI.String("dir") != "" && !cmd.CLI.Bool("print") {
+ dir := cmd.CLI.String("dir")
+ _, err := os.Stat(dir)
+ if err != nil {
+ if !os.IsNotExist(err) {
+ fmt.Fprintf(cmd.CLI.App.ErrWriter, "Error checking directory: %s\n", err)
+ return ErrGeneric
+ }
+ } else {
+ // Check if the directory is empty.
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ fmt.Fprintf(cmd.CLI.App.ErrWriter, "Error reading directory: %s\n", err)
+ return ErrGeneric
+ }
+ if len(entries) > 0 && !cmd.CLI.Bool("force") {
+ fmt.Fprintf(cmd.CLI.App.ErrWriter, "Directory %s is not empty. Use --force to overwrite.\n", dir)
+ return nil
+ }
+ }
+ }
+
+ fmt.Println("Generating GitOps configuration files...")
+
+ cmd.AppConfig, err = cmd.Client.GetAppConfig()
+ if err != nil {
+ return err
+ }
+
+ // Gather the list of teams to process, which may include some
+ // virtual teams (i.e. global and no-team).
+ var teamsToProcess []teamToProcess
+ globalTeam := teamToProcess{
+ ID: nil,
+ Team: &fleet.Team{
+ Name: "Global",
+ },
+ }
+ noTeam := teamToProcess{
+ ID: ptr.Uint(0),
+ Team: &fleet.Team{
+ ID: 0,
+ Name: "No team",
+ },
+ }
+ switch {
+ case cmd.CLI.String("team") == "global" || !cmd.AppConfig.License.IsPremium():
+ teamsToProcess = []teamToProcess{globalTeam}
+ case cmd.CLI.String("team") == "no-team":
+ teamsToProcess = []teamToProcess{noTeam}
+ default:
+ // Get the list of teams.
+ teams, err := cmd.Client.ListTeams("")
+ if err != nil {
+ fmt.Fprintf(cmd.CLI.App.ErrWriter, "Error getting teams: %s\n", err)
+ return ErrGeneric
+ }
+ // If a specific team is requested, find it.
+ if cmd.CLI.String("team") != "" {
+ transformedSelectedName := generateFilename(cmd.CLI.String("team"))
+ for _, team := range teams {
+ transformedTeamName := generateFilename(team.Name)
+ if transformedSelectedName == transformedTeamName {
+ teamsToProcess = []teamToProcess{{
+ ID: &team.ID,
+ Team: &team,
+ }}
+ }
+ }
+ if len(teamsToProcess) == 0 {
+ fmt.Fprintf(cmd.CLI.App.ErrWriter, "Team %s not found\n", cmd.CLI.String("team"))
+ return nil
+ }
+ } else {
+ // Otherwise process all teams, including global and no-team.
+ teamsToProcess = make([]teamToProcess, len(teams)+2)
+ for i, team := range teams {
+ teamsToProcess[i] = teamToProcess{
+ ID: &team.ID,
+ Team: &team,
+ }
+ }
+ teamsToProcess[len(teams)] = noTeam
+ teamsToProcess[len(teams)+1] = globalTeam
+ }
+ }
+
+ // Iterate over the teams and generate the config files.
+ for _, teamToProcess := range teamsToProcess {
+ var teamFileName string
+ var fileName string
+ var team *fleet.Team
+ if teamToProcess.ID != nil {
+ team = teamToProcess.Team
+ }
+ // If it's a real team, start the filename with the team name.
+ if team != nil {
+ teamFileName = generateFilename(team.Name)
+ fileName = "teams/" + teamFileName + ".yml"
+ cmd.FilesToWrite[fileName] = map[string]interface{}{
+ "name": team.Name,
+ }
+ } else {
+ fileName = "default.yml"
+ }
+
+ // Set mdm to the global config by default.
+ // We'll override this for teams other than no-team.
+ mdmConfig := fleet.TeamMDM{
+ EnableDiskEncryption: cmd.AppConfig.MDM.EnableDiskEncryption.Value,
+ MacOSUpdates: cmd.AppConfig.MDM.MacOSUpdates,
+ IOSUpdates: cmd.AppConfig.MDM.IOSUpdates,
+ IPadOSUpdates: cmd.AppConfig.MDM.IPadOSUpdates,
+ WindowsUpdates: cmd.AppConfig.MDM.WindowsUpdates,
+ MacOSSetup: cmd.AppConfig.MDM.MacOSSetup,
+ }
+
+ if team == nil {
+ // Generate org settings, agent options and labels for the global config.
+ orgSettings, err := cmd.generateOrgSettings()
+ if err != nil {
+ fmt.Fprintf(cmd.CLI.App.ErrWriter, "Error generating org settings: %s\n", err)
+ return ErrGeneric
+ }
+
+ cmd.FilesToWrite["default.yml"] = map[string]interface{}{
+ "org_settings": orgSettings,
+ }
+
+ cmd.FilesToWrite[fileName].(map[string]interface{})["agent_options"] = cmd.AppConfig.AgentOptions
+
+ // Generate labels.
+ labels, err := cmd.generateLabels()
+ if err != nil {
+ fmt.Fprintf(cmd.CLI.App.ErrWriter, "Error generating labels: %s\n", err)
+ return ErrGeneric
+ }
+ cmd.FilesToWrite[fileName].(map[string]interface{})["labels"] = labels
+
+ } else if team.ID != 0 {
+ // Generate team settings and agent options for the team.
+ teamSettings, err := cmd.generateTeamSettings(fileName, team)
+ if err != nil {
+ fmt.Fprintf(cmd.CLI.App.ErrWriter, "Error generating org settings: %s\n", err)
+ return ErrGeneric
+ }
+
+ cmd.FilesToWrite[fileName].(map[string]interface{})["team_settings"] = teamSettings
+ cmd.FilesToWrite[fileName].(map[string]interface{})["agent_options"] = team.Config.AgentOptions
+
+ mdmConfig = team.Config.MDM
+ }
+
+ // Generate controls.
+ // Only do this on the global team if we're on the free tier.
+ if teamToProcess.ID != nil || !cmd.AppConfig.License.IsPremium() {
+ controls, err := cmd.generateControls(teamToProcess.ID, teamFileName, &mdmConfig)
+ if err != nil {
+ fmt.Fprintf(cmd.CLI.App.ErrWriter, "Error generating controls for %s: %s\n", teamFileName, err)
+ return ErrGeneric
+ }
+ cmd.FilesToWrite[fileName].(map[string]interface{})["controls"] = controls
+ }
+
+ // Generate software.
+ if team != nil {
+ software, err := cmd.generateSoftware(fileName, team.ID, teamFileName)
+ if err != nil {
+ fmt.Fprintf(cmd.CLI.App.ErrWriter, "Error generating software for %s: %s\n", teamFileName, err)
+ return ErrGeneric
+ }
+ if software == nil {
+ cmd.FilesToWrite[fileName].(map[string]interface{})["software"] = nil
+ } else {
+ cmd.FilesToWrite[fileName].(map[string]interface{})["software"] = software
+ }
+ }
+
+ // Generate policies.
+ policies, err := cmd.generatePolicies(teamToProcess.ID, teamFileName)
+ if err != nil {
+ fmt.Fprintf(cmd.CLI.App.ErrWriter, "Error generating policies for team %s: %s\n", team.Name, err)
+ return ErrGeneric
+ }
+ cmd.FilesToWrite[fileName].(map[string]interface{})["policies"] = policies
+
+ if team == nil || team.ID != 0 {
+ // Generate queries (except for on No Team).
+ queries, err := cmd.generateQueries(teamToProcess.ID)
+ if err != nil {
+ fmt.Fprintf(cmd.CLI.App.ErrWriter, "Error generating queries for team %s: %s\n", team.Name, err)
+ return ErrGeneric
+ }
+ cmd.FilesToWrite[fileName].(map[string]interface{})["queries"] = queries
+ }
+ }
+
+ // If we're just looking to print out a specific key, attempt to do that now.
+ if cmd.CLI.String("key") != "" {
+ var fileName string
+ // If a team is specified, get the file for that team.
+ switch cmd.CLI.String("team") {
+ case "global":
+ fileName = "default.yml"
+ case "":
+ fileName = "default.yml"
+ case "no-team":
+ fileName = "teams/no-team.yml"
+ default:
+ teamFileName := generateFilename(cmd.CLI.String("team"))
+ fileName = "teams/" + teamFileName + ".yml"
+ }
+
+ // Marshal and ummarshal the data to standardize the keys.
+ b, err := yaml.Marshal(cmd.FilesToWrite[fileName])
+ if err != nil {
+ fmt.Fprintf(cmd.CLI.App.ErrWriter, "Error marshaling settings: %s\n", err)
+ return ErrGeneric
+ }
+ var data map[string]interface{}
+ if err := yaml.Unmarshal(b, &data); err != nil {
+ fmt.Fprintf(cmd.CLI.App.ErrWriter, "Error unmarshaling settings: %s\n", err)
+ return ErrGeneric
+ }
+ value, ok := getValueAtKey(data, cmd.CLI.String("key"))
+ if !ok {
+ fmt.Fprintf(cmd.CLI.App.ErrWriter, "Key %s not found in %s\n", cmd.CLI.String("key"), fileName)
+ return nil
+ }
+ b, err = yaml.Marshal(value)
+ if err != nil {
+ fmt.Fprintf(cmd.CLI.App.ErrWriter, "Error marshaling value: %s\n", err)
+ return ErrGeneric
+ }
+ fmt.Fprintf(cmd.CLI.App.Writer, "%s", string(b))
+ return nil
+ }
+
+ emptyVal := regexp.MustCompile(`(?m):\s*(null|""|\[\]|\{\})\s*$`)
+ // Add comments to the result.
+ for path, fileToWrite := range cmd.FilesToWrite {
+ fullPath := fmt.Sprintf("%s/%s", cmd.CLI.String("dir"), path)
+ var b []byte
+ var err error
+ // If the filename ends in .yml, marshal it to YAML.
+ if strings.HasSuffix(path, ".yml") {
+ b, err = yaml.Marshal(fileToWrite)
+ if err != nil {
+ fmt.Fprintf(cmd.CLI.App.ErrWriter, "Error marshaling file to write: %s\n", err)
+ return ErrGeneric
+ }
+ for _, comment := range cmd.Comments {
+ if comment.Filename == path {
+ b = bytes.ReplaceAll(b,
+ []byte(comment.Token),
+ []byte("# "+comment.Comment),
+ )
+ }
+ }
+ // Replace any empty values with a blank.
+ b = emptyVal.ReplaceAll(b, []byte(":"))
+ } else {
+ b = []byte(fileToWrite.(string))
+ }
+
+ // If --print is set, print the file to stdout.
+ if cmd.CLI.Bool("print") {
+ fmt.Fprintf(cmd.CLI.App.Writer, "------------------------------------------------------------------\n%s\n------------------------------------------------------------------\n\n%+v\n\n", fullPath, string(b))
+ } else {
+ // Ensure the dir exists
+ err = os.MkdirAll(pathUtils.Dir(fullPath), 0o755)
+ if err != nil {
+ fmt.Fprintf(cmd.CLI.App.ErrWriter, "Error creating dir %s: %s\n\n", fullPath, err)
+ return ErrGeneric
+ }
+ // Write the file to the output directory.
+ err = os.WriteFile(fullPath, b, 0o644)
+ if err != nil {
+ fmt.Fprintf(cmd.CLI.App.ErrWriter, "Error writing file %s: %s\n\n", fullPath, err)
+ return ErrGeneric
+ }
+ }
+ }
+
+ fmt.Fprintf(cmd.CLI.App.Writer, "Config generation complete!\n")
+ if len(cmd.Messages.SecretWarnings) > 0 {
+ fmt.Fprintf(cmd.CLI.App.Writer, "Sensitive information was redacted in the following places, and will need to be replaced:\n")
+ for _, secretWarning := range cmd.Messages.SecretWarnings {
+ fmt.Fprintf(cmd.CLI.App.Writer, " • %s: %s\n", secretWarning.Filename, secretWarning.Key)
+ }
+ fmt.Fprintf(cmd.CLI.App.Writer, "\n")
+ }
+
+ if cmd.CLI.String("team") == "global" || cmd.CLI.String("team") == "" {
+ cmd.Messages.Notes = append(cmd.Messages.Notes, Note{
+ Filename: "default.yml",
+ Note: "Warning: YARA rules are not supported by this tool yet. If you have existing YARA rules, add them to the new default.yml file.",
+ })
+ }
+
+ if cmd.CLI.String("team") != "global" {
+ cmd.Messages.Notes = append(cmd.Messages.Notes, Note{
+ Note: "Warning: Software categories are not supported by this tool yet. If you have added any categories to software items, add them to the appropriate team .yml file.",
+ })
+ }
+
+ if len(cmd.Messages.Notes) > 0 {
+ fmt.Fprintf(cmd.CLI.App.Writer, "Other notes:\n")
+ for _, note := range cmd.Messages.Notes {
+ if note.Filename != "" {
+ fmt.Fprintf(cmd.CLI.App.Writer, " • %s: %s\n", note.Filename, note.Note)
+ } else {
+ fmt.Fprintf(cmd.CLI.App.Writer, " • %s\n", note.Note)
+ }
+ }
+ }
+
+ return nil
+}
+
+// Add a comment to a file. The comment is added as a token in the map, which
+// is replaced with the comment when the file is written.
+func (cmd *GenerateGitopsCommand) AddComment(filename, comment string) string {
+ token := fmt.Sprintf("___GITOPS_COMMENT_%d___", len(cmd.Comments))
+ cmd.Comments = append(cmd.Comments, Comment{
+ Filename: filename,
+ Comment: comment,
+ Token: token,
+ })
+ return token
+}
+
+// Given a name, generate a filename by replacing spaces with dashes and
+// removing any non-alphanumeric characters.
+func generateFilename(name string) string {
+ fileName := strings.Map(func(r rune) rune {
+ switch {
+ case unicode.IsLetter(r) || unicode.IsDigit(r):
+ return unicode.ToLower(r)
+ case unicode.IsSpace(r):
+ return '-'
+ default:
+ return -1
+ }
+ }, name)
+ // Strip any leading/trailing dashes using regex.
+ fileName = strings.Trim(fileName, "-")
+ return fileName
+}
+
+var isJSON = regexp.MustCompile(`^\s*\{`)
+
+// Generate a filename for a profile based on its name and contents.
+func generateProfileFilename(profile *fleet.MDMConfigProfilePayload, profileContentsString string) string {
+ fileName := generateFilename(profile.Name)
+ if profile.Platform == "darwin" {
+ if isJSON.MatchString(profileContentsString) {
+ fileName += ".json"
+ } else {
+ fileName += ".mobileconfig"
+ }
+ } else {
+ fileName += ".xml"
+ }
+ return fileName
+}
+
+func (cmd *GenerateGitopsCommand) generateOrgSettings() (orgSettings map[string]interface{}, err error) {
+ t := reflect.TypeOf(fleet.EnrichedAppConfig{})
+ orgSettings = map[string]interface{}{
+ jsonFieldName(t, "Features"): cmd.AppConfig.Features,
+ jsonFieldName(t, "FleetDesktop"): cmd.AppConfig.FleetDesktop,
+ jsonFieldName(t, "HostExpirySettings"): cmd.AppConfig.HostExpirySettings,
+ jsonFieldName(t, "OrgInfo"): cmd.AppConfig.OrgInfo,
+ jsonFieldName(t, "ServerSettings"): cmd.AppConfig.ServerSettings,
+ jsonFieldName(t, "WebhookSettings"): cmd.AppConfig.WebhookSettings,
+ }
+ integrations, err := cmd.generateIntegrations("default.yml", &GlobalOrTeamIntegrations{GlobalIntegrations: &cmd.AppConfig.Integrations})
+ if err != nil {
+ return nil, err
+ }
+ orgSettings[jsonFieldName(t, "Integrations")] = integrations
+ mdm, err := cmd.generateMDM(&cmd.AppConfig.MDM)
+ if err != nil {
+ return nil, err
+ }
+ orgSettings[jsonFieldName(t, "MDM")] = mdm
+ yaraRules, err := cmd.generateYaraRules(cmd.AppConfig.YaraRules)
+ if err != nil {
+ return nil, err
+ }
+ orgSettings[jsonFieldName(t, "YaraRules")] = yaraRules
+
+ // If --insecure is set, add real secrets.
+ if cmd.CLI.Bool("insecure") {
+ enrollSecrets, err := cmd.Client.GetEnrollSecretSpec()
+ if err != nil {
+ return nil, err
+ }
+ secrets := make([]map[string]string, len(enrollSecrets.Secrets))
+ for i, spec := range enrollSecrets.Secrets {
+ secrets[i] = map[string]string{"secret": spec.Secret}
+ }
+ orgSettings["secrets"] = secrets
+ } else {
+ orgSettings["secrets"] = []map[string]string{{"secret": cmd.AddComment("default.yml", "TODO: Add your enroll secrets here")}}
+ cmd.Messages.SecretWarnings = append(cmd.Messages.SecretWarnings, SecretWarning{
+ Filename: "default.yml",
+ Key: "org_settings.secrets",
+ })
+ }
+
+ if (orgSettings)[jsonFieldName(t, "SSOSettings")], err = cmd.generateSSOSettings(cmd.AppConfig.SSOSettings); err != nil {
+ return nil, err
+ }
+ return orgSettings, nil
+}
+
+func (cmd *GenerateGitopsCommand) generateSSOSettings(ssoSettings *fleet.SSOSettings) (map[string]interface{}, error) {
+ t := reflect.TypeOf(fleet.SSOSettings{})
+ result := map[string]interface{}{
+ jsonFieldName(t, "EnableSSO"): ssoSettings.EnableSSO,
+ jsonFieldName(t, "IDPName"): ssoSettings.IDPName,
+ jsonFieldName(t, "IDPImageURL"): ssoSettings.IDPImageURL,
+ jsonFieldName(t, "EntityID"): ssoSettings.EntityID,
+ jsonFieldName(t, "Metadata"): ssoSettings.Metadata,
+ jsonFieldName(t, "MetadataURL"): ssoSettings.MetadataURL,
+ jsonFieldName(t, "EnableSSOIdPLogin"): ssoSettings.EnableSSOIdPLogin,
+ }
+ if cmd.AppConfig.License.IsPremium() {
+ result[jsonFieldName(t, "EnableJITProvisioning")] = ssoSettings.EnableJITProvisioning
+ }
+ if !cmd.CLI.Bool("insecure") {
+ if ssoSettings.Metadata != "" {
+ result[jsonFieldName(t, "Metadata")] = cmd.AddComment("default.yml", "TODO: Add your SSO metadata here")
+ cmd.Messages.SecretWarnings = append(cmd.Messages.SecretWarnings, SecretWarning{
+ Filename: "default.yml",
+ Key: "org_settings.sso_settings.metadata",
+ })
+
+ }
+ if ssoSettings.MetadataURL != "" {
+ result[jsonFieldName(t, "MetadataURL")] = cmd.AddComment("default.yml", "TODO: Add your SSO metadata URL here")
+ cmd.Messages.SecretWarnings = append(cmd.Messages.SecretWarnings, SecretWarning{
+ Filename: "default.yml",
+ Key: "org_settings.sso_settings.metadata_url",
+ })
+ }
+ }
+ return result, nil
+}
+
+type GlobalOrTeamIntegrations struct {
+ GlobalIntegrations *fleet.Integrations `json:"global_integrations,omitempty"`
+ TeamIntegrations *fleet.TeamIntegrations `json:"team_integrations,omitempty"`
+}
+
+func (cmd *GenerateGitopsCommand) generateIntegrations(filePath string, integrations *GlobalOrTeamIntegrations) (map[string]interface{}, error) {
+ // Rather than crawling through the whole struct, we'll marshall/unmarshall it
+ // to get the keys we want.
+ b, err := yaml.Marshal(integrations)
+ if err != nil {
+ fmt.Fprintf(cmd.CLI.App.ErrWriter, "Error marshaling integrations: %s\n", err)
+ return nil, err
+ }
+ var result map[string]interface{}
+ if err := yaml.Unmarshal(b, &result); err != nil {
+ fmt.Fprintf(cmd.CLI.App.ErrWriter, "Error unmarshaling integrations: %s\n", err)
+ return nil, err
+ }
+ if result["global_integrations"] != nil {
+ result = result["global_integrations"].(map[string]interface{})
+ } else {
+ result = result["team_integrations"].(map[string]interface{})
+ if result["google_calendar"] != nil {
+ result = map[string]interface{}{
+ "google_calendar": result["google_calendar"],
+ }
+ } else {
+ result = nil
+ }
+ // Team integrations don't have secrets right now, so just return as-is.
+ return result, nil
+ }
+ // Obfuscate secrets if not in insecure mode.
+ if !cmd.CLI.Bool("insecure") {
+ if googleCalendar, ok := result["google_calendar"]; ok && googleCalendar != nil {
+ for _, intg := range googleCalendar.([]interface{}) {
+ if apiKeyJson, ok := intg.(map[string]interface{})["api_key_json"]; ok {
+ apiKeyJson.(map[string]interface{})["private_key"] = cmd.AddComment(filePath, "TODO: Add your Google Calendar API key JSON here")
+ cmd.Messages.SecretWarnings = append(cmd.Messages.SecretWarnings, SecretWarning{
+ Filename: "default.yml",
+ Key: "integrations.google_calendar.api_key_json.private_key",
+ })
+ }
+ }
+ }
+ if jira, ok := result["jira"]; ok && jira != nil {
+ for _, intg := range jira.([]interface{}) {
+ intg.(map[string]interface{})["api_token"] = cmd.AddComment(filePath, "TODO: Add your Jira API token here")
+ cmd.Messages.SecretWarnings = append(cmd.Messages.SecretWarnings, SecretWarning{
+ Filename: "default.yml",
+ Key: "integrations.jira.api_token",
+ })
+ }
+ }
+ if zendesk, ok := result["zendesk"]; ok && zendesk != nil {
+ for _, intg := range zendesk.([]interface{}) {
+ intg.(map[string]interface{})["api_token"] = cmd.AddComment(filePath, "TODO: Add your Zendesk API token here")
+ cmd.Messages.SecretWarnings = append(cmd.Messages.SecretWarnings, SecretWarning{
+ Filename: "default.yml",
+ Key: "integrations.zendesk.api_token",
+ })
+ }
+ }
+ if digicert, ok := result["digicert"]; ok && digicert != nil {
+ for _, intg := range digicert.([]interface{}) {
+ intg.(map[string]interface{})["api_token"] = cmd.AddComment(filePath, "TODO: Add your Digicert API token here")
+ cmd.Messages.SecretWarnings = append(cmd.Messages.SecretWarnings, SecretWarning{
+ Filename: "default.yml",
+ Key: "integrations.digicert.api_token",
+ })
+ }
+ }
+ if ndes_scep_proxy, ok := result["ndes_scep_proxy"]; ok && ndes_scep_proxy != nil {
+ ndes_scep_proxy.(map[string]interface{})["password"] = cmd.AddComment(filePath, "TODO: Add your NDES SCEP proxy password here")
+ cmd.Messages.SecretWarnings = append(cmd.Messages.SecretWarnings, SecretWarning{
+ Filename: "default.yml",
+ Key: "integrations.ndes_scep_proxy.password",
+ })
+ }
+ if custom_scep_proxy, ok := result["custom_scep_proxy"]; ok && custom_scep_proxy != nil {
+ for _, intg := range custom_scep_proxy.([]interface{}) {
+ intg.(map[string]interface{})["challenge"] = cmd.AddComment(filePath, "TODO: Add your custom SCEP proxy challenge here")
+ cmd.Messages.SecretWarnings = append(cmd.Messages.SecretWarnings, SecretWarning{
+ Filename: "default.yml",
+ Key: "integrations.custom_scep_proxy.challenge",
+ })
+ }
+ }
+ }
+
+ return result, nil
+}
+
+func (cmd *GenerateGitopsCommand) generateMDM(mdm *fleet.MDM) (map[string]interface{}, error) {
+ t := reflect.TypeOf(fleet.MDM{})
+ result := map[string]interface{}{
+ jsonFieldName(t, "AppleServerURL"): mdm.AppleServerURL,
+ jsonFieldName(t, "EndUserAuthentication"): mdm.EndUserAuthentication,
+ }
+ if cmd.AppConfig.License.IsPremium() {
+ result[jsonFieldName(t, "AppleBusinessManager")] = mdm.AppleBusinessManager
+ result[jsonFieldName(t, "VolumePurchasingProgram")] = mdm.VolumePurchasingProgram
+ }
+
+ if !cmd.CLI.Bool("insecure") {
+ if auth, ok := result[jsonFieldName(t, "EndUserAuthentication")]; ok {
+ endUserAuth := auth.(fleet.MDMEndUserAuthentication)
+ if endUserAuth.Metadata != "" {
+ endUserAuth.Metadata = cmd.AddComment("default.yml", "TODO: Add your MDM end user auth metadata here")
+ cmd.Messages.SecretWarnings = append(cmd.Messages.SecretWarnings, SecretWarning{
+ Filename: "default.yml",
+ Key: "mdm.end_user_authentication.metadata",
+ })
+ }
+ if endUserAuth.MetadataURL != "" {
+ endUserAuth.MetadataURL = cmd.AddComment("default.yml", "TODO: Add your MDM end user auth metadata URL here")
+ cmd.Messages.SecretWarnings = append(cmd.Messages.SecretWarnings, SecretWarning{
+ Filename: "default.yml",
+ Key: "mdm.end_user_authentication.metadata_url",
+ })
+ }
+ result[jsonFieldName(t, "EndUserAuthentication")] = endUserAuth
+ }
+ }
+ return result, nil
+}
+
+func (cmd *GenerateGitopsCommand) generateYaraRules(yaraRules []fleet.YaraRule) (map[string]interface{}, error) {
+ // TODC -- come up with a way to export Yara rules.
+ return map[string]interface{}{}, nil
+}
+
+func (cmd *GenerateGitopsCommand) generateTeamSettings(filePath string, team *fleet.Team) (teamSettings map[string]interface{}, err error) {
+ t := reflect.TypeOf(fleet.TeamConfig{})
+ teamSettings = map[string]interface{}{
+ jsonFieldName(t, "Features"): team.Config.Features,
+ jsonFieldName(t, "HostExpirySettings"): team.Config.HostExpirySettings,
+ jsonFieldName(t, "WebhookSettings"): team.Config.WebhookSettings,
+ }
+ integrations, err := cmd.generateIntegrations(filePath, &GlobalOrTeamIntegrations{TeamIntegrations: &team.Config.Integrations})
+ if err != nil {
+ return nil, err
+ }
+ teamSettings[jsonFieldName(t, "Integrations")] = integrations
+ // If --insecure is set, add real secrets.
+ if cmd.CLI.Bool("insecure") {
+ secrets := make([]map[string]string, len(team.Secrets))
+ for i, spec := range team.Secrets {
+ secrets[i] = map[string]string{"secret": spec.Secret}
+ }
+ teamSettings["secrets"] = secrets
+ } else {
+ teamSettings["secrets"] = []map[string]string{{"secret": cmd.AddComment(filePath, "TODO: Add your enroll secrets here")}}
+ cmd.Messages.SecretWarnings = append(cmd.Messages.SecretWarnings, SecretWarning{
+ Filename: filePath,
+ Key: "team_settings.secrets",
+ })
+ }
+ return teamSettings, nil
+}
+
+func (cmd *GenerateGitopsCommand) generateControls(teamId *uint, teamName string, teamMdm *fleet.TeamMDM) (map[string]interface{}, error) {
+ t := reflect.TypeOf(spec.GitOpsControls{})
+ result := map[string]interface{}{}
+
+ if teamId != nil {
+ scripts, err := cmd.generateScripts(teamId, teamName)
+ if err != nil {
+ fmt.Fprintf(cmd.CLI.App.ErrWriter, "Error generating scripts: %s\n", err)
+ return nil, err
+ }
+ result[jsonFieldName(t, "Scripts")] = scripts
+ }
+
+ profiles, err := cmd.generateProfiles(teamId, teamName)
+ if err != nil {
+ fmt.Fprintf(cmd.CLI.App.ErrWriter, "Error generating profiles: %s\n", err)
+ return nil, err
+ }
+ if profiles != nil {
+ if len(profiles["apple_profiles"].([]map[string]interface{})) > 0 {
+ result[jsonFieldName(t, "MacOSSettings")] = map[string]interface{}{
+ "custom_settings": profiles["apple_profiles"],
+ }
+ }
+ if len(profiles["windows_profiles"].([]map[string]interface{})) > 0 {
+ result[jsonFieldName(t, "WindowsSettings")] = map[string]interface{}{
+ "custom_settings": profiles["windows_profiles"],
+ }
+ }
+ }
+
+ if teamMdm != nil && cmd.AppConfig.License.IsPremium() {
+ mdmT := reflect.TypeOf(fleet.TeamMDM{})
+
+ result[jsonFieldName(mdmT, "EnableDiskEncryption")] = teamMdm.EnableDiskEncryption
+ result[jsonFieldName(mdmT, "MacOSUpdates")] = teamMdm.MacOSUpdates
+ result[jsonFieldName(mdmT, "IOSUpdates")] = teamMdm.IOSUpdates
+ result[jsonFieldName(mdmT, "IPadOSUpdates")] = teamMdm.IPadOSUpdates
+ result[jsonFieldName(mdmT, "WindowsUpdates")] = teamMdm.WindowsUpdates
+
+ if teamId == nil || *teamId == 0 {
+ mdmT := reflect.TypeOf(fleet.MDM{})
+ result[jsonFieldName(mdmT, "WindowsMigrationEnabled")] = cmd.AppConfig.MDM.WindowsMigrationEnabled
+ result[jsonFieldName(mdmT, "MacOSMigration")] = cmd.AppConfig.MDM.MacOSMigration
+ }
+ if cmd.AppConfig.MDM.WindowsEnabledAndConfigured {
+ result["windows_enabled_and_configured"] = cmd.AppConfig.MDM.WindowsEnabledAndConfigured
+ }
+
+ // TODO -- add an IsSet() method to MacOSSSetup to encapsulate this logic.
+ if teamMdm.MacOSSetup.BootstrapPackage.Value != "" || teamMdm.MacOSSetup.EnableEndUserAuthentication || teamMdm.MacOSSetup.MacOSSetupAssistant.Value != "" || teamMdm.MacOSSetup.Script.Value != "" || (teamMdm.MacOSSetup.Software.Valid && len(teamMdm.MacOSSetup.Software.Value) > 0) {
+ result[jsonFieldName(mdmT, "MacOSSetup")] = "TODO: update with your macos_setup configuration"
+ cmd.Messages.Notes = append(cmd.Messages.Notes, Note{
+ Filename: teamName,
+ Note: "The macos_setup configuration is not supported by this tool yet. To configure it, please follow the Fleet documentation at https://fleetdm.com/docs/configuration/yaml-files#macos-setup",
+ })
+ }
+ }
+
+ return result, nil
+}
+
+func (cmd *GenerateGitopsCommand) generateProfiles(teamId *uint, teamName string) (map[string]interface{}, error) {
+ // Get profiles.
+ profiles, err := cmd.Client.ListConfigurationProfiles(teamId)
+ if err != nil {
+ if strings.Contains(err.Error(), fleet.MDMNotConfiguredMessage) {
+ return nil, nil
+ }
+
+ fmt.Fprintf(cmd.CLI.App.ErrWriter, "Error getting profiles: %v\n", err)
+ return nil, err
+ }
+ if len(profiles) == 0 {
+ return nil, nil
+ }
+ appleProfilesSlice := make([]map[string]interface{}, 0)
+ windowsProfilesSlice := make([]map[string]interface{}, 0)
+ for _, profile := range profiles {
+ profileSpec := map[string]interface{}{}
+ // Parse any labels.
+ if profile.LabelsIncludeAll != nil {
+ labels := make([]string, len(profile.LabelsIncludeAll))
+ for i, label := range profile.LabelsIncludeAll {
+ labels[i] = label.LabelName
+ }
+ profileSpec["labels_include_all"] = labels
+ }
+ if profile.LabelsIncludeAny != nil {
+ labels := make([]string, len(profile.LabelsIncludeAny))
+ for i, label := range profile.LabelsIncludeAny {
+ labels[i] = label.LabelName
+ }
+ profileSpec["labels_include_any"] = labels
+ }
+ if profile.LabelsExcludeAny != nil {
+ labels := make([]string, len(profile.LabelsExcludeAny))
+ for i, label := range profile.LabelsExcludeAny {
+ labels[i] = label.LabelName
+ }
+ profileSpec["labels_exclude_any"] = labels
+ }
+
+ // Download the profile contents.
+ profileContents, err := cmd.Client.GetProfileContents(profile.ProfileUUID)
+ if err != nil {
+ fmt.Fprintf(cmd.CLI.App.ErrWriter, "Error getting profile contents: %s\n", err)
+ return nil, err
+ }
+ profileContentsString := string(profileContents)
+
+ fileName := fmt.Sprintf("profiles/%s", generateProfileFilename(profile, profileContentsString))
+ if teamId == nil {
+ fileName = fmt.Sprintf("lib/%s", fileName)
+ } else {
+ fileName = fmt.Sprintf("lib/%s/%s", teamName, fileName)
+ }
+
+ cmd.FilesToWrite[fileName] = profileContentsString
+ var path string
+ if teamId == nil {
+ path = fmt.Sprintf("./%s", fileName)
+ } else {
+ path = fmt.Sprintf("../%s", fileName)
+ }
+
+ profileSpec["path"] = path
+
+ if profile.Platform == "darwin" {
+ appleProfilesSlice = append(appleProfilesSlice, profileSpec)
+ } else {
+ windowsProfilesSlice = append(windowsProfilesSlice, profileSpec)
+ }
+ }
+
+ return map[string]interface{}{
+ "apple_profiles": appleProfilesSlice,
+ "windows_profiles": windowsProfilesSlice,
+ }, nil
+}
+
+func (cmd *GenerateGitopsCommand) generateScripts(teamId *uint, teamName string) ([]map[string]interface{}, error) {
+ // Get scripts.
+ query := ""
+ if teamId != nil {
+ query = fmt.Sprintf("team_id=%d", *teamId)
+ }
+ scripts, err := cmd.Client.ListScripts(query)
+ if err != nil {
+ fmt.Fprintf(cmd.CLI.App.ErrWriter, "Error getting scripts: %s\n", err)
+ return nil, err
+ }
+ if len(scripts) == 0 {
+ return nil, nil
+ }
+
+ scriptSlice := make([]map[string]interface{}, len(scripts))
+ // For each script, get the contents and add a new file for output.
+ for i, script := range scripts {
+ fileName := fmt.Sprintf("scripts/%s", script.Name)
+ if teamId == nil {
+ fileName = fmt.Sprintf("lib/%s", fileName)
+ } else {
+ fileName = fmt.Sprintf("lib/%s/%s", teamName, fileName)
+ }
+ scriptContents, err := cmd.Client.GetScriptContents(script.ID)
+ if err != nil {
+ fmt.Fprintf(cmd.CLI.App.ErrWriter, "Error getting script contents: %s\n", err)
+ return nil, err
+ }
+ cmd.FilesToWrite[fileName] = string(scriptContents)
+ var path string
+ if teamId == nil {
+ path = fmt.Sprintf("./%s", fileName)
+ } else {
+ path = fmt.Sprintf("../%s", fileName)
+ }
+ scriptSlice[i] = map[string]interface{}{
+ "path": path,
+ }
+ cmd.ScriptList[script.ID] = path
+ }
+ return scriptSlice, nil
+}
+
+func (cmd *GenerateGitopsCommand) generatePolicies(teamId *uint, filePath string) ([]map[string]interface{}, error) {
+ policies, err := cmd.Client.GetPolicies(teamId)
+ if err != nil {
+ fmt.Fprintf(cmd.CLI.App.ErrWriter, "Error getting policies: %s\n", err)
+ return nil, err
+ }
+ if len(policies) == 0 {
+ return nil, nil
+ }
+ t := reflect.TypeOf(fleet.Policy{})
+ result := make([]map[string]interface{}, len(policies))
+ for i, policy := range policies {
+ policySpec := map[string]interface{}{
+ jsonFieldName(t, "Name"): policy.Name,
+ jsonFieldName(t, "Description"): policy.Description,
+ jsonFieldName(t, "Resolution"): policy.Resolution,
+ jsonFieldName(t, "Query"): policy.Query,
+ jsonFieldName(t, "Platform"): policy.Platform,
+ jsonFieldName(t, "Critical"): policy.Critical,
+ jsonFieldName(t, "CalendarEventsEnabled"): policy.CalendarEventsEnabled,
+ }
+ // Handle software automation.
+ if policy.InstallSoftware != nil {
+ if software, ok := cmd.SoftwareList[policy.InstallSoftware.SoftwareTitleID]; ok {
+ policySpec["install_software"] = map[string]interface{}{
+ "hash_sha256": software.Hash + " " + software.Comment,
+ }
+ } else {
+ policySpec["install_software"] = map[string]interface{}{
+ "hash_sha256": cmd.AddComment(filePath, "TODO: Add your hash_sha256 here"),
+ }
+ cmd.Messages.Notes = append(cmd.Messages.Notes, Note{
+ Filename: filePath,
+ Note: fmt.Sprintf("Warning: policy %s software (install_software) has no hash_sha256. This is required for GitOps to work. Please add the hash_sha256 manually.", policy.Name),
+ })
+ }
+ }
+ // Handle script automation.
+ if policy.RunScript != nil {
+ if scriptPath, ok := cmd.ScriptList[policy.RunScript.ID]; ok {
+ policySpec["run_script"] = map[string]interface{}{
+ "path": scriptPath,
+ }
+ }
+ }
+ // Parse any labels.
+ if policy.LabelsIncludeAny != nil {
+ labels := make([]string, len(policy.LabelsIncludeAny))
+ for i, label := range policy.LabelsIncludeAny {
+ labels[i] = label.LabelName
+ }
+ policySpec["labels_include_any"] = labels
+ }
+ if policy.LabelsExcludeAny != nil {
+ labels := make([]string, len(policy.LabelsExcludeAny))
+ for i, label := range policy.LabelsExcludeAny {
+ labels[i] = label.LabelName
+ }
+ policySpec["labels_exclude_any"] = labels
+ }
+ result[i] = policySpec
+ }
+ return result, nil
+}
+
+func (cmd *GenerateGitopsCommand) generateQueries(teamId *uint) ([]map[string]interface{}, error) {
+ queries, err := cmd.Client.GetQueries(teamId, nil)
+ if err != nil {
+ fmt.Fprintf(cmd.CLI.App.ErrWriter, "Error getting queries: %s\n", err)
+ return nil, err
+ }
+ if len(queries) == 0 {
+ return nil, nil
+ }
+ t := reflect.TypeOf(fleet.Query{})
+ result := make([]map[string]interface{}, len(queries))
+ for i, query := range queries {
+ querySpec := map[string]interface{}{
+ jsonFieldName(t, "Name"): query.Name,
+ jsonFieldName(t, "Description"): query.Description,
+ jsonFieldName(t, "Query"): query.Query,
+ jsonFieldName(t, "Platform"): query.Platform,
+ jsonFieldName(t, "Interval"): query.Interval,
+ jsonFieldName(t, "ObserverCanRun"): query.ObserverCanRun,
+ jsonFieldName(t, "AutomationsEnabled"): query.AutomationsEnabled,
+ jsonFieldName(t, "MinOsqueryVersion"): query.MinOsqueryVersion,
+ jsonFieldName(t, "Logging"): query.Logging,
+ jsonFieldName(t, "DiscardData"): query.DiscardData,
+ }
+
+ // Parse any labels.
+ if query.LabelsIncludeAny != nil {
+ labels := make([]string, len(query.LabelsIncludeAny))
+ for i, label := range query.LabelsIncludeAny {
+ labels[i] = label.LabelName
+ }
+ querySpec["labels_include_any"] = labels
+ }
+
+ result[i] = querySpec
+ }
+ return result, nil
+}
+
+func (cmd *GenerateGitopsCommand) generateSoftware(filePath string, teamId uint, teamFilename string) (map[string]interface{}, error) {
+ query := fmt.Sprintf("available_for_install=1&team_id=%d", teamId)
+ software, err := cmd.Client.ListSoftwareTitles(query)
+ if err != nil {
+ fmt.Fprintf(cmd.CLI.App.ErrWriter, "Error getting software: %s\n", err)
+ return nil, err
+ }
+ if len(software) == 0 {
+ return nil, nil
+ }
+ result := make(map[string]interface{})
+ packages := make([]map[string]interface{}, 0)
+ appStoreApps := make([]map[string]interface{}, 0)
+ for _, sw := range software {
+ versions := make([]string, len(sw.Versions))
+ for j, version := range sw.Versions {
+ versions[j] = version.Version
+ }
+ softwareSpec := make(map[string]interface{})
+ switch {
+ case sw.SoftwarePackage != nil:
+ pkgName := ""
+ if sw.SoftwarePackage.Name != "" {
+ pkgName = fmt.Sprintf(" (%s)", sw.SoftwarePackage.Name)
+ }
+ comment := cmd.AddComment(filePath, fmt.Sprintf("%s%s version %s", sw.Name, pkgName, strings.Join(versions, ", ")))
+ if sw.HashSHA256 == nil {
+ cmd.Messages.Notes = append(cmd.Messages.Notes, Note{
+ Filename: filePath,
+ Note: fmt.Sprintf("Warning: software %s has no hash_sha256. This is required for GitOps to work. Please add it manually.", sw.Name),
+ })
+ softwareSpec["hash_sha256"] = cmd.AddComment(filePath, "TODO: Add your hash_sha256 here")
+ } else {
+ softwareSpec["hash_sha256"] = *sw.HashSHA256 + " " + comment
+ cmd.SoftwareList[sw.ID] = Software{
+ Hash: *sw.HashSHA256,
+ Comment: comment,
+ }
+ }
+ case sw.AppStoreApp != nil:
+ softwareSpec["app_store_id"] = sw.AppStoreApp.AppStoreID
+ default:
+ fmt.Fprintf(cmd.CLI.App.ErrWriter, "Error: software %s has no software package or app store app\n", sw.Name)
+ continue
+ }
+
+ softwareTitle, err := cmd.Client.GetSoftwareTitleByID(sw.ID, &teamId)
+ if err != nil {
+ fmt.Fprintf(cmd.CLI.App.ErrWriter, "Error getting software title %s: %s\n", sw.Name, err)
+ return nil, err
+ }
+
+ if softwareTitle.SoftwarePackage != nil {
+ filenamePrefix := generateFilename(sw.Name) + "-" + sw.SoftwarePackage.Platform
+ if softwareTitle.SoftwarePackage.InstallScript != "" {
+ script := softwareTitle.SoftwarePackage.InstallScript
+ fileName := fmt.Sprintf("lib/%s/scripts/%s", teamFilename, filenamePrefix+"-install")
+ path := fmt.Sprintf("../%s", fileName)
+ softwareSpec["install_script"] = map[string]interface{}{
+ "path": path,
+ }
+ cmd.FilesToWrite[fileName] = script
+ }
+
+ if softwareTitle.SoftwarePackage.PostInstallScript != "" {
+ script := softwareTitle.SoftwarePackage.PostInstallScript
+ fileName := fmt.Sprintf("lib/%s/scripts/%s", teamFilename, filenamePrefix+"-postinstall")
+ path := fmt.Sprintf("../%s", fileName)
+ softwareSpec["post_install_script"] = map[string]interface{}{
+ "path": path,
+ }
+ cmd.FilesToWrite[fileName] = script
+ }
+
+ if softwareTitle.SoftwarePackage.UninstallScript != "" {
+ script := softwareTitle.SoftwarePackage.UninstallScript
+ fileName := fmt.Sprintf("lib/%s/scripts/%s", teamFilename, filenamePrefix+"-uninstall")
+ path := fmt.Sprintf("../%s", fileName)
+ softwareSpec["uninstall_script"] = map[string]interface{}{
+ "path": path,
+ }
+ cmd.FilesToWrite[fileName] = script
+ }
+
+ if softwareTitle.SoftwarePackage.PreInstallQuery != "" {
+ query := softwareTitle.SoftwarePackage.PreInstallQuery
+ fileName := fmt.Sprintf("lib/%s/queries/%s", teamFilename, filenamePrefix+"-preinstallquery.yml")
+ path := fmt.Sprintf("../%s", fileName)
+ softwareSpec["pre_install_query"] = map[string]interface{}{
+ "path": path,
+ }
+ cmd.FilesToWrite[fileName] = []map[string]interface{}{{
+ "query": query,
+ }}
+ }
+
+ if softwareTitle.SoftwarePackage.SelfService {
+ softwareSpec["self_service"] = softwareTitle.SoftwarePackage.SelfService
+ }
+ }
+
+ if cmd.AppConfig.License.IsPremium() {
+ var labels []fleet.SoftwareScopeLabel
+ var labelKey string
+ if softwareTitle.SoftwarePackage != nil {
+ if len(softwareTitle.SoftwarePackage.LabelsIncludeAny) > 0 {
+ labels = softwareTitle.SoftwarePackage.LabelsIncludeAny
+ labelKey = "labels_include_any"
+ }
+ if len(softwareTitle.SoftwarePackage.LabelsExcludeAny) > 0 {
+ labels = softwareTitle.SoftwarePackage.LabelsExcludeAny
+ labelKey = "labels_exclude_any"
+ }
+ } else {
+ if len(softwareTitle.AppStoreApp.LabelsIncludeAny) > 0 {
+ labels = softwareTitle.AppStoreApp.LabelsIncludeAny
+ labelKey = "labels_include_any"
+ }
+ if len(softwareTitle.AppStoreApp.LabelsExcludeAny) > 0 {
+ labels = softwareTitle.AppStoreApp.LabelsExcludeAny
+ labelKey = "labels_exclude_any"
+ }
+ }
+ if len(labels) > 0 {
+ labelsList := make([]string, len(labels))
+ for i, label := range labels {
+ labelsList[i] = label.LabelName
+ }
+ softwareSpec[labelKey] = labelsList
+ }
+ }
+
+ if sw.SoftwarePackage != nil {
+ packages = append(packages, softwareSpec)
+ } else {
+ appStoreApps = append(appStoreApps, softwareSpec)
+ }
+ }
+ if len(packages) > 0 {
+ result["packages"] = packages
+ }
+ if len(appStoreApps) > 0 {
+ result["app_store_apps"] = appStoreApps
+ }
+ // TODO -- add FMA apps to the result. Currently they will be output using hashes.
+ return result, nil
+}
+
+func (cmd *GenerateGitopsCommand) generateLabels() ([]map[string]interface{}, error) {
+ labels, err := cmd.Client.GetLabels()
+ if err != nil {
+ fmt.Fprintf(cmd.CLI.App.ErrWriter, "Error getting labels: %s\n", err)
+ return nil, err
+ }
+ if len(labels) == 0 {
+ return nil, nil
+ }
+ t := reflect.TypeOf(fleet.LabelSpec{})
+ result := make([]map[string]interface{}, 0)
+ for _, label := range labels {
+ if label.LabelType != fleet.LabelTypeRegular {
+ continue
+ }
+ labelSpec := map[string]interface{}{
+ jsonFieldName(t, "Name"): label.Name,
+ jsonFieldName(t, "Description"): label.Description,
+ jsonFieldName(t, "LabelMembershipType"): label.LabelMembershipType,
+ }
+ if label.Platform != "" {
+ labelSpec[jsonFieldName(t, "Platform")] = label.Platform
+ }
+ if label.LabelMembershipType == fleet.LabelMembershipTypeDynamic {
+ labelSpec[jsonFieldName(t, "Query")] = label.Query
+ } else {
+ labelSpec[jsonFieldName(t, "Hosts")] = label.Hosts
+ }
+
+ result = append(result, labelSpec)
+ }
+ return result, nil
+}
+
+var _ generateGitopsClient = (*service.Client)(nil)
diff --git a/cmd/fleetctl/generate_gitops_test.go b/cmd/fleetctl/generate_gitops_test.go
new file mode 100644
index 0000000000..8705d28bb8
--- /dev/null
+++ b/cmd/fleetctl/generate_gitops_test.go
@@ -0,0 +1,948 @@
+// filepath: cmd/fleetctl/generate_gitops_test.go
+package main
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "flag"
+ "fmt"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/fleetdm/fleet/v4/server/fleet"
+ "github.com/fleetdm/fleet/v4/server/ptr"
+ "github.com/ghodss/yaml"
+ "github.com/google/uuid"
+ "github.com/stretchr/testify/require"
+ "github.com/urfave/cli/v2"
+)
+
+type MockClient struct {
+ IsFree bool
+}
+
+func (c *MockClient) GetAppConfig() (*fleet.EnrichedAppConfig, error) {
+ b, err := os.ReadFile("./testdata/generateGitops/appConfig.json")
+ if err != nil {
+ return nil, err
+ }
+ var appConfig fleet.EnrichedAppConfig
+ if err := json.Unmarshal(b, &appConfig); err != nil {
+ return nil, err
+ }
+ if c.IsFree == true {
+ appConfig.License.Tier = fleet.TierFree
+ }
+ return &appConfig, nil
+}
+
+func (MockClient) GetEnrollSecretSpec() (*fleet.EnrollSecretSpec, error) {
+ spec := &fleet.EnrollSecretSpec{
+ Secrets: []*fleet.EnrollSecret{
+ {
+ Secret: "some-secret-number-one",
+ },
+ {
+ Secret: "some-secret-number-two",
+ },
+ },
+ }
+ return spec, nil
+}
+
+func (MockClient) ListTeams(query string) ([]fleet.Team, error) {
+ b, err := os.ReadFile("./testdata/generateGitops/teamConfig.json")
+ if err != nil {
+ return nil, err
+ }
+ var config fleet.TeamConfig
+ if err := json.Unmarshal(b, &config); err != nil {
+ return nil, err
+ }
+ teams := []fleet.Team{
+ {
+ ID: 1,
+ Name: "Team A",
+ Config: config,
+ Secrets: []*fleet.EnrollSecret{
+ {
+ Secret: "some-team-secret",
+ },
+ },
+ },
+ }
+ return teams, nil
+}
+
+func (MockClient) ListScripts(query string) ([]*fleet.Script, error) {
+ switch query {
+ case "team_id=1":
+ return []*fleet.Script{{
+ ID: 2,
+ TeamID: ptr.Uint(1),
+ Name: "Script B.ps1",
+ ScriptContentID: 2,
+ }}, nil
+ case "team_id=0":
+ return []*fleet.Script{{
+ ID: 3,
+ TeamID: ptr.Uint(0),
+ Name: "Script Z.ps1",
+ ScriptContentID: 3,
+ }}, nil
+ default:
+ return nil, fmt.Errorf("unexpected query: %s", query)
+ }
+}
+
+func (MockClient) ListConfigurationProfiles(teamID *uint) ([]*fleet.MDMConfigProfilePayload, error) {
+ if teamID == nil {
+ return []*fleet.MDMConfigProfilePayload{
+ {
+ ProfileUUID: "global-macos-mobileconfig-profile-uuid",
+ Name: "Global MacOS MobileConfig Profile",
+ Platform: "darwin",
+ Identifier: "com.example.global-macos-mobileconfig-profile",
+ LabelsIncludeAll: []fleet.ConfigurationProfileLabel{{
+ LabelName: "Label A",
+ }, {
+ LabelName: "Label B",
+ }},
+ },
+ {
+ ProfileUUID: "global-macos-json-profile-uuid",
+ Name: "Global MacOS JSON Profile",
+ Platform: "darwin",
+ Identifier: "com.example.global-macos-json-profile",
+ LabelsExcludeAny: []fleet.ConfigurationProfileLabel{{
+ LabelName: "Label C",
+ }},
+ },
+ {
+ ProfileUUID: "global-windows-profile-uuid",
+ Name: "Global Windows Profile",
+ Platform: "windows",
+ Identifier: "com.example.global-windows-profile",
+ LabelsIncludeAny: []fleet.ConfigurationProfileLabel{{
+ LabelName: "Label D",
+ }},
+ },
+ }, nil
+ }
+ if *teamID == 1 {
+ return []*fleet.MDMConfigProfilePayload{
+ {
+ ProfileUUID: "test-mobileconfig-profile-uuid",
+ Name: "Team MacOS MobileConfig Profile",
+ Platform: "darwin",
+ Identifier: "com.example.team-macos-mobileconfig-profile",
+ },
+ }, nil
+ }
+ if *teamID == 0 {
+ return nil, nil
+ }
+ return nil, fmt.Errorf("unexpected team ID: %v", *teamID)
+}
+
+func (MockClient) GetScriptContents(scriptID uint) ([]byte, error) {
+ if scriptID == 2 {
+ return []byte("pop goes the weasel!"), nil
+ }
+ if scriptID == 3 {
+ return []byte("#!/usr/bin/env pwsh\necho \"Hello from Script B!\""), nil
+ }
+ return nil, errors.New("script not found")
+}
+
+func (MockClient) GetProfileContents(profileID string) ([]byte, error) {
+ switch profileID {
+ case "global-macos-mobileconfig-profile-uuid":
+ return []byte("global macos mobileconfig profile"), nil
+ case "global-macos-json-profile-uuid":
+ return []byte(`{"profile": "global macos json profile"}`), nil
+ case "global-windows-profile-uuid":
+ return []byte("global windows profile"), nil
+ case "test-mobileconfig-profile-uuid":
+ return []byte("test mobileconfig profile"), nil
+ }
+ return nil, errors.New("profile not found")
+}
+
+func (MockClient) GetTeam(teamID uint) (*fleet.Team, error) {
+ if teamID == 1 {
+ b, err := os.ReadFile("./testdata/generateGitops/teamConfig.json")
+ if err != nil {
+ return nil, err
+ }
+ var config fleet.TeamConfig
+ if err := json.Unmarshal(b, &config); err != nil {
+ return nil, err
+ }
+ return &fleet.Team{
+ ID: 1,
+ Name: "Test Team",
+ Config: config,
+ Secrets: []*fleet.EnrollSecret{
+ {
+ Secret: "some-team-secret",
+ },
+ },
+ }, nil
+ }
+
+ return nil, errors.New("team not found")
+}
+
+func (MockClient) ListSoftwareTitles(query string) ([]fleet.SoftwareTitleListResult, error) {
+ switch query {
+ case "available_for_install=1&team_id=1":
+ return []fleet.SoftwareTitleListResult{
+ {
+ ID: 1,
+ Name: "My Software Package",
+ Versions: []fleet.SoftwareVersion{{
+ ID: 1,
+ Version: "1.0.0",
+ }, {
+ ID: 2,
+ Version: "2.0.0",
+ }},
+ HashSHA256: ptr.String("software-package-hash"),
+ SoftwarePackage: &fleet.SoftwarePackageOrApp{
+ Name: "my-software.pkg",
+ Platform: "darwin",
+ },
+ },
+ {
+ ID: 2,
+ Name: "My App Store App",
+ Versions: []fleet.SoftwareVersion{{
+ ID: 3,
+ Version: "5.6.7",
+ }, {
+ ID: 4,
+ Version: "8.9.10",
+ }},
+ AppStoreApp: &fleet.SoftwarePackageOrApp{
+ AppStoreID: "com.example.team-software",
+ },
+ HashSHA256: ptr.String("app-store-app-hash"),
+ },
+ }, nil
+ case "available_for_install=1&team_id=0":
+ return []fleet.SoftwareTitleListResult{}, nil
+ default:
+ return nil, fmt.Errorf("unexpected query: %s", query)
+ }
+}
+
+func (MockClient) GetPolicies(teamID *uint) ([]*fleet.Policy, error) {
+ if teamID == nil {
+ return []*fleet.Policy{
+ {
+ PolicyData: fleet.PolicyData{
+ ID: 1,
+ Name: "Global Policy",
+ Query: "SELECT * FROM global_policy WHERE id = 1",
+ Resolution: ptr.String("Do a global thing"),
+ Description: "This is a global policy",
+ Platform: "darwin",
+ LabelsIncludeAny: []fleet.LabelIdent{{
+ LabelName: "Label A",
+ }, {
+ LabelName: "Label B",
+ }},
+ },
+ InstallSoftware: &fleet.PolicySoftwareTitle{
+ SoftwareTitleID: 1,
+ },
+ },
+ }, nil
+ }
+ return []*fleet.Policy{
+ {
+ PolicyData: fleet.PolicyData{
+ ID: 1,
+ Name: "Team Policy",
+ Query: "SELECT * FROM team_policy WHERE id = 1",
+ Resolution: ptr.String("Do a team thing"),
+ Description: "This is a team policy",
+ Platform: "linux,windows",
+ },
+ RunScript: &fleet.PolicyScript{
+ ID: 1,
+ },
+ },
+ }, nil
+}
+
+func (MockClient) GetQueries(teamID *uint, name *string) ([]fleet.Query, error) {
+ if teamID == nil {
+ return []fleet.Query{
+ {
+ ID: 1,
+ Name: "Global Query",
+ Query: "SELECT * FROM global_query WHERE id = 1",
+ Description: "This is a global query",
+ Platform: "darwin",
+ Interval: 3600,
+ ObserverCanRun: true,
+ AutomationsEnabled: true,
+ LabelsIncludeAny: []fleet.LabelIdent{{
+ LabelName: "Label A",
+ }, {
+ LabelName: "Label B",
+ }},
+ MinOsqueryVersion: "1.2.3",
+ Logging: "stdout",
+ },
+ }, nil
+ }
+ return []fleet.Query{
+ {
+ ID: 1,
+ Name: "Team Query",
+ Query: "SELECT * FROM team_query WHERE id = 1",
+ Description: "This is a team query",
+ Platform: "linux,windows",
+ Interval: 1800,
+ ObserverCanRun: false,
+ AutomationsEnabled: true,
+ MinOsqueryVersion: "4.5.6",
+ Logging: "stderr",
+ },
+ }, nil
+}
+
+//nolint:gocritic // ignore captLocal
+func (MockClient) GetSoftwareTitleByID(ID uint, teamID *uint) (*fleet.SoftwareTitle, error) {
+ switch ID {
+ case 1:
+ if *teamID != 1 {
+ return nil, errors.New("team ID mismatch")
+ }
+ return &fleet.SoftwareTitle{
+ ID: 1,
+ SoftwarePackage: &fleet.SoftwareInstaller{
+ LabelsIncludeAny: []fleet.SoftwareScopeLabel{{
+ LabelName: "Label A",
+ }, {
+ LabelName: "Label B",
+ }},
+ PreInstallQuery: "SELECT * FROM pre_install_query",
+ InstallScript: "foo",
+ PostInstallScript: "bar",
+ UninstallScript: "baz",
+ SelfService: true,
+ Platform: "darwin",
+ },
+ }, nil
+ case 2:
+ if *teamID != 1 {
+ return nil, errors.New("team ID mismatch")
+ }
+ return &fleet.SoftwareTitle{
+ ID: 2,
+ AppStoreApp: &fleet.VPPAppStoreApp{
+ LabelsExcludeAny: []fleet.SoftwareScopeLabel{{
+ LabelName: "Label C",
+ }, {
+ LabelName: "Label D",
+ }},
+ },
+ }, nil
+ default:
+ return nil, errors.New("software title not found")
+ }
+}
+
+func (MockClient) GetLabels() ([]*fleet.LabelSpec, error) {
+ return []*fleet.LabelSpec{{
+ Name: "Label A",
+ Platform: "linux,macos",
+ Description: "Label A description",
+ LabelMembershipType: fleet.LabelMembershipTypeDynamic,
+ Query: "SELECT * FROM osquery_info",
+ }, {
+ Name: "Label B",
+ Description: "Label B description",
+ LabelMembershipType: fleet.LabelMembershipTypeManual,
+ Hosts: []string{"host1", "host2"},
+ }}, nil
+}
+
+func (MockClient) Me() (*fleet.User, error) {
+ return &fleet.User{
+ ID: 1,
+ Name: "Test User",
+ Email: "test@example.com",
+ GlobalRole: ptr.String("admin"),
+ }, nil
+}
+
+func compareDirs(t *testing.T, sourceDir, targetDir string) {
+ err := filepath.WalkDir(sourceDir, func(srcPath string, d os.DirEntry, walkErr error) error {
+ if d.IsDir() {
+ return nil
+ }
+
+ relPath, err := filepath.Rel(sourceDir, srcPath)
+ require.NoError(t, err, "Error getting relative path: %v", err)
+
+ tgtPath := filepath.Join(targetDir, relPath)
+
+ targetInfo, err := os.Stat(tgtPath)
+ require.NoError(t, err, "Error getting target file info: %v", err)
+ require.False(t, targetInfo.IsDir(), "Expected file but found directory: %s", tgtPath)
+
+ srcData, err := os.ReadFile(srcPath)
+ require.NoError(t, err, "Error reading source file: %v", err)
+
+ tgtData, err := os.ReadFile(tgtPath)
+ require.NoError(t, err, "Error reading target file: %v", err)
+
+ require.Equal(t, string(srcData), string(tgtData), "File contents do not match for %s", relPath)
+
+ return nil
+ })
+ if err != nil {
+ t.Fatalf("Error walking source directory: %v", err)
+ }
+}
+
+func TestGenerateGitops(t *testing.T) {
+ fleetClient := &MockClient{}
+ action := createGenerateGitopsAction(fleetClient)
+ buf := new(bytes.Buffer)
+ tempDir := os.TempDir() + "/" + uuid.New().String()
+ flagSet := flag.NewFlagSet("test", flag.ContinueOnError)
+ flagSet.String("dir", tempDir, "")
+
+ cliContext := cli.NewContext(&cli.App{
+ Name: "test",
+ Usage: "test",
+ Writer: buf,
+ ErrWriter: buf,
+ }, flagSet, nil)
+ err := action(cliContext)
+ require.NoError(t, err, buf.String())
+
+ compareDirs(t, "./testdata/generateGitops/test_dir_premium", tempDir)
+
+ t.Cleanup(func() {
+ if err := os.RemoveAll(tempDir); err != nil {
+ t.Fatalf("failed to remove temp dir: %v", err)
+ }
+ })
+}
+
+func TestGenerateGitopsFree(t *testing.T) {
+ fleetClient := &MockClient{}
+ fleetClient.IsFree = true
+ action := createGenerateGitopsAction(fleetClient)
+ buf := new(bytes.Buffer)
+ tempDir := os.TempDir() + "/" + uuid.New().String()
+ flagSet := flag.NewFlagSet("test", flag.ContinueOnError)
+ flagSet.String("dir", tempDir, "")
+
+ cliContext := cli.NewContext(&cli.App{
+ Name: "test",
+ Usage: "test",
+ Writer: buf,
+ ErrWriter: buf,
+ }, flagSet, nil)
+ err := action(cliContext)
+ require.NoError(t, err, buf.String())
+
+ compareDirs(t, "./testdata/generateGitops/test_dir_free", tempDir)
+
+ t.Cleanup(func() {
+ if err := os.RemoveAll(tempDir); err != nil {
+ t.Fatalf("failed to remove temp dir: %v", err)
+ }
+ })
+}
+
+func TestGenerateOrgSettings(t *testing.T) {
+ // Get the test app config.
+ fleetClient := &MockClient{}
+ appConfig, err := fleetClient.GetAppConfig()
+ require.NoError(t, err)
+
+ // Create the command.
+ cmd := &GenerateGitopsCommand{
+ Client: fleetClient,
+ CLI: cli.NewContext(&cli.App{}, nil, nil),
+ Messages: Messages{},
+ FilesToWrite: make(map[string]interface{}),
+ AppConfig: appConfig,
+ }
+
+ // Generate the org settings.
+ // Note that nested keys here may be strings,
+ // so we'll JSON marshal and unmarshal to a map for comparison.
+ orgSettingsRaw, err := cmd.generateOrgSettings()
+ require.NoError(t, err)
+ require.NotNil(t, orgSettingsRaw)
+ var orgSettings map[string]interface{}
+ b, err := yaml.Marshal(orgSettingsRaw)
+ require.NoError(t, err)
+ fmt.Println("Org settings raw:\n", string(b)) // Debugging line
+ err = yaml.Unmarshal(b, &orgSettings)
+ require.NoError(t, err)
+
+ // Get the expected org settings YAML.
+ b, err = os.ReadFile("./testdata/generateGitops/expectedOrgSettings.yaml")
+ require.NoError(t, err)
+ var expectedAppConfig map[string]interface{}
+ err = yaml.Unmarshal(b, &expectedAppConfig)
+ require.NoError(t, err)
+
+ // Compare.
+ require.Equal(t, expectedAppConfig, orgSettings)
+}
+
+func TestGenerateOrgSettingsInsecure(t *testing.T) {
+ // Get the test app config.
+ fleetClient := &MockClient{}
+ appConfig, err := fleetClient.GetAppConfig()
+ require.NoError(t, err)
+
+ flagSet := flag.NewFlagSet("test", flag.ContinueOnError)
+ flagSet.Bool("insecure", true, "Output sensitive information in plaintext.")
+ // Create the command.
+ cmd := &GenerateGitopsCommand{
+ Client: fleetClient,
+ CLI: cli.NewContext(&cli.App{}, flagSet, nil),
+ Messages: Messages{},
+ FilesToWrite: make(map[string]interface{}),
+ AppConfig: appConfig,
+ }
+
+ // Generate the org settings.
+ // Note that nested keys here may be strings,
+ // so we'll JSON marshal and unmarshal to a map for comparison.
+ orgSettingsRaw, err := cmd.generateOrgSettings()
+ require.NoError(t, err)
+ require.NotNil(t, orgSettingsRaw)
+ var orgSettings map[string]interface{}
+ b, err := yaml.Marshal(orgSettingsRaw)
+ require.NoError(t, err)
+ fmt.Println("Org settings raw:\n", string(b)) // Debugging line
+ err = yaml.Unmarshal(b, &orgSettings)
+ require.NoError(t, err)
+
+ // Get the expected org settings YAML.
+ b, err = os.ReadFile("./testdata/generateGitops/expectedOrgSettings-insecure.yaml")
+ require.NoError(t, err)
+ var expectedAppConfig map[string]interface{}
+ err = yaml.Unmarshal(b, &expectedAppConfig)
+ require.NoError(t, err)
+
+ // Compare.
+ require.Equal(t, expectedAppConfig, orgSettings)
+}
+
+func TestGenerateTeamSettings(t *testing.T) {
+ // Get the test team.
+ fleetClient := &MockClient{}
+ team, err := fleetClient.GetTeam(1)
+ require.NoError(t, err)
+
+ // Create the command.
+ cmd := &GenerateGitopsCommand{
+ Client: fleetClient,
+ CLI: cli.NewContext(&cli.App{}, nil, nil),
+ Messages: Messages{},
+ FilesToWrite: make(map[string]interface{}),
+ AppConfig: nil,
+ }
+
+ // Generate the org settings.
+ // Note that nested keys here may be strings,
+ // so we'll JSON marshal and unmarshal to a map for comparison.
+ TeamSettingsRaw, err := cmd.generateTeamSettings("team.yml", team)
+ require.NoError(t, err)
+ require.NotNil(t, TeamSettingsRaw)
+ var TeamSettings map[string]interface{}
+ b, err := yaml.Marshal(TeamSettingsRaw)
+ require.NoError(t, err)
+ fmt.Println("Team settings raw:\n", string(b)) // Debugging line
+ err = yaml.Unmarshal(b, &TeamSettings)
+ require.NoError(t, err)
+
+ // Get the expected org settings YAML.
+ b, err = os.ReadFile("./testdata/generateGitops/expectedTeamSettings.yaml")
+ require.NoError(t, err)
+ var expectedAppConfig map[string]interface{}
+ err = yaml.Unmarshal(b, &expectedAppConfig)
+ require.NoError(t, err)
+
+ // Compare.
+ require.Equal(t, expectedAppConfig, TeamSettings)
+}
+
+func TestGenerateTeamSettingsInsecure(t *testing.T) {
+ // Get the test team.
+ fleetClient := &MockClient{}
+ team, err := fleetClient.GetTeam(1)
+ require.NoError(t, err)
+
+ flagSet := flag.NewFlagSet("test", flag.ContinueOnError)
+ flagSet.Bool("insecure", true, "Output sensitive information in plaintext.")
+ // Create the command.
+ cmd := &GenerateGitopsCommand{
+ Client: fleetClient,
+ CLI: cli.NewContext(&cli.App{}, flagSet, nil),
+ Messages: Messages{},
+ FilesToWrite: make(map[string]interface{}),
+ AppConfig: nil,
+ }
+
+ // Generate the org settings.
+ // Note that nested keys here may be strings,
+ // so we'll JSON marshal and unmarshal to a map for comparison.
+ TeamSettingsRaw, err := cmd.generateTeamSettings("team.yml", team)
+ require.NoError(t, err)
+ require.NotNil(t, TeamSettingsRaw)
+ var TeamSettings map[string]interface{}
+ b, err := yaml.Marshal(TeamSettingsRaw)
+ require.NoError(t, err)
+ fmt.Println("Team settings raw:\n", string(b)) // Debugging line
+ err = yaml.Unmarshal(b, &TeamSettings)
+ require.NoError(t, err)
+
+ // Get the expected org settings YAML.
+ b, err = os.ReadFile("./testdata/generateGitops/expectedTeamSettings-insecure.yaml")
+ require.NoError(t, err)
+ var expectedAppConfig map[string]interface{}
+ err = yaml.Unmarshal(b, &expectedAppConfig)
+ require.NoError(t, err)
+
+ // Compare.
+ require.Equal(t, expectedAppConfig, TeamSettings)
+}
+
+func TestGenerateControls(t *testing.T) {
+ // Get the test app config.
+ fleetClient := &MockClient{}
+ appConfig, err := fleetClient.GetAppConfig()
+ require.NoError(t, err)
+
+ // Create the command.
+ cmd := &GenerateGitopsCommand{
+ Client: fleetClient,
+ CLI: cli.NewContext(cli.NewApp(), nil, nil),
+ Messages: Messages{},
+ FilesToWrite: make(map[string]interface{}),
+ AppConfig: appConfig,
+ ScriptList: make(map[uint]string),
+ }
+
+ // Generate global controls.
+ // Note that nested keys here may be strings,
+ // so we'll JSON marshal and unmarshal to a map for comparison.
+ mdmConfig := fleet.TeamMDM{
+ EnableDiskEncryption: appConfig.MDM.EnableDiskEncryption.Value,
+ MacOSUpdates: appConfig.MDM.MacOSUpdates,
+ IOSUpdates: appConfig.MDM.IOSUpdates,
+ IPadOSUpdates: appConfig.MDM.IPadOSUpdates,
+ WindowsUpdates: appConfig.MDM.WindowsUpdates,
+ MacOSSetup: appConfig.MDM.MacOSSetup,
+ }
+ controlsRaw, err := cmd.generateControls(nil, "", &mdmConfig)
+ require.NoError(t, err)
+ require.NotNil(t, controlsRaw)
+ var controls map[string]interface{}
+ b, err := yaml.Marshal(controlsRaw)
+ require.NoError(t, err)
+ fmt.Println("Controls raw:\n", string(b)) // Debugging line
+ err = yaml.Unmarshal(b, &controls)
+ require.NoError(t, err)
+
+ // Get the expected org settings YAML.
+ b, err = os.ReadFile("./testdata/generateGitops/expectedGlobalControls.yaml")
+ require.NoError(t, err)
+ var expectedControls map[string]interface{}
+ err = yaml.Unmarshal(b, &expectedControls)
+ require.NoError(t, err)
+
+ // Compare.
+ require.Equal(t, expectedControls, controls)
+
+ // Generate controls for a team.
+ // Note that nested keys here may be strings,
+ // so we'll JSON marshal and unmarshal to a map for comparison.
+ controlsRaw, err = cmd.generateControls(ptr.Uint(1), "some_team", nil)
+ require.NoError(t, err)
+ require.NotNil(t, controlsRaw)
+ b, err = yaml.Marshal(controlsRaw)
+ require.NoError(t, err)
+ fmt.Println("Controls raw:\n", string(b)) // Debugging line
+ err = yaml.Unmarshal(b, &controls)
+ require.NoError(t, err)
+
+ // Get the expected org settings YAML.
+ b, err = os.ReadFile("./testdata/generateGitops/expectedTeamControls.yaml")
+ require.NoError(t, err)
+ err = yaml.Unmarshal(b, &expectedControls)
+ require.NoError(t, err)
+
+ if fileContents, ok := cmd.FilesToWrite["lib/profiles/global-macos-mobileconfig-profile.mobileconfig"]; ok {
+ require.Equal(t, "global macos mobileconfig profile", fileContents)
+ } else {
+ t.Fatalf("Expected file not found")
+ }
+
+ if fileContents, ok := cmd.FilesToWrite["lib/profiles/global-macos-json-profile.json"]; ok {
+ require.Equal(t, `{"profile": "global macos json profile"}`, fileContents)
+ } else {
+ t.Fatalf("Expected file not found")
+ }
+
+ if fileContents, ok := cmd.FilesToWrite["lib/profiles/global-windows-profile.xml"]; ok {
+ require.Equal(t, "global windows profile", fileContents)
+ } else {
+ t.Fatalf("Expected file not found")
+ }
+
+ if fileContents, ok := cmd.FilesToWrite["lib/some_team/profiles/team-macos-mobileconfig-profile.mobileconfig"]; ok {
+ require.Equal(t, "test mobileconfig profile", fileContents)
+ } else {
+ t.Fatalf("Expected file not found")
+ }
+
+ if fileContents, ok := cmd.FilesToWrite["lib/some_team/scripts/Script B.ps1"]; ok {
+ require.Equal(t, "pop goes the weasel!", fileContents)
+ } else {
+ t.Fatalf("Expected file not found")
+ }
+
+ // Compare.
+ require.Equal(t, expectedControls, controls)
+}
+
+func TestGenerateSoftware(t *testing.T) {
+ // Get the test app config.
+ fleetClient := &MockClient{}
+ appConfig, err := fleetClient.GetAppConfig()
+ require.NoError(t, err)
+
+ // Create the command.
+ cmd := &GenerateGitopsCommand{
+ Client: fleetClient,
+ CLI: cli.NewContext(cli.NewApp(), nil, nil),
+ Messages: Messages{},
+ FilesToWrite: make(map[string]interface{}),
+ AppConfig: appConfig,
+ SoftwareList: make(map[uint]Software),
+ }
+
+ softwareRaw, err := cmd.generateSoftware("team.yml", 1, "some-team")
+ require.NoError(t, err)
+ require.NotNil(t, softwareRaw)
+ var software map[string]interface{}
+ b, err := yaml.Marshal(softwareRaw)
+ require.NoError(t, err)
+ fmt.Println("software raw:\n", string(b)) // Debugging line
+ err = yaml.Unmarshal(b, &software)
+ require.NoError(t, err)
+
+ // Get the expected org settings YAML.
+ b, err = os.ReadFile("./testdata/generateGitops/expectedTeamSoftware.yaml")
+ require.NoError(t, err)
+ var expectedSoftware map[string]interface{}
+ err = yaml.Unmarshal(b, &expectedSoftware)
+ require.NoError(t, err)
+
+ // Compare.
+ require.Equal(t, expectedSoftware, software)
+
+ if fileContents, ok := cmd.FilesToWrite["lib/some-team/scripts/my-software-package-darwin-install"]; ok {
+ require.Equal(t, "foo", fileContents)
+ } else {
+ t.Fatalf("Expected file not found")
+ }
+
+ if fileContents, ok := cmd.FilesToWrite["lib/some-team/scripts/my-software-package-darwin-postinstall"]; ok {
+ require.Equal(t, "bar", fileContents)
+ } else {
+ t.Fatalf("Expected file not found")
+ }
+
+ if fileContents, ok := cmd.FilesToWrite["lib/some-team/scripts/my-software-package-darwin-uninstall"]; ok {
+ require.Equal(t, "baz", fileContents)
+ } else {
+ t.Fatalf("Expected file not found")
+ }
+
+ if fileContents, ok := cmd.FilesToWrite["lib/some-team/queries/my-software-package-darwin-preinstallquery.yml"]; ok {
+ require.Equal(t, []map[string]interface{}{{
+ "query": "SELECT * FROM pre_install_query",
+ }}, fileContents)
+ } else {
+ t.Fatalf("Expected file not found")
+ }
+}
+
+func TestGeneratePolicies(t *testing.T) {
+ // Get the test app config.
+ fleetClient := &MockClient{}
+ appConfig, err := fleetClient.GetAppConfig()
+ require.NoError(t, err)
+
+ // Create the command.
+ cmd := &GenerateGitopsCommand{
+ Client: fleetClient,
+ CLI: cli.NewContext(cli.NewApp(), nil, nil),
+ Messages: Messages{},
+ FilesToWrite: make(map[string]interface{}),
+ AppConfig: appConfig,
+ SoftwareList: map[uint]Software{
+ 1: {
+ Hash: "team-software-hash",
+ Comment: "__TEAM_SOFTWARE_COMMENT_TOKEN__",
+ },
+ },
+ ScriptList: map[uint]string{
+ 1: "/path/to/script1.sh",
+ },
+ }
+
+ policiesRaw, err := cmd.generatePolicies(nil, "default.yml")
+ require.NoError(t, err)
+ require.NotNil(t, policiesRaw)
+ var policies []map[string]interface{}
+ b, err := yaml.Marshal(policiesRaw)
+ require.NoError(t, err)
+ fmt.Println("policies raw:\n", string(b)) // Debugging line
+ err = yaml.Unmarshal(b, &policies)
+ require.NoError(t, err)
+
+ // Get the expected org settings YAML.
+ b, err = os.ReadFile("./testdata/generateGitops/expectedGlobalPolicies.yaml")
+ require.NoError(t, err)
+ var expectedPolicies []map[string]interface{}
+ err = yaml.Unmarshal(b, &expectedPolicies)
+ require.NoError(t, err)
+
+ // Compare.
+ require.Equal(t, expectedPolicies, policies)
+
+ // Generate policies for a team.
+ // Note that nested keys here may be strings,
+ // so we'll JSON marshal and unmarshal to a map for comparison.
+ policiesRaw, err = cmd.generatePolicies(ptr.Uint(1), "some_team")
+ require.NoError(t, err)
+ require.NotNil(t, policiesRaw)
+ b, err = yaml.Marshal(policiesRaw)
+ require.NoError(t, err)
+ fmt.Println("policies raw:\n", string(b)) // Debugging line
+ err = yaml.Unmarshal(b, &policies)
+ require.NoError(t, err)
+
+ // Get the expected org settings YAML.
+ b, err = os.ReadFile("./testdata/generateGitops/expectedTeamPolicies.yaml")
+ require.NoError(t, err)
+ err = yaml.Unmarshal(b, &expectedPolicies)
+ require.NoError(t, err)
+
+ // Compare.
+ require.Equal(t, expectedPolicies, policies)
+}
+
+func TestGenerateQueries(t *testing.T) {
+ // Get the test app config.
+ fleetClient := &MockClient{}
+ appConfig, err := fleetClient.GetAppConfig()
+ require.NoError(t, err)
+
+ // Create the command.
+ cmd := &GenerateGitopsCommand{
+ Client: fleetClient,
+ CLI: cli.NewContext(cli.NewApp(), nil, nil),
+ Messages: Messages{},
+ FilesToWrite: make(map[string]interface{}),
+ AppConfig: appConfig,
+ }
+
+ queriesRaw, err := cmd.generateQueries(nil)
+ require.NoError(t, err)
+ require.NotNil(t, queriesRaw)
+ var queries []map[string]interface{}
+ b, err := yaml.Marshal(queriesRaw)
+ require.NoError(t, err)
+ fmt.Println("queries raw:\n", string(b)) // Debugging line
+ err = yaml.Unmarshal(b, &queries)
+ require.NoError(t, err)
+
+ // Get the expected org settings YAML.
+ b, err = os.ReadFile("./testdata/generateGitops/expectedGlobalQueries.yaml")
+ require.NoError(t, err)
+ var expectedQueries []map[string]interface{}
+ err = yaml.Unmarshal(b, &expectedQueries)
+ require.NoError(t, err)
+
+ // Compare.
+ require.Equal(t, expectedQueries, queries)
+
+ // Generate queries for a team.
+ // Note that nested keys here may be strings,
+ // so we'll JSON marshal and unmarshal to a map for comparison.
+ queriesRaw, err = cmd.generateQueries(ptr.Uint(1))
+ require.NoError(t, err)
+ require.NotNil(t, queriesRaw)
+ b, err = yaml.Marshal(queriesRaw)
+ require.NoError(t, err)
+ fmt.Println("queries raw:\n", string(b)) // Debugging line
+ err = yaml.Unmarshal(b, &queries)
+ require.NoError(t, err)
+
+ // Get the expected org settings YAML.
+ b, err = os.ReadFile("./testdata/generateGitops/expectedTeamQueries.yaml")
+ require.NoError(t, err)
+ err = yaml.Unmarshal(b, &expectedQueries)
+ require.NoError(t, err)
+
+ // Compare.
+ require.Equal(t, expectedQueries, queries)
+}
+
+func TestGenerateLabels(t *testing.T) {
+ // Get the test app config.
+ fleetClient := &MockClient{}
+ appConfig, err := fleetClient.GetAppConfig()
+ require.NoError(t, err)
+
+ // Create the command.
+ cmd := &GenerateGitopsCommand{
+ Client: fleetClient,
+ CLI: cli.NewContext(cli.NewApp(), nil, nil),
+ Messages: Messages{},
+ FilesToWrite: make(map[string]interface{}),
+ AppConfig: appConfig,
+ }
+
+ labelsRaw, err := cmd.generateLabels()
+ require.NoError(t, err)
+ require.NotNil(t, labelsRaw)
+ var labels []map[string]interface{}
+ b, err := yaml.Marshal(labelsRaw)
+ require.NoError(t, err)
+ fmt.Println("labels raw:\n", string(b)) // Debugging line
+ err = yaml.Unmarshal(b, &labels)
+ require.NoError(t, err)
+
+ // Get the expected org settings YAML.
+ b, err = os.ReadFile("./testdata/generateGitops/expectedLabels.yaml")
+ require.NoError(t, err)
+ var expectedlabels []map[string]interface{}
+ err = yaml.Unmarshal(b, &expectedlabels)
+ require.NoError(t, err)
+
+ // Compare.
+ require.Equal(t, expectedlabels, labels)
+}
diff --git a/cmd/fleetctl/testdata/generateGitops/appConfig.json b/cmd/fleetctl/testdata/generateGitops/appConfig.json
new file mode 100644
index 0000000000..5974075695
--- /dev/null
+++ b/cmd/fleetctl/testdata/generateGitops/appConfig.json
@@ -0,0 +1,308 @@
+{
+ "update_interval": {
+ "osquery_detail": 3600000000000,
+ "osquery_policy": 3600000000000
+ },
+ "vulnerabilities": {
+ "databases_path": "/home/fleet",
+ "periodicity": 3600000000000,
+ "cpe_database_url": "",
+ "cpe_translations_url": "",
+ "cve_feed_prefix_url": "",
+ "current_instance_checks": "auto",
+ "disable_data_sync": false,
+ "recent_vulnerability_max_age": 2592000000000000,
+ "disable_win_os_vulnerabilities": false
+ },
+ "license": {
+ "tier": "premium",
+ "organization": "fleet",
+ "device_count": 1000000,
+ "expiration": "2031-10-16T00:00:00Z",
+ "note": "dogfood env license"
+ },
+ "logging": {
+ "debug": true,
+ "json": true,
+ "result": {
+ "plugin": "firehose",
+ "config": {
+ "region": "us-east-2",
+ "status_stream": "osquery_status",
+ "result_stream": "osquery_results",
+ "audit_stream": "fleet_audit"
+ }
+ },
+ "status": {
+ "plugin": "firehose",
+ "config": {
+ "region": "us-east-2",
+ "status_stream": "osquery_status",
+ "result_stream": "osquery_results",
+ "audit_stream": "fleet_audit"
+ }
+ },
+ "audit": {
+ "plugin": "firehose",
+ "config": {
+ "region": "us-east-2",
+ "status_stream": "osquery_status",
+ "result_stream": "osquery_results",
+ "audit_stream": "fleet_audit"
+ }
+ }
+ },
+ "email": {
+ "backend": "ses",
+ "config": {
+ "region": "",
+ "source_arn": "some-ses-arn"
+ }
+ },
+ "android_enabled": true,
+ "org_info": {
+ "org_name": "Fleet",
+ "org_logo_url": "http://some-org-logo-url.com",
+ "org_logo_url_light_background": "http://some-org-logo-url-light-background.com",
+ "contact_url": "https://fleetdm.com/company/contact"
+ },
+ "server_settings": {
+ "server_url": "https://dogfood.fleetdm.com",
+ "live_query_disabled": false,
+ "enable_analytics": true,
+ "debug_host_ids": [
+ 1,
+ 3
+ ],
+ "deferred_save_host": false,
+ "query_reports_disabled": false,
+ "scripts_disabled": false,
+ "ai_features_disabled": false,
+ "query_report_cap": 1
+ },
+ "smtp_settings": {
+ "enable_smtp": false,
+ "configured": false,
+ "sender_address": "",
+ "server": "localhost",
+ "port": 587,
+ "authentication_type": "authtype_username_password",
+ "user_name": "",
+ "password": "",
+ "enable_ssl_tls": false,
+ "authentication_method": "authmethod_plain",
+ "domain": "",
+ "verify_ssl_certs": false,
+ "enable_start_tls": false
+ },
+ "host_expiry_settings": {
+ "host_expiry_enabled": false,
+ "host_expiry_window": 59995
+ },
+ "activity_expiry_settings": {
+ "activity_expiry_enabled": false,
+ "activity_expiry_window": 30
+ },
+ "features": {
+ "enable_host_users": true,
+ "enable_software_inventory": true,
+ "additional_queries": {
+ "time": "SELECT * FROM time",
+ "macs": "SELECT mac FROM interface_details"
+ },
+ "detail_query_overrides": {
+ "users": null,
+ "mdm": "SELECT enrolled, server_url, installed_from_dep, payload_identifier FROM mdm;"
+ }
+ },
+ "agent_options": {
+ "config": {
+ "options": {
+ "pack_delimiter": "/",
+ "logger_tls_period": 10,
+ "distributed_plugin": "tls",
+ "disable_distributed": false,
+ "logger_tls_endpoint": "/api/osquery/log",
+ "distributed_interval": 10,
+ "distributed_tls_max_attempts": 3
+ },
+ "decorators": {
+ "load": [
+ "SELECT uuid AS host_uuid FROM system_info;",
+ "SELECT hostname AS hostname FROM system_info;"
+ ]
+ }
+ }
+ },
+ "sso_settings": {
+ "entity_id": "dogfood.fleetdm.com",
+ "issuer_uri": "https://some-sso-issuer-uri.com",
+ "metadata": "some-sso-metadata",
+ "metadata_url": "http://some-sso-metadata-url.com",
+ "idp_name": "some-idp-name",
+ "idp_image_url": "http://some-sso-idp-image-url.com",
+ "enable_sso": true,
+ "enable_sso_idp_login": false,
+ "enable_jit_provisioning": true,
+ "enable_jit_role_sync": false
+ },
+ "fleet_desktop": {
+ "transparency_url": "https://fleetdm.com/transparency"
+ },
+ "vulnerability_settings": {
+ "databases_path": ""
+ },
+ "webhook_settings": {
+ "activities_webhook": {
+ "enable_activities_webhook": true,
+ "destination_url": "https://some-activities-webhook-url.com"
+ },
+ "host_status_webhook": {
+ "enable_host_status_webhook": true,
+ "destination_url": "https://some-host-status-webhook-url.com",
+ "host_percentage": 20,
+ "days_count": 5
+ },
+ "failing_policies_webhook": {
+ "enable_failing_policies_webhook": true,
+ "destination_url": "https://some-failing-policies-webhook-url.com",
+ "policy_ids": [],
+ "host_batch_size": 2
+ },
+ "vulnerabilities_webhook": {
+ "enable_vulnerabilities_webhook": true,
+ "destination_url": "https://some-vulerabilities-webhook-url.com",
+ "host_batch_size": 3
+ },
+ "interval": "6h0m0s"
+ },
+ "integrations": {
+ "jira": [
+ {
+ "url": "https://some-jira-url.com",
+ "username": "some-jira-username",
+ "api_token": "some-jira-api-token",
+ "project_key": "some-jira-project-key"
+ }
+ ],
+ "zendesk": [
+ {
+ "url": "https://some-zendesk-url.com",
+ "email": "some-zendesk-email@example.com",
+ "api_token": "some-zendesk-api-token",
+ "group_id": 123456789
+ }
+ ],
+ "google_calendar": [
+ {
+ "domain": "fleetdm.com",
+ "api_key_json": {
+ "owl": "hoot"
+ }
+ }
+ ],
+ "digicert": [
+ {
+ "name": "some-digicert-name",
+ "url": "https://some-digicert-url.com",
+ "api_token": "some-digicert-api-token",
+ "profile_id": "some-digicert-profile-id",
+ "certificate_common_name": "some-digicert-certificate-common-name",
+ "certificate_user_principal_names": [
+ "some-digicert-certificate-user-principal-name",
+ "some-other-digicert-certificate-user-principal-name"
+ ],
+ "certificate_seat_id": "some-digicert-certificate-seat-id"
+ }
+ ],
+ "ndes_scep_proxy": {
+ "url": "https://some-ndes-scep-proxy-url.com",
+ "admin_url": "https://some-ndes-admin-url.com",
+ "username": "some-ndes-username",
+ "password": "some-ndes-password"
+ },
+ "custom_scep_proxy": [
+ {
+ "name": "some-custom-scep-proxy-name",
+ "url": "https://some-custom-scep-proxy-url.com",
+ "challenge": "some-custom-scep-proxy-challenge"
+ }
+ ]
+ },
+ "mdm": {
+ "apple_server_url": "http://some-apple-server-url.com",
+ "apple_business_manager": [
+ {
+ "organization_name": "Fleet Device Management Inc.",
+ "macos_team": "💻 Workstations",
+ "ios_team": "📱🏢 Company-owned mobile devices",
+ "ipados_team": "📱🏢 Company-owned mobile devices"
+ }
+ ],
+ "apple_bm_enabled_and_configured": true,
+ "apple_bm_terms_expired": false,
+ "enabled_and_configured": true,
+ "macos_updates": {
+ "minimum_version": "15.1",
+ "deadline": "2024-12-31"
+ },
+ "ios_updates": {
+ "minimum_version": "18.1",
+ "deadline": "2025-12-31"
+ },
+ "ipados_updates": {
+ "minimum_version": "18.2",
+ "deadline": "2026-12-31"
+ },
+ "windows_updates": {
+ "deadline_days": 5,
+ "grace_period_days": 2
+ },
+ "macos_settings": {
+ "custom_settings": null
+ },
+ "macos_setup": {
+ "bootstrap_package": "some-bootstrap-package",
+ "enable_end_user_authentication": true,
+ "macos_setup_assistant": "",
+ "enable_release_device_manually": false,
+ "script": "",
+ "software": []
+ },
+ "macos_migration": {
+ "enable": true,
+ "mode": "voluntary",
+ "webhook_url": "https://some-macos-migration-webhook-url.com"
+ },
+ "windows_migration_enabled": true,
+ "end_user_authentication": {
+ "entity_id": "some-mdm-entity-id.com",
+ "issuer_uri": "https://some-mdm-issuer-uri.com",
+ "metadata": "some-mdm-metadata",
+ "metadata_url": "http://some-mdm-metadata-url.com",
+ "idp_name": "some-other-idp-name"
+ },
+ "windows_enabled_and_configured": true,
+ "enable_disk_encryption": true,
+ "windows_settings": {
+ "custom_settings": []
+ },
+ "volume_purchasing_program": [
+ {
+ "location": "Fleet Device Management Inc.",
+ "teams": [
+ "💻 Workstations",
+ "💻🐣 Workstations (canary)",
+ "📱🏢 Company-owned mobile devices",
+ "📱🔐 Personal mobile devices"
+ ]
+ }
+ ],
+ "android_enabled_and_configured": true
+ },
+ "gitops": {
+ "gitops_mode_enabled": false,
+ "repository_url": "https://github.com/fleetdm/fleet/tree/main/it-and-security"
+ },
+ "scripts": []
+}
\ No newline at end of file
diff --git a/cmd/fleetctl/testdata/generateGitops/expectedGlobalControls.yaml b/cmd/fleetctl/testdata/generateGitops/expectedGlobalControls.yaml
new file mode 100644
index 0000000000..804eb25832
--- /dev/null
+++ b/cmd/fleetctl/testdata/generateGitops/expectedGlobalControls.yaml
@@ -0,0 +1,34 @@
+macos_settings:
+ custom_settings:
+ - labels_include_all:
+ - Label A
+ - Label B
+ path: ./lib/profiles/global-macos-mobileconfig-profile.mobileconfig
+ - labels_exclude_any:
+ - Label C
+ path: ./lib/profiles/global-macos-json-profile.json
+windows_settings:
+ custom_settings:
+ - labels_include_any:
+ - Label D
+ path: ./lib/profiles/global-windows-profile.xml
+macos_updates:
+ minimum_version: "15.1"
+ deadline: "2024-12-31"
+ios_updates:
+ minimum_version: "18.1"
+ deadline: "2025-12-31"
+ipados_updates:
+ minimum_version: "18.2"
+ deadline: "2026-12-31"
+windows_updates:
+ deadline_days: 5
+ grace_period_days: 2
+windows_enabled_and_configured: true
+windows_migration_enabled: true
+enable_disk_encryption: true
+macos_setup: "TODO: update with your macos_setup configuration"
+macos_migration: # Available in Fleet Premium
+ enable: true
+ mode: voluntary
+ webhook_url: https://some-macos-migration-webhook-url.com
\ No newline at end of file
diff --git a/cmd/fleetctl/testdata/generateGitops/expectedGlobalPolicies.yaml b/cmd/fleetctl/testdata/generateGitops/expectedGlobalPolicies.yaml
new file mode 100644
index 0000000000..72c2a343c7
--- /dev/null
+++ b/cmd/fleetctl/testdata/generateGitops/expectedGlobalPolicies.yaml
@@ -0,0 +1,12 @@
+- calendar_events_enabled: false
+ critical: false
+ description: This is a global policy
+ install_software:
+ hash_sha256: team-software-hash __TEAM_SOFTWARE_COMMENT_TOKEN__
+ labels_include_any:
+ - Label A
+ - Label B
+ name: Global Policy
+ platform: darwin
+ query: SELECT * FROM global_policy WHERE id = 1
+ resolution: Do a global thing
\ No newline at end of file
diff --git a/cmd/fleetctl/testdata/generateGitops/expectedGlobalQueries.yaml b/cmd/fleetctl/testdata/generateGitops/expectedGlobalQueries.yaml
new file mode 100644
index 0000000000..fcfa479144
--- /dev/null
+++ b/cmd/fleetctl/testdata/generateGitops/expectedGlobalQueries.yaml
@@ -0,0 +1,13 @@
+- automations_enabled: true
+ logging: stdout
+ min_osquery_version: 1.2.3
+ description: This is a global query
+ interval: 3600
+ labels_include_any:
+ - Label A
+ - Label B
+ name: Global Query
+ observer_can_run: true
+ platform: darwin
+ query: SELECT * FROM global_query WHERE id = 1
+ discard_data: false
\ No newline at end of file
diff --git a/cmd/fleetctl/testdata/generateGitops/expectedLabels.yaml b/cmd/fleetctl/testdata/generateGitops/expectedLabels.yaml
new file mode 100644
index 0000000000..34a75cc6e1
--- /dev/null
+++ b/cmd/fleetctl/testdata/generateGitops/expectedLabels.yaml
@@ -0,0 +1,11 @@
+- name: Label A
+ description: Label A description
+ label_membership_type: dynamic
+ query: SELECT * FROM osquery_info
+ platform: linux,macos
+- name: Label B
+ description: Label B description
+ label_membership_type: manual
+ hosts:
+ - host1
+ - host2
diff --git a/cmd/fleetctl/testdata/generateGitops/expectedOrgSettings-insecure.yaml b/cmd/fleetctl/testdata/generateGitops/expectedOrgSettings-insecure.yaml
new file mode 100644
index 0000000000..517cc37800
--- /dev/null
+++ b/cmd/fleetctl/testdata/generateGitops/expectedOrgSettings-insecure.yaml
@@ -0,0 +1,121 @@
+features:
+ enable_host_users: true
+ enable_software_inventory: true
+ additional_queries:
+ time: "SELECT * FROM time"
+ macs: "SELECT mac FROM interface_details"
+ detail_query_overrides:
+ users:
+ mdm: "SELECT enrolled, server_url, installed_from_dep, payload_identifier FROM mdm;"
+fleet_desktop:
+ transparency_url: https://fleetdm.com/transparency
+host_expiry_settings:
+ host_expiry_enabled: false
+ host_expiry_window: 59995
+integrations:
+ custom_scep_proxy:
+ - challenge: some-custom-scep-proxy-challenge
+ name: some-custom-scep-proxy-name
+ url: https://some-custom-scep-proxy-url.com
+ digicert:
+ - api_token: some-digicert-api-token
+ certificate_common_name: some-digicert-certificate-common-name
+ certificate_seat_id: some-digicert-certificate-seat-id
+ certificate_user_principal_names:
+ - some-digicert-certificate-user-principal-name
+ - some-other-digicert-certificate-user-principal-name
+ name: some-digicert-name
+ profile_id: some-digicert-profile-id
+ url: https://some-digicert-url.com
+ google_calendar:
+ - api_key_json:
+ owl: hoot
+ domain: fleetdm.com
+ jira:
+ - api_token: some-jira-api-token
+ enable_failing_policies: false
+ enable_software_vulnerabilities: false
+ project_key: some-jira-project-key
+ url: https://some-jira-url.com
+ username: some-jira-username
+ ndes_scep_proxy:
+ admin_url: https://some-ndes-admin-url.com
+ password: some-ndes-password
+ url: https://some-ndes-scep-proxy-url.com
+ username: some-ndes-username
+ zendesk:
+ - api_token: some-zendesk-api-token
+ email: some-zendesk-email@example.com
+ enable_failing_policies: false
+ enable_software_vulnerabilities: false
+ group_id: 123456789
+ url: https://some-zendesk-url.com
+mdm:
+ apple_business_manager:
+ - ios_team: "\U0001F4F1\U0001F3E2 Company-owned mobile devices"
+ ipados_team: "\U0001F4F1\U0001F3E2 Company-owned mobile devices"
+ macos_team: "\U0001F4BB Workstations"
+ organization_name: Fleet Device Management Inc.
+ apple_server_url: http://some-apple-server-url.com
+ end_user_authentication:
+ entity_id: some-mdm-entity-id.com
+ idp_name: some-other-idp-name
+ issuer_uri: https://some-mdm-issuer-uri.com
+ metadata: some-mdm-metadata
+ metadata_url: http://some-mdm-metadata-url.com
+ volume_purchasing_program:
+ - location: Fleet Device Management Inc.
+ teams:
+ - "\U0001F4BB Workstations"
+ - "\U0001F4BB\U0001F423 Workstations (canary)"
+ - "\U0001F4F1\U0001F3E2 Company-owned mobile devices"
+ - "\U0001F4F1\U0001F510 Personal mobile devices"
+org_info:
+ contact_url: https://fleetdm.com/company/contact
+ org_logo_url: http://some-org-logo-url.com
+ org_logo_url_light_background: http://some-org-logo-url-light-background.com
+ org_name: Fleet
+secrets:
+- secret: some-secret-number-one
+- secret: some-secret-number-two
+server_settings:
+ ai_features_disabled: false
+ debug_host_ids:
+ - 1
+ - 3
+ deferred_save_host: false
+ enable_analytics: true
+ live_query_disabled: false
+ query_report_cap: 1
+ query_reports_disabled: false
+ scripts_disabled: false
+ server_url: https://dogfood.fleetdm.com
+sso_settings:
+ enable_jit_provisioning: true
+ enable_sso: true
+ enable_sso_idp_login: false
+ entity_id: dogfood.fleetdm.com
+ idp_image_url: http://some-sso-idp-image-url.com
+ idp_name: some-idp-name
+ metadata: some-sso-metadata
+ metadata_url: http://some-sso-metadata-url.com
+webhook_settings:
+ activities_webhook:
+ destination_url: https://some-activities-webhook-url.com
+ enable_activities_webhook: true
+ failing_policies_webhook:
+ destination_url: https://some-failing-policies-webhook-url.com
+ enable_failing_policies_webhook: true
+ host_batch_size: 2
+ policy_ids: []
+ host_status_webhook:
+ days_count: 5
+ destination_url: https://some-host-status-webhook-url.com
+ enable_host_status_webhook: true
+ host_percentage: 20
+ interval: 6h0m0s
+ vulnerabilities_webhook:
+ destination_url: https://some-vulerabilities-webhook-url.com
+ enable_vulnerabilities_webhook: true
+ host_batch_size: 3
+yara_rules: {}
diff --git a/cmd/fleetctl/testdata/generateGitops/expectedOrgSettings.yaml b/cmd/fleetctl/testdata/generateGitops/expectedOrgSettings.yaml
new file mode 100644
index 0000000000..1c7dcf9042
--- /dev/null
+++ b/cmd/fleetctl/testdata/generateGitops/expectedOrgSettings.yaml
@@ -0,0 +1,121 @@
+features:
+ enable_host_users: true
+ enable_software_inventory: true
+ additional_queries:
+ time: "SELECT * FROM time"
+ macs: "SELECT mac FROM interface_details"
+ detail_query_overrides:
+ users:
+ mdm: "SELECT enrolled, server_url, installed_from_dep, payload_identifier FROM mdm;"
+fleet_desktop:
+ transparency_url: https://fleetdm.com/transparency
+host_expiry_settings:
+ host_expiry_enabled: false
+ host_expiry_window: 59995
+integrations:
+ custom_scep_proxy:
+ - challenge: ___GITOPS_COMMENT_5___
+ name: some-custom-scep-proxy-name
+ url: https://some-custom-scep-proxy-url.com
+ digicert:
+ - api_token: ___GITOPS_COMMENT_3___
+ certificate_common_name: some-digicert-certificate-common-name
+ certificate_seat_id: some-digicert-certificate-seat-id
+ certificate_user_principal_names:
+ - some-digicert-certificate-user-principal-name
+ - some-other-digicert-certificate-user-principal-name
+ name: some-digicert-name
+ profile_id: some-digicert-profile-id
+ url: https://some-digicert-url.com
+ google_calendar:
+ - api_key_json:
+ owl: hoot
+ private_key: ___GITOPS_COMMENT_0___
+ domain: fleetdm.com
+ jira:
+ - api_token: ___GITOPS_COMMENT_1___
+ enable_failing_policies: false
+ enable_software_vulnerabilities: false
+ project_key: some-jira-project-key
+ url: https://some-jira-url.com
+ username: some-jira-username
+ ndes_scep_proxy:
+ admin_url: https://some-ndes-admin-url.com
+ password: ___GITOPS_COMMENT_4___
+ url: https://some-ndes-scep-proxy-url.com
+ username: some-ndes-username
+ zendesk:
+ - api_token: ___GITOPS_COMMENT_2___
+ email: some-zendesk-email@example.com
+ enable_failing_policies: false
+ enable_software_vulnerabilities: false
+ group_id: 123456789
+ url: https://some-zendesk-url.com
+mdm:
+ apple_business_manager:
+ - ios_team: "\U0001F4F1\U0001F3E2 Company-owned mobile devices"
+ ipados_team: "\U0001F4F1\U0001F3E2 Company-owned mobile devices"
+ macos_team: "\U0001F4BB Workstations"
+ organization_name: Fleet Device Management Inc.
+ apple_server_url: http://some-apple-server-url.com
+ end_user_authentication:
+ entity_id: some-mdm-entity-id.com
+ idp_name: some-other-idp-name
+ issuer_uri: https://some-mdm-issuer-uri.com
+ metadata: ___GITOPS_COMMENT_6___
+ metadata_url: ___GITOPS_COMMENT_7___
+ volume_purchasing_program:
+ - location: Fleet Device Management Inc.
+ teams:
+ - "\U0001F4BB Workstations"
+ - "\U0001F4BB\U0001F423 Workstations (canary)"
+ - "\U0001F4F1\U0001F3E2 Company-owned mobile devices"
+ - "\U0001F4F1\U0001F510 Personal mobile devices"
+org_info:
+ contact_url: https://fleetdm.com/company/contact
+ org_logo_url: http://some-org-logo-url.com
+ org_logo_url_light_background: http://some-org-logo-url-light-background.com
+ org_name: Fleet
+secrets:
+- secret: ___GITOPS_COMMENT_8___
+server_settings:
+ ai_features_disabled: false
+ debug_host_ids:
+ - 1
+ - 3
+ deferred_save_host: false
+ enable_analytics: true
+ live_query_disabled: false
+ query_report_cap: 1
+ query_reports_disabled: false
+ scripts_disabled: false
+ server_url: https://dogfood.fleetdm.com
+sso_settings:
+ enable_jit_provisioning: true
+ enable_sso: true
+ enable_sso_idp_login: false
+ entity_id: dogfood.fleetdm.com
+ idp_image_url: http://some-sso-idp-image-url.com
+ idp_name: some-idp-name
+ metadata: ___GITOPS_COMMENT_9___
+ metadata_url: ___GITOPS_COMMENT_10___
+webhook_settings:
+ activities_webhook:
+ destination_url: https://some-activities-webhook-url.com
+ enable_activities_webhook: true
+ failing_policies_webhook:
+ destination_url: https://some-failing-policies-webhook-url.com
+ enable_failing_policies_webhook: true
+ host_batch_size: 2
+ policy_ids: []
+ host_status_webhook:
+ days_count: 5
+ destination_url: https://some-host-status-webhook-url.com
+ enable_host_status_webhook: true
+ host_percentage: 20
+ interval: 6h0m0s
+ vulnerabilities_webhook:
+ destination_url: https://some-vulerabilities-webhook-url.com
+ enable_vulnerabilities_webhook: true
+ host_batch_size: 3
+yara_rules: {}
\ No newline at end of file
diff --git a/cmd/fleetctl/testdata/generateGitops/expectedTeamControls.yaml b/cmd/fleetctl/testdata/generateGitops/expectedTeamControls.yaml
new file mode 100644
index 0000000000..c6423e555a
--- /dev/null
+++ b/cmd/fleetctl/testdata/generateGitops/expectedTeamControls.yaml
@@ -0,0 +1,5 @@
+macos_settings:
+ custom_settings:
+ - path: ../lib/some_team/profiles/team-macos-mobileconfig-profile.mobileconfig
+scripts:
+- path: ../lib/some_team/scripts/Script B.ps1
\ No newline at end of file
diff --git a/cmd/fleetctl/testdata/generateGitops/expectedTeamPolicies.yaml b/cmd/fleetctl/testdata/generateGitops/expectedTeamPolicies.yaml
new file mode 100644
index 0000000000..7dfcc6879b
--- /dev/null
+++ b/cmd/fleetctl/testdata/generateGitops/expectedTeamPolicies.yaml
@@ -0,0 +1,9 @@
+- calendar_events_enabled: false
+ critical: false
+ description: This is a team policy
+ name: Team Policy
+ platform: linux,windows
+ query: SELECT * FROM team_policy WHERE id = 1
+ resolution: Do a team thing
+ run_script:
+ path: /path/to/script1.sh
\ No newline at end of file
diff --git a/cmd/fleetctl/testdata/generateGitops/expectedTeamQueries.yaml b/cmd/fleetctl/testdata/generateGitops/expectedTeamQueries.yaml
new file mode 100644
index 0000000000..f55970732a
--- /dev/null
+++ b/cmd/fleetctl/testdata/generateGitops/expectedTeamQueries.yaml
@@ -0,0 +1,10 @@
+- automations_enabled: true
+ logging: stderr
+ min_osquery_version: 4.5.6
+ discard_data: false
+ description: This is a team query
+ interval: 1800
+ name: Team Query
+ observer_can_run: false
+ platform: linux,windows
+ query: SELECT * FROM team_query WHERE id = 1
\ No newline at end of file
diff --git a/cmd/fleetctl/testdata/generateGitops/expectedTeamSettings-insecure.yaml b/cmd/fleetctl/testdata/generateGitops/expectedTeamSettings-insecure.yaml
new file mode 100644
index 0000000000..a1616be62b
--- /dev/null
+++ b/cmd/fleetctl/testdata/generateGitops/expectedTeamSettings-insecure.yaml
@@ -0,0 +1,26 @@
+features:
+ enable_host_users: true
+ enable_software_inventory: true
+host_expiry_settings:
+ host_expiry_enabled: false
+ host_expiry_window: 1
+integrations:
+ google_calendar:
+ enable_calendar_events: true
+ webhook_url: https://some-team-google-calendar-webhook.com
+secrets:
+- secret: some-team-secret
+webhook_settings:
+ failing_policies_webhook:
+ destination_url: https://some-team-failing_policies-webhook.com
+ enable_failing_policies_webhook: false
+ host_batch_size: 4
+ policy_ids:
+ - 1
+ - 2
+ - 3
+ host_status_webhook:
+ days_count: 3
+ destination_url: https://some-team-host-status-webhook.com
+ enable_host_status_webhook: false
+ host_percentage: 2
\ No newline at end of file
diff --git a/cmd/fleetctl/testdata/generateGitops/expectedTeamSettings.yaml b/cmd/fleetctl/testdata/generateGitops/expectedTeamSettings.yaml
new file mode 100644
index 0000000000..9410c9cf68
--- /dev/null
+++ b/cmd/fleetctl/testdata/generateGitops/expectedTeamSettings.yaml
@@ -0,0 +1,26 @@
+features:
+ enable_host_users: true
+ enable_software_inventory: true
+host_expiry_settings:
+ host_expiry_enabled: false
+ host_expiry_window: 1
+integrations:
+ google_calendar:
+ enable_calendar_events: true
+ webhook_url: https://some-team-google-calendar-webhook.com
+secrets:
+- secret: ___GITOPS_COMMENT_0___
+webhook_settings:
+ failing_policies_webhook:
+ destination_url: https://some-team-failing_policies-webhook.com
+ enable_failing_policies_webhook: false
+ host_batch_size: 4
+ policy_ids:
+ - 1
+ - 2
+ - 3
+ host_status_webhook:
+ days_count: 3
+ destination_url: https://some-team-host-status-webhook.com
+ enable_host_status_webhook: false
+ host_percentage: 2
\ No newline at end of file
diff --git a/cmd/fleetctl/testdata/generateGitops/expectedTeamSoftware.yaml b/cmd/fleetctl/testdata/generateGitops/expectedTeamSoftware.yaml
new file mode 100644
index 0000000000..2d1e860485
--- /dev/null
+++ b/cmd/fleetctl/testdata/generateGitops/expectedTeamSoftware.yaml
@@ -0,0 +1,19 @@
+packages:
+ - hash_sha256: software-package-hash ___GITOPS_COMMENT_0___
+ install_script:
+ path: ../lib/some-team/scripts/my-software-package-darwin-install
+ labels_include_any:
+ - Label A
+ - Label B
+ post_install_script:
+ path: ../lib/some-team/scripts/my-software-package-darwin-postinstall
+ pre_install_query:
+ path: ../lib/some-team/queries/my-software-package-darwin-preinstallquery.yml
+ self_service: true
+ uninstall_script:
+ path: ../lib/some-team/scripts/my-software-package-darwin-uninstall
+app_store_apps:
+ - app_store_id: com.example.team-software
+ labels_exclude_any:
+ - Label C
+ - Label D
\ No newline at end of file
diff --git a/cmd/fleetctl/testdata/generateGitops/teamConfig.json b/cmd/fleetctl/testdata/generateGitops/teamConfig.json
new file mode 100644
index 0000000000..3028c3c8d1
--- /dev/null
+++ b/cmd/fleetctl/testdata/generateGitops/teamConfig.json
@@ -0,0 +1,242 @@
+{
+ "id": 1,
+ "created_at": "2025-02-05T23:33:39Z",
+ "name": "Test Team",
+ "description": "A test team",
+ "agent_options": {
+ "config": {
+ "options": {
+ "pack_delimiter": "/",
+ "logger_tls_period": 10,
+ "distributed_plugin": "tls",
+ "disable_distributed": false,
+ "logger_tls_endpoint": "/api/osquery/log",
+ "distributed_interval": 10,
+ "distributed_tls_max_attempts": 3
+ },
+ "decorators": {
+ "load": [
+ "SELECT uuid AS host_uuid FROM system_info;",
+ "SELECT hostname AS hostname FROM system_info;"
+ ]
+ }
+ },
+ "update_channels": {
+ "orbit": "edge",
+ "desktop": "edge",
+ "osqueryd": "edge"
+ }
+ },
+ "host_expiry_settings": {
+ "host_expiry_enabled": false,
+ "host_expiry_window": 1
+ },
+ "webhook_settings": {
+ "host_status_webhook": {
+ "enable_host_status_webhook": false,
+ "destination_url": "https://some-team-host-status-webhook.com",
+ "host_percentage": 2,
+ "days_count": 3
+ },
+ "failing_policies_webhook": {
+ "enable_failing_policies_webhook": false,
+ "destination_url": "https://some-team-failing_policies-webhook.com",
+ "policy_ids": [
+ 1,
+ 2,
+ 3
+ ],
+ "host_batch_size": 4
+ }
+ },
+ "integrations": {
+ "jira": [
+ {
+ "url": "https://some-team-jira-url.com",
+ "username": "some-team-jira-username",
+ "api_token": "some-team-jira-api-token",
+ "project_key": "some-team-jira-project-key"
+ }
+ ],
+ "zendesk": [
+ {
+ "url": "https://some-team-zendesk-url.com",
+ "email": "some-team-zendesk-email@example.com",
+ "api_token": "some-team-zendesk-api-token",
+ "group_id": 123456789
+ }
+ ],
+ "google_calendar": {
+ "enable_calendar_events": true,
+ "webhook_url": "https://some-team-google-calendar-webhook.com"
+ },
+ "digicert": [
+ {
+ "name": "some-team-digicert-name",
+ "url": "https://some-team-digicert-url.com",
+ "api_token": "some-team-digicert-api-token",
+ "profile_id": "some-team-digicert-profile-id",
+ "certificate_common_name": "some-team-digicert-certificate-common-name",
+ "certificate_user_principal_names": [
+ "some-team-digicert-certificate-user-principal-name",
+ "some-team-other-digicert-certificate-user-principal-name"
+ ],
+ "certificate_seat_id": "some-team-digicert-certificate-seat-id"
+ }
+ ],
+ "ndes_scep_proxy": {
+ "url": "https://some-team-ndes-scep-proxy-url.com",
+ "admin_url": "https://some-team-ndes-admin-url.com",
+ "username": "some-team-ndes-username",
+ "password": "some-team-ndes-password"
+ },
+ "custom_scep_proxy": [
+ {
+ "name": "some-team-custom-scep-proxy-name",
+ "url": "https://some-team-custom-scep-proxy-url.com",
+ "challenge": "some-team-custom-scep-proxy-challenge"
+ }
+ ]
+ },
+ "features": {
+ "enable_host_users": true,
+ "enable_software_inventory": true
+ },
+ "mdm": {
+ "enable_disk_encryption": true,
+ "macos_updates": {
+ "minimum_version": "95.1",
+ "deadline": "2020-12-31"
+ },
+ "ios_updates": {
+ "minimum_version": "98.1",
+ "deadline": "2021-12-31"
+ },
+ "ipados_updates": {
+ "minimum_version": "98.2",
+ "deadline": "2022-12-31"
+ },
+ "windows_updates": {
+ "deadline_days": 95,
+ "grace_period_days": 92
+ },
+ "macos_settings": {
+ "custom_settings": null
+ },
+ "macos_setup": {
+ "bootstrap_package": "",
+ "enable_end_user_authentication": false,
+ "macos_setup_assistant": "",
+ "enable_release_device_manually": false,
+ "script": "",
+ "software": []
+ },
+ "windows_settings": {
+ "custom_settings": null
+ }
+ },
+ "scripts": [
+ "/home/runner/work/fleet/fleet/it-and-security/lib/macos/scripts/uninstall-fleetd-macos.sh",
+ "/home/runner/work/fleet/fleet/it-and-security/lib/windows/scripts/uninstall-fleetd-windows.ps1",
+ "/home/runner/work/fleet/fleet/it-and-security/lib/linux/scripts/uninstall-fleetd-linux.sh",
+ "/home/runner/work/fleet/fleet/it-and-security/lib/linux/scripts/install-fleet-desktop-required-extension.sh"
+ ],
+ "software": {
+ "packages": [
+ {
+ "url": "https://zoom.us/client/6.3.10.7150/zoom_amd64.deb",
+ "self_service": true,
+ "pre_install_query": {
+ "path": ""
+ },
+ "install_script": {
+ "path": ""
+ },
+ "post_install_script": {
+ "path": ""
+ },
+ "uninstall_script": {
+ "path": ""
+ },
+ "labels_include_any": [
+ "Debian-based Linux hosts"
+ ],
+ "labels_exclude_any": null,
+ "referenced_yaml_path": "/home/runner/work/fleet/fleet/it-and-security/lib/linux/software/zoom-deb.yml"
+ },
+ {
+ "url": "https://zoom.us/client/6.3.10.7150/zoom_x86_64.rpm",
+ "self_service": true,
+ "pre_install_query": {
+ "path": ""
+ },
+ "install_script": {
+ "path": ""
+ },
+ "post_install_script": {
+ "path": ""
+ },
+ "uninstall_script": {
+ "path": ""
+ },
+ "labels_include_any": [
+ "RPM-based Linux hosts"
+ ],
+ "labels_exclude_any": null,
+ "referenced_yaml_path": "/home/runner/work/fleet/fleet/it-and-security/lib/linux/software/zoom-rpm.yml"
+ },
+ {
+ "url": "https://downloads.slack-edge.com/desktop-releases/linux/x64/4.41.105/slack-desktop-4.41.105-amd64.deb",
+ "self_service": true,
+ "pre_install_query": {
+ "path": ""
+ },
+ "install_script": {
+ "path": ""
+ },
+ "post_install_script": {
+ "path": ""
+ },
+ "uninstall_script": {
+ "path": ""
+ },
+ "labels_include_any": [
+ "Debian-based Linux hosts"
+ ],
+ "labels_exclude_any": null,
+ "referenced_yaml_path": "/home/runner/work/fleet/fleet/it-and-security/lib/linux/software/slack-deb.yml"
+ },
+ {
+ "url": "https://downloads.slack-edge.com/desktop-releases/linux/x64/4.41.105/slack-4.41.105-0.1.el8.x86_64.rpm",
+ "self_service": true,
+ "pre_install_query": {
+ "path": ""
+ },
+ "install_script": {
+ "path": ""
+ },
+ "post_install_script": {
+ "path": ""
+ },
+ "uninstall_script": {
+ "path": ""
+ },
+ "labels_include_any": [
+ "RPM-based Linux hosts"
+ ],
+ "labels_exclude_any": null,
+ "referenced_yaml_path": "/home/runner/work/fleet/fleet/it-and-security/lib/linux/software/slack-rpm.yml"
+ }
+ ],
+ "app_store_apps": null
+ },
+ "user_count": 5,
+ "host_count": 24,
+ "secrets": [
+ {
+ "secret": "some-team-secret",
+ "created_at": "2025-02-05T23:33:40Z",
+ "team_id": 270
+ }
+ ]
+}
\ No newline at end of file
diff --git a/cmd/fleetctl/testdata/generateGitops/test_dir_free/default.yml b/cmd/fleetctl/testdata/generateGitops/test_dir_free/default.yml
new file mode 100644
index 0000000000..d6628556bf
--- /dev/null
+++ b/cmd/fleetctl/testdata/generateGitops/test_dir_free/default.yml
@@ -0,0 +1,178 @@
+agent_options:
+ config:
+ decorators:
+ load:
+ - SELECT uuid AS host_uuid FROM system_info;
+ - SELECT hostname AS hostname FROM system_info;
+ options:
+ disable_distributed: false
+ distributed_interval: 10
+ distributed_plugin: tls
+ distributed_tls_max_attempts: 3
+ logger_tls_endpoint: /api/osquery/log
+ logger_tls_period: 10
+ pack_delimiter: /
+controls:
+ macos_settings:
+ custom_settings:
+ - labels_include_all:
+ - Label A
+ - Label B
+ path: ./lib/profiles/global-macos-mobileconfig-profile.mobileconfig
+ - labels_exclude_any:
+ - Label C
+ path: ./lib/profiles/global-macos-json-profile.json
+ windows_settings:
+ custom_settings:
+ - labels_include_any:
+ - Label D
+ path: ./lib/profiles/global-windows-profile.xml
+labels:
+- description: Label A description
+ label_membership_type: dynamic
+ name: Label A
+ platform: linux,macos
+ query: SELECT * FROM osquery_info
+- description: Label B description
+ hosts:
+ - host1
+ - host2
+ label_membership_type: manual
+ name: Label B
+org_settings:
+ features:
+ additional_queries:
+ macs: SELECT mac FROM interface_details
+ time: SELECT * FROM time
+ detail_query_overrides:
+ mdm: SELECT enrolled, server_url, installed_from_dep, payload_identifier FROM
+ mdm;
+ users:
+ enable_host_users: true
+ enable_software_inventory: true
+ fleet_desktop:
+ transparency_url: https://fleetdm.com/transparency
+ host_expiry_settings:
+ host_expiry_enabled: false
+ host_expiry_window: 59995
+ integrations:
+ custom_scep_proxy:
+ - challenge: # TODO: Add your custom SCEP proxy challenge here
+ name: some-custom-scep-proxy-name
+ url: https://some-custom-scep-proxy-url.com
+ digicert:
+ - api_token: # TODO: Add your Digicert API token here
+ certificate_common_name: some-digicert-certificate-common-name
+ certificate_seat_id: some-digicert-certificate-seat-id
+ certificate_user_principal_names:
+ - some-digicert-certificate-user-principal-name
+ - some-other-digicert-certificate-user-principal-name
+ name: some-digicert-name
+ profile_id: some-digicert-profile-id
+ url: https://some-digicert-url.com
+ google_calendar:
+ - api_key_json:
+ owl: hoot
+ private_key: # TODO: Add your Google Calendar API key JSON here
+ domain: fleetdm.com
+ jira:
+ - api_token: # TODO: Add your Jira API token here
+ enable_failing_policies: false
+ enable_software_vulnerabilities: false
+ project_key: some-jira-project-key
+ url: https://some-jira-url.com
+ username: some-jira-username
+ ndes_scep_proxy:
+ admin_url: https://some-ndes-admin-url.com
+ password: # TODO: Add your NDES SCEP proxy password here
+ url: https://some-ndes-scep-proxy-url.com
+ username: some-ndes-username
+ zendesk:
+ - api_token: # TODO: Add your Zendesk API token here
+ email: some-zendesk-email@example.com
+ enable_failing_policies: false
+ enable_software_vulnerabilities: false
+ group_id: 123456789
+ url: https://some-zendesk-url.com
+ mdm:
+ apple_server_url: http://some-apple-server-url.com
+ end_user_authentication:
+ entity_id: some-mdm-entity-id.com
+ idp_name: some-other-idp-name
+ issuer_uri: https://some-mdm-issuer-uri.com
+ metadata: # TODO: Add your MDM end user auth metadata here
+ metadata_url: # TODO: Add your MDM end user auth metadata URL here
+ org_info:
+ contact_url: https://fleetdm.com/company/contact
+ org_logo_url: http://some-org-logo-url.com
+ org_logo_url_light_background: http://some-org-logo-url-light-background.com
+ org_name: Fleet
+ secrets:
+ - secret: # TODO: Add your enroll secrets here
+ server_settings:
+ ai_features_disabled: false
+ debug_host_ids:
+ - 1
+ - 3
+ deferred_save_host: false
+ enable_analytics: true
+ live_query_disabled: false
+ query_report_cap: 1
+ query_reports_disabled: false
+ scripts_disabled: false
+ server_url: https://dogfood.fleetdm.com
+ sso_settings:
+ enable_sso: true
+ enable_sso_idp_login: false
+ entity_id: dogfood.fleetdm.com
+ idp_image_url: http://some-sso-idp-image-url.com
+ idp_name: some-idp-name
+ metadata: # TODO: Add your SSO metadata here
+ metadata_url: # TODO: Add your SSO metadata URL here
+ webhook_settings:
+ activities_webhook:
+ destination_url: https://some-activities-webhook-url.com
+ enable_activities_webhook: true
+ failing_policies_webhook:
+ destination_url: https://some-failing-policies-webhook-url.com
+ enable_failing_policies_webhook: true
+ host_batch_size: 2
+ policy_ids:
+ host_status_webhook:
+ days_count: 5
+ destination_url: https://some-host-status-webhook-url.com
+ enable_host_status_webhook: true
+ host_percentage: 20
+ interval: 6h0m0s
+ vulnerabilities_webhook:
+ destination_url: https://some-vulerabilities-webhook-url.com
+ enable_vulnerabilities_webhook: true
+ host_batch_size: 3
+ yara_rules:
+policies:
+- calendar_events_enabled: false
+ critical: false
+ description: This is a global policy
+ install_software:
+ hash_sha256: ___GITOPS_COMMENT_11___
+ labels_include_any:
+ - Label A
+ - Label B
+ name: Global Policy
+ platform: darwin
+ query: SELECT * FROM global_policy WHERE id = 1
+ resolution: Do a global thing
+queries:
+- automations_enabled: true
+ description: This is a global query
+ discard_data: false
+ interval: 3600
+ labels_include_any:
+ - Label A
+ - Label B
+ logging: stdout
+ min_osquery_version: 1.2.3
+ name: Global Query
+ observer_can_run: true
+ platform: darwin
+ query: SELECT * FROM global_query WHERE id = 1
diff --git a/cmd/fleetctl/testdata/generateGitops/test_dir_free/lib/profiles/global-macos-json-profile.json b/cmd/fleetctl/testdata/generateGitops/test_dir_free/lib/profiles/global-macos-json-profile.json
new file mode 100644
index 0000000000..877de0ffd0
--- /dev/null
+++ b/cmd/fleetctl/testdata/generateGitops/test_dir_free/lib/profiles/global-macos-json-profile.json
@@ -0,0 +1 @@
+{"profile": "global macos json profile"}
\ No newline at end of file
diff --git a/cmd/fleetctl/testdata/generateGitops/test_dir_free/lib/profiles/global-macos-mobileconfig-profile.mobileconfig b/cmd/fleetctl/testdata/generateGitops/test_dir_free/lib/profiles/global-macos-mobileconfig-profile.mobileconfig
new file mode 100644
index 0000000000..d2ce5ba191
--- /dev/null
+++ b/cmd/fleetctl/testdata/generateGitops/test_dir_free/lib/profiles/global-macos-mobileconfig-profile.mobileconfig
@@ -0,0 +1 @@
+global macos mobileconfig profile
\ No newline at end of file
diff --git a/cmd/fleetctl/testdata/generateGitops/test_dir_free/lib/profiles/global-windows-profile.xml b/cmd/fleetctl/testdata/generateGitops/test_dir_free/lib/profiles/global-windows-profile.xml
new file mode 100644
index 0000000000..c594eb9abf
--- /dev/null
+++ b/cmd/fleetctl/testdata/generateGitops/test_dir_free/lib/profiles/global-windows-profile.xml
@@ -0,0 +1 @@
+global windows profile
\ No newline at end of file
diff --git a/cmd/fleetctl/testdata/generateGitops/test_dir_premium/default.yml b/cmd/fleetctl/testdata/generateGitops/test_dir_premium/default.yml
new file mode 100644
index 0000000000..f885a337c5
--- /dev/null
+++ b/cmd/fleetctl/testdata/generateGitops/test_dir_premium/default.yml
@@ -0,0 +1,176 @@
+agent_options:
+ config:
+ decorators:
+ load:
+ - SELECT uuid AS host_uuid FROM system_info;
+ - SELECT hostname AS hostname FROM system_info;
+ options:
+ disable_distributed: false
+ distributed_interval: 10
+ distributed_plugin: tls
+ distributed_tls_max_attempts: 3
+ logger_tls_endpoint: /api/osquery/log
+ logger_tls_period: 10
+ pack_delimiter: /
+labels:
+- description: Label A description
+ label_membership_type: dynamic
+ name: Label A
+ platform: linux,macos
+ query: SELECT * FROM osquery_info
+- description: Label B description
+ hosts:
+ - host1
+ - host2
+ label_membership_type: manual
+ name: Label B
+org_settings:
+ features:
+ additional_queries:
+ macs: SELECT mac FROM interface_details
+ time: SELECT * FROM time
+ detail_query_overrides:
+ mdm: SELECT enrolled, server_url, installed_from_dep, payload_identifier FROM
+ mdm;
+ users:
+ enable_host_users: true
+ enable_software_inventory: true
+ fleet_desktop:
+ transparency_url: https://fleetdm.com/transparency
+ host_expiry_settings:
+ host_expiry_enabled: false
+ host_expiry_window: 59995
+ integrations:
+ custom_scep_proxy:
+ - challenge: # TODO: Add your custom SCEP proxy challenge here
+ name: some-custom-scep-proxy-name
+ url: https://some-custom-scep-proxy-url.com
+ digicert:
+ - api_token: # TODO: Add your Digicert API token here
+ certificate_common_name: some-digicert-certificate-common-name
+ certificate_seat_id: some-digicert-certificate-seat-id
+ certificate_user_principal_names:
+ - some-digicert-certificate-user-principal-name
+ - some-other-digicert-certificate-user-principal-name
+ name: some-digicert-name
+ profile_id: some-digicert-profile-id
+ url: https://some-digicert-url.com
+ google_calendar:
+ - api_key_json:
+ owl: hoot
+ private_key: # TODO: Add your Google Calendar API key JSON here
+ domain: fleetdm.com
+ jira:
+ - api_token: # TODO: Add your Jira API token here
+ enable_failing_policies: false
+ enable_software_vulnerabilities: false
+ project_key: some-jira-project-key
+ url: https://some-jira-url.com
+ username: some-jira-username
+ ndes_scep_proxy:
+ admin_url: https://some-ndes-admin-url.com
+ password: # TODO: Add your NDES SCEP proxy password here
+ url: https://some-ndes-scep-proxy-url.com
+ username: some-ndes-username
+ zendesk:
+ - api_token: # TODO: Add your Zendesk API token here
+ email: some-zendesk-email@example.com
+ enable_failing_policies: false
+ enable_software_vulnerabilities: false
+ group_id: 123456789
+ url: https://some-zendesk-url.com
+ mdm:
+ apple_business_manager:
+ - ios_team: "\U0001F4F1\U0001F3E2 Company-owned mobile devices"
+ ipados_team: "\U0001F4F1\U0001F3E2 Company-owned mobile devices"
+ macos_team: "\U0001F4BB Workstations"
+ organization_name: Fleet Device Management Inc.
+ apple_server_url: http://some-apple-server-url.com
+ end_user_authentication:
+ entity_id: some-mdm-entity-id.com
+ idp_name: some-other-idp-name
+ issuer_uri: https://some-mdm-issuer-uri.com
+ metadata: # TODO: Add your MDM end user auth metadata here
+ metadata_url: # TODO: Add your MDM end user auth metadata URL here
+ volume_purchasing_program:
+ - location: Fleet Device Management Inc.
+ teams:
+ - "\U0001F4BB Workstations"
+ - "\U0001F4BB\U0001F423 Workstations (canary)"
+ - "\U0001F4F1\U0001F3E2 Company-owned mobile devices"
+ - "\U0001F4F1\U0001F510 Personal mobile devices"
+ org_info:
+ contact_url: https://fleetdm.com/company/contact
+ org_logo_url: http://some-org-logo-url.com
+ org_logo_url_light_background: http://some-org-logo-url-light-background.com
+ org_name: Fleet
+ secrets:
+ - secret: # TODO: Add your enroll secrets here
+ server_settings:
+ ai_features_disabled: false
+ debug_host_ids:
+ - 1
+ - 3
+ deferred_save_host: false
+ enable_analytics: true
+ live_query_disabled: false
+ query_report_cap: 1
+ query_reports_disabled: false
+ scripts_disabled: false
+ server_url: https://dogfood.fleetdm.com
+ sso_settings:
+ enable_jit_provisioning: true
+ enable_sso: true
+ enable_sso_idp_login: false
+ entity_id: dogfood.fleetdm.com
+ idp_image_url: http://some-sso-idp-image-url.com
+ idp_name: some-idp-name
+ metadata: # TODO: Add your SSO metadata here
+ metadata_url: # TODO: Add your SSO metadata URL here
+ webhook_settings:
+ activities_webhook:
+ destination_url: https://some-activities-webhook-url.com
+ enable_activities_webhook: true
+ failing_policies_webhook:
+ destination_url: https://some-failing-policies-webhook-url.com
+ enable_failing_policies_webhook: true
+ host_batch_size: 2
+ policy_ids:
+ host_status_webhook:
+ days_count: 5
+ destination_url: https://some-host-status-webhook-url.com
+ enable_host_status_webhook: true
+ host_percentage: 20
+ interval: 6h0m0s
+ vulnerabilities_webhook:
+ destination_url: https://some-vulerabilities-webhook-url.com
+ enable_vulnerabilities_webhook: true
+ host_batch_size: 3
+ yara_rules:
+policies:
+- calendar_events_enabled: false
+ critical: false
+ description: This is a global policy
+ install_software:
+ hash_sha256: software-package-hash ___GITOPS_COMMENT_1___
+ labels_include_any:
+ - Label A
+ - Label B
+ name: Global Policy
+ platform: darwin
+ query: SELECT * FROM global_policy WHERE id = 1
+ resolution: Do a global thing
+queries:
+- automations_enabled: true
+ description: This is a global query
+ discard_data: false
+ interval: 3600
+ labels_include_any:
+ - Label A
+ - Label B
+ logging: stdout
+ min_osquery_version: 1.2.3
+ name: Global Query
+ observer_can_run: true
+ platform: darwin
+ query: SELECT * FROM global_query WHERE id = 1
diff --git a/cmd/fleetctl/testdata/generateGitops/test_dir_premium/lib/no-team/scripts/Script Z.ps1 b/cmd/fleetctl/testdata/generateGitops/test_dir_premium/lib/no-team/scripts/Script Z.ps1
new file mode 100644
index 0000000000..c7feac2a65
--- /dev/null
+++ b/cmd/fleetctl/testdata/generateGitops/test_dir_premium/lib/no-team/scripts/Script Z.ps1
@@ -0,0 +1,2 @@
+#!/usr/bin/env pwsh
+echo "Hello from Script B!"
\ No newline at end of file
diff --git a/cmd/fleetctl/testdata/generateGitops/test_dir_premium/lib/team-a/profiles/team-macos-mobileconfig-profile.mobileconfig b/cmd/fleetctl/testdata/generateGitops/test_dir_premium/lib/team-a/profiles/team-macos-mobileconfig-profile.mobileconfig
new file mode 100644
index 0000000000..d8f74b96ff
--- /dev/null
+++ b/cmd/fleetctl/testdata/generateGitops/test_dir_premium/lib/team-a/profiles/team-macos-mobileconfig-profile.mobileconfig
@@ -0,0 +1 @@
+test mobileconfig profile
\ No newline at end of file
diff --git a/cmd/fleetctl/testdata/generateGitops/test_dir_premium/lib/team-a/queries/my-software-package-darwin-preinstallquery.yml b/cmd/fleetctl/testdata/generateGitops/test_dir_premium/lib/team-a/queries/my-software-package-darwin-preinstallquery.yml
new file mode 100644
index 0000000000..4bbb2f72cc
--- /dev/null
+++ b/cmd/fleetctl/testdata/generateGitops/test_dir_premium/lib/team-a/queries/my-software-package-darwin-preinstallquery.yml
@@ -0,0 +1 @@
+- query: SELECT * FROM pre_install_query
diff --git a/cmd/fleetctl/testdata/generateGitops/test_dir_premium/lib/team-a/scripts/Script B.ps1 b/cmd/fleetctl/testdata/generateGitops/test_dir_premium/lib/team-a/scripts/Script B.ps1
new file mode 100644
index 0000000000..df0abd7097
--- /dev/null
+++ b/cmd/fleetctl/testdata/generateGitops/test_dir_premium/lib/team-a/scripts/Script B.ps1
@@ -0,0 +1 @@
+pop goes the weasel!
\ No newline at end of file
diff --git a/cmd/fleetctl/testdata/generateGitops/test_dir_premium/lib/team-a/scripts/my-software-package-darwin-install b/cmd/fleetctl/testdata/generateGitops/test_dir_premium/lib/team-a/scripts/my-software-package-darwin-install
new file mode 100644
index 0000000000..1910281566
--- /dev/null
+++ b/cmd/fleetctl/testdata/generateGitops/test_dir_premium/lib/team-a/scripts/my-software-package-darwin-install
@@ -0,0 +1 @@
+foo
\ No newline at end of file
diff --git a/cmd/fleetctl/testdata/generateGitops/test_dir_premium/lib/team-a/scripts/my-software-package-darwin-postinstall b/cmd/fleetctl/testdata/generateGitops/test_dir_premium/lib/team-a/scripts/my-software-package-darwin-postinstall
new file mode 100644
index 0000000000..ba0e162e1c
--- /dev/null
+++ b/cmd/fleetctl/testdata/generateGitops/test_dir_premium/lib/team-a/scripts/my-software-package-darwin-postinstall
@@ -0,0 +1 @@
+bar
\ No newline at end of file
diff --git a/cmd/fleetctl/testdata/generateGitops/test_dir_premium/lib/team-a/scripts/my-software-package-darwin-uninstall b/cmd/fleetctl/testdata/generateGitops/test_dir_premium/lib/team-a/scripts/my-software-package-darwin-uninstall
new file mode 100644
index 0000000000..3f95386662
--- /dev/null
+++ b/cmd/fleetctl/testdata/generateGitops/test_dir_premium/lib/team-a/scripts/my-software-package-darwin-uninstall
@@ -0,0 +1 @@
+baz
\ No newline at end of file
diff --git a/cmd/fleetctl/testdata/generateGitops/test_dir_premium/teams/no-team.yml b/cmd/fleetctl/testdata/generateGitops/test_dir_premium/teams/no-team.yml
new file mode 100644
index 0000000000..4879c5fba1
--- /dev/null
+++ b/cmd/fleetctl/testdata/generateGitops/test_dir_premium/teams/no-team.yml
@@ -0,0 +1,33 @@
+controls:
+ enable_disk_encryption: true
+ ios_updates:
+ deadline: "2025-12-31"
+ minimum_version: "18.1"
+ ipados_updates:
+ deadline: "2026-12-31"
+ minimum_version: "18.2"
+ macos_migration:
+ enable: true
+ mode: voluntary
+ webhook_url: https://some-macos-migration-webhook-url.com
+ macos_setup: 'TODO: update with your macos_setup configuration'
+ macos_updates:
+ deadline: "2024-12-31"
+ minimum_version: "15.1"
+ scripts:
+ - path: ../lib/no-team/scripts/Script Z.ps1
+ windows_enabled_and_configured: true
+ windows_migration_enabled: true
+ windows_updates:
+ deadline_days: 5
+ grace_period_days: 2
+name: No team
+policies:
+- calendar_events_enabled: false
+ critical: false
+ description: This is a team policy
+ name: Team Policy
+ platform: linux,windows
+ query: SELECT * FROM team_policy WHERE id = 1
+ resolution: Do a team thing
+software:
\ No newline at end of file
diff --git a/cmd/fleetctl/testdata/generateGitops/test_dir_premium/teams/team-a.yml b/cmd/fleetctl/testdata/generateGitops/test_dir_premium/teams/team-a.yml
new file mode 100644
index 0000000000..242c83d3f8
--- /dev/null
+++ b/cmd/fleetctl/testdata/generateGitops/test_dir_premium/teams/team-a.yml
@@ -0,0 +1,105 @@
+agent_options:
+ config:
+ decorators:
+ load:
+ - SELECT uuid AS host_uuid FROM system_info;
+ - SELECT hostname AS hostname FROM system_info;
+ options:
+ disable_distributed: false
+ distributed_interval: 10
+ distributed_plugin: tls
+ distributed_tls_max_attempts: 3
+ logger_tls_endpoint: /api/osquery/log
+ logger_tls_period: 10
+ pack_delimiter: /
+ update_channels:
+ desktop: edge
+ orbit: edge
+ osqueryd: edge
+controls:
+ enable_disk_encryption: true
+ ios_updates:
+ deadline: "2021-12-31"
+ minimum_version: "98.1"
+ ipados_updates:
+ deadline: "2022-12-31"
+ minimum_version: "98.2"
+ macos_settings:
+ custom_settings:
+ - path: ../lib/team-a/profiles/team-macos-mobileconfig-profile.mobileconfig
+ macos_updates:
+ deadline: "2020-12-31"
+ minimum_version: "95.1"
+ scripts:
+ - path: ../lib/team-a/scripts/Script B.ps1
+ windows_enabled_and_configured: true
+ windows_updates:
+ deadline_days: 95
+ grace_period_days: 92
+name: Team A
+policies:
+- calendar_events_enabled: false
+ critical: false
+ description: This is a team policy
+ name: Team Policy
+ platform: linux,windows
+ query: SELECT * FROM team_policy WHERE id = 1
+ resolution: Do a team thing
+queries:
+- automations_enabled: true
+ description: This is a team query
+ discard_data: false
+ interval: 1800
+ logging: stderr
+ min_osquery_version: 4.5.6
+ name: Team Query
+ observer_can_run: false
+ platform: linux,windows
+ query: SELECT * FROM team_query WHERE id = 1
+software:
+ app_store_apps:
+ - app_store_id: com.example.team-software
+ labels_exclude_any:
+ - Label C
+ - Label D
+ packages:
+ - hash_sha256: software-package-hash # My Software Package (my-software.pkg) version 1.0.0, 2.0.0
+ install_script:
+ path: ../lib/team-a/scripts/my-software-package-darwin-install
+ labels_include_any:
+ - Label A
+ - Label B
+ post_install_script:
+ path: ../lib/team-a/scripts/my-software-package-darwin-postinstall
+ pre_install_query:
+ path: ../lib/team-a/queries/my-software-package-darwin-preinstallquery.yml
+ self_service: true
+ uninstall_script:
+ path: ../lib/team-a/scripts/my-software-package-darwin-uninstall
+team_settings:
+ features:
+ enable_host_users: true
+ enable_software_inventory: true
+ host_expiry_settings:
+ host_expiry_enabled: false
+ host_expiry_window: 1
+ integrations:
+ google_calendar:
+ enable_calendar_events: true
+ webhook_url: https://some-team-google-calendar-webhook.com
+ secrets:
+ - secret: # TODO: Add your enroll secrets here
+ webhook_settings:
+ failing_policies_webhook:
+ destination_url: https://some-team-failing_policies-webhook.com
+ enable_failing_policies_webhook: false
+ host_batch_size: 4
+ policy_ids:
+ - 1
+ - 2
+ - 3
+ host_status_webhook:
+ days_count: 3
+ destination_url: https://some-team-host-status-webhook.com
+ enable_host_status_webhook: false
+ host_percentage: 2
diff --git a/pkg/spec/gitops.go b/pkg/spec/gitops.go
index 4a165f0b02..eeb0d474f9 100644
--- a/pkg/spec/gitops.go
+++ b/pkg/spec/gitops.go
@@ -76,6 +76,7 @@ type PolicyRunScript struct {
type PolicyInstallSoftware struct {
PackagePath string `json:"package_path"`
AppStoreID string `json:"app_store_id"`
+ HashSHA256 string `json:"hash_sha256"`
}
type Query struct {
@@ -94,8 +95,9 @@ type SoftwarePackage struct {
}
type Software struct {
- Packages []SoftwarePackage `json:"packages"`
- AppStoreApps []fleet.TeamSpecAppStoreApp `json:"app_store_apps"`
+ Packages []SoftwarePackage `json:"packages"`
+ AppStoreApps []fleet.TeamSpecAppStoreApp `json:"app_store_apps"`
+ FleetMaintainedApps []fleet.MaintainedApp `json:"fleet_maintained_apps"`
}
type GitOps struct {
@@ -816,8 +818,8 @@ func parsePolicyInstallSoftware(baseDir string, teamName *string, policy *Policy
if policy.InstallSoftware != nil && (policy.InstallSoftware.PackagePath != "" || policy.InstallSoftware.AppStoreID != "") && teamName == nil {
return errors.New("install_software can only be set on team policies")
}
- if policy.InstallSoftware.PackagePath == "" && policy.InstallSoftware.AppStoreID == "" {
- return errors.New("install_software must include either a package path or app store app ID")
+ if policy.InstallSoftware.PackagePath == "" && policy.InstallSoftware.AppStoreID == "" && policy.InstallSoftware.HashSHA256 == "" {
+ return errors.New("install_software must include either a package_path, an app_store_id or a hash_sha256")
}
if policy.InstallSoftware.PackagePath != "" && policy.InstallSoftware.AppStoreID != "" {
return errors.New("install_software must have only one of package_path or app_store_id")
@@ -967,6 +969,9 @@ func parseSoftware(top map[string]json.RawMessage, result *GitOps, baseDir strin
return multierror.Append(multiError, fmt.Errorf("failed to unmarshall softwarespec: %v", err))
}
}
+ if software.FleetMaintainedApps != nil {
+ return multierror.Append(multiError, errors.New("Fleet maintained apps are not currently supported in GitOps"))
+ }
for _, item := range software.AppStoreApps {
item := item
if item.AppStoreID == "" {
diff --git a/pkg/spec/gitops_test.go b/pkg/spec/gitops_test.go
index 4a74c7d344..729cb50e2f 100644
--- a/pkg/spec/gitops_test.go
+++ b/pkg/spec/gitops_test.go
@@ -929,7 +929,7 @@ policies:
package_path:
`
_, err = gitOpsFromString(t, config)
- assert.ErrorContains(t, err, "must include either a package path or app store app ID")
+ assert.ErrorContains(t, err, "install_software must include either a package_path, an app_store_id or a hash_sha256")
config = getTeamConfig([]string{"policies"})
config += `
diff --git a/server/datastore/mysql/software_installers.go b/server/datastore/mysql/software_installers.go
index f3c40b10ec..be5c74697c 100644
--- a/server/datastore/mysql/software_installers.go
+++ b/server/datastore/mysql/software_installers.go
@@ -654,6 +654,7 @@ SELECT
si.team_id,
si.title_id,
si.storage_id,
+ si.fleet_maintained_app_id,
si.package_ids,
si.filename,
si.extension,
@@ -2166,7 +2167,9 @@ func (ds *Datastore) GetSoftwareInstallers(ctx context.Context, teamID uint) ([]
SELECT
team_id,
title_id,
- url
+ url,
+ storage_id as hash_sha256,
+ fleet_maintained_app_id
FROM
software_installers
WHERE global_or_team_id = ?
diff --git a/server/datastore/mysql/software_titles.go b/server/datastore/mysql/software_titles.go
index ce285103a9..0940ec28c1 100644
--- a/server/datastore/mysql/software_titles.go
+++ b/server/datastore/mysql/software_titles.go
@@ -321,6 +321,7 @@ SELECT
si.url AS package_url,
si.install_during_setup as package_install_during_setup,
si.storage_id as package_storage_id,
+ si.fleet_maintained_app_id,
vat.self_service as vpp_app_self_service,
vat.adam_id as vpp_app_adam_id,
vat.install_during_setup as vpp_install_during_setup,
@@ -338,7 +339,7 @@ LEFT JOIN software_titles_host_counts sthc ON sthc.software_title_id = st.id AND
WHERE %s
-- placeholder for filter based on software installed on hosts + software installers
AND (%s)
-GROUP BY st.id, package_self_service, package_name, package_version, package_platform, package_url, package_install_during_setup, package_storage_id, vpp_app_self_service, vpp_app_adam_id, vpp_app_version, vpp_app_platform, vpp_app_icon_url, vpp_install_during_setup`
+GROUP BY st.id, package_self_service, package_name, package_version, package_platform, package_url, package_install_during_setup, package_storage_id, fleet_maintained_app_id, vpp_app_self_service, vpp_app_adam_id, vpp_app_version, vpp_app_platform, vpp_app_icon_url, vpp_install_during_setup`
cveJoinType := "LEFT"
if opt.VulnerableOnly {
diff --git a/server/fleet/software.go b/server/fleet/software.go
index 26c24e31e9..25784df6d3 100644
--- a/server/fleet/software.go
+++ b/server/fleet/software.go
@@ -239,8 +239,9 @@ type SoftwareTitleListResult struct {
// BundleIdentifier is used by Apple installers to uniquely identify
// the software installed. It's surfaced in software_titles to match
// with existing software entries.
- BundleIdentifier *string `json:"bundle_identifier,omitempty" db:"bundle_identifier"`
- HashSHA256 *string `json:"hash_sha256,omitempty" db:"package_storage_id"`
+ BundleIdentifier *string `json:"bundle_identifier,omitempty" db:"bundle_identifier"`
+ HashSHA256 *string `json:"hash_sha256,omitempty" db:"package_storage_id"`
+ FleetMaintainedAppID *uint `json:"fleet_maintained_app_id,omitempty" db:"fleet_maintained_app_id"`
}
type SoftwareTitleListOptions struct {
diff --git a/server/fleet/software_installer.go b/server/fleet/software_installer.go
index 36974a4e0e..6057e90062 100644
--- a/server/fleet/software_installer.go
+++ b/server/fleet/software_installer.go
@@ -137,7 +137,7 @@ type SoftwareInstaller struct {
// URL is the source URL for this installer (set when uploading via batch/gitops).
URL string `json:"url" db:"url"`
// FleetMaintainedAppID is the related Fleet-maintained app for this installer (if not nil).
- FleetMaintainedAppID *uint `json:"-" db:"fleet_maintained_app_id"`
+ FleetMaintainedAppID *uint `json:"fleet_maintained_app_id" db:"fleet_maintained_app_id"`
// AutomaticInstallPolicies is the list of policies that trigger automatic
// installation of this software.
AutomaticInstallPolicies []AutomaticInstallPolicy `json:"automatic_install_policies" db:"-"`
@@ -161,6 +161,10 @@ type SoftwarePackageResponse struct {
TitleID *uint `json:"title_id" db:"title_id"`
// URL is the source URL for this installer (set when uploading via batch/gitops).
URL string `json:"url" db:"url"`
+ // HashSHA256 is the SHA256 hash of the software installer.
+ HashSHA256 string `json:"hash_sha256" db:"hash_sha256"`
+ // ID of the Fleet Maintained App this package uses, if any
+ FleetMaintainedAppID *uint `json:"fleet_maintained_app_id" db:"fleet_maintained_app_id"`
}
// VPPAppResponse is the response type used when applying app store apps by batch.
diff --git a/server/service/client.go b/server/service/client.go
index 3166c05825..ab7ac81529 100644
--- a/server/service/client.go
+++ b/server/service/client.go
@@ -2097,18 +2097,20 @@ func (c *Client) doGitOpsPolicies(config *spec.GitOps, teamSoftwareInstallers []
// Get software titles of packages for the team.
softwareTitleIDsByInstallerURL := make(map[string]uint)
softwareTitleIDsByAppStoreAppID := make(map[string]uint)
+ softwareTitleIDsByHash := make(map[string]uint)
for _, softwareInstaller := range teamSoftwareInstallers {
if softwareInstaller.TitleID == nil {
// Should not happen, but to not panic we just log a warning.
logFn("[!] software installer without title id: team_id=%d, url=%s\n", *teamID, softwareInstaller.URL)
continue
}
- if softwareInstaller.URL == "" {
+ if softwareInstaller.URL == "" && softwareInstaller.HashSHA256 == "" {
// Should not happen because we previously applied packages via gitops, but to not panic we just log a warning.
logFn("[!] software installer without url: team_id=%d, title_id=%d\n", *teamID, *softwareInstaller.TitleID)
continue
}
softwareTitleIDsByInstallerURL[softwareInstaller.URL] = *softwareInstaller.TitleID
+ softwareTitleIDsByHash[softwareInstaller.HashSHA256] = *softwareInstaller.TitleID
}
for _, vppApp := range teamVPPApps {
if vppApp.Platform != fleet.MacOSPlatform {
@@ -2155,6 +2157,17 @@ func (c *Client) doGitOpsPolicies(config *spec.GitOps, teamSoftwareInstallers []
}
config.Policies[i].SoftwareTitleID = &softwareTitleID
}
+ if config.Policies[i].InstallSoftware.HashSHA256 != "" {
+ softwareTitleID, ok := softwareTitleIDsByHash[config.Policies[i].InstallSoftware.HashSHA256]
+ if !ok {
+ // Should not happen because software packages are uploaded first.
+ if !dryRun {
+ logFn("[!] software hash without software title ID: %s\n", config.Policies[i].InstallSoftware.HashSHA256)
+ }
+ continue
+ }
+ config.Policies[i].SoftwareTitleID = &softwareTitleID
+ }
}
// Get scripts for the team.
diff --git a/server/service/client_profiles.go b/server/service/client_profiles.go
index 360adef2d7..49c75a6102 100644
--- a/server/service/client_profiles.go
+++ b/server/service/client_profiles.go
@@ -36,6 +36,41 @@ func (c *Client) ListProfiles(teamID *uint) ([]*fleet.MDMAppleConfigProfile, err
return responseBody.ConfigProfiles, nil
}
+func (c *Client) ListConfigurationProfiles(teamID *uint) ([]*fleet.MDMConfigProfilePayload, error) {
+ verb, path := "GET", "/api/latest/fleet/configuration_profiles"
+ query := make(url.Values)
+ if teamID != nil {
+ query.Add("team_id", strconv.FormatUint(uint64(*teamID), 10))
+ }
+ var responseBody listMDMConfigProfilesResponse
+ if err := c.authenticatedRequestWithQuery(nil, verb, path, &responseBody, query.Encode()); err != nil {
+ return nil, err
+ }
+ return responseBody.Profiles, nil
+}
+
+// Get the contents of a saved profile.
+func (c *Client) GetProfileContents(profileID string) ([]byte, error) {
+ verb, path := "GET", "/api/latest/fleet/mdm/profiles/"+profileID
+ response, err := c.AuthenticatedDo(verb, path, "alt=media", nil)
+ if err != nil {
+ return nil, fmt.Errorf("%s %s: %w", verb, path, err)
+ }
+ defer response.Body.Close()
+ err = c.parseResponse(verb, path, response, nil)
+ if err != nil {
+ return nil, fmt.Errorf("%s %s: %w", verb, path, err)
+ }
+ if response.StatusCode != http.StatusNoContent {
+ b, err := io.ReadAll(response.Body)
+ if err != nil {
+ return nil, fmt.Errorf("reading response body: %w", err)
+ }
+ return b, nil
+ }
+ return nil, nil
+}
+
func (c *Client) AddProfile(teamID uint, configurationProfile []byte) (uint, error) {
if c.token == "" {
return 0, errors.New("authentication token is empty")
diff --git a/server/service/client_scripts.go b/server/service/client_scripts.go
index 893abd3faf..87260af164 100644
--- a/server/service/client_scripts.go
+++ b/server/service/client_scripts.go
@@ -229,3 +229,25 @@ func (c *Client) ListScripts(query string) ([]*fleet.Script, error) {
}
return responseBody.Scripts, nil
}
+
+// Get the contents of a saved script.
+func (c *Client) GetScriptContents(scriptID uint) ([]byte, error) {
+ verb, path := "GET", "/api/latest/fleet/scripts/"+fmt.Sprint(scriptID)
+ response, err := c.AuthenticatedDo(verb, path, "alt=media", nil)
+ if err != nil {
+ return nil, fmt.Errorf("%s %s: %w", verb, path, err)
+ }
+ defer response.Body.Close()
+ err = c.parseResponse(verb, path, response, nil)
+ if err != nil {
+ return nil, fmt.Errorf("parsing script response: %w", err)
+ }
+ if response.StatusCode != http.StatusNoContent {
+ b, err := io.ReadAll(response.Body)
+ if err != nil {
+ return nil, fmt.Errorf("reading response body: %w", err)
+ }
+ return b, nil
+ }
+ return nil, nil
+}
diff --git a/server/service/client_software.go b/server/service/client_software.go
index e3e12e873d..48bb487f9e 100644
--- a/server/service/client_software.go
+++ b/server/service/client_software.go
@@ -31,6 +31,23 @@ func (c *Client) ListSoftwareTitles(query string) ([]fleet.SoftwareTitleListResu
return responseBody.SoftwareTitles, nil
}
+// GetSoftwareTitleByID retrieves a software title by ID.
+//
+//nolint:gocritic // ignore captLocal
+func (c *Client) GetSoftwareTitleByID(ID uint, teamID *uint) (*fleet.SoftwareTitle, error) {
+ var query string
+ if teamID != nil {
+ query = fmt.Sprintf("team_id=%d", *teamID)
+ }
+ verb, path := "GET", "/api/latest/fleet/software/titles/"+fmt.Sprint(ID)
+ var responseBody getSoftwareTitleResponse
+ err := c.authenticatedRequestWithQuery(nil, verb, path, &responseBody, query)
+ if err != nil {
+ return nil, err
+ }
+ return responseBody.SoftwareTitle, nil
+}
+
func (c *Client) ApplyNoTeamSoftwareInstallers(softwareInstallers []fleet.SoftwareInstallerPayload, opts fleet.ApplySpecOptions) ([]fleet.SoftwarePackageResponse, error) {
query, err := url.ParseQuery(opts.RawQuery())
if err != nil {