fleet/pkg/spec/gitops.go
Scott Gress 2a18f56655
Manage labels in GitOps (#27038)
For #24473 

# Checklist for submitter

<!-- Note that API documentation changes are now addressed by the
product design team. -->

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

## Details

This PR adds the ability to manage labels via GitOps. Usage is as
follows:

* If a top-level `labels:` key is provided in the global YAML file
provided to GitOps, then any labels in this list will be created (if
using a new name) or updated (if using an existing name).
* If no top-level `labels:` key is provided, no changes will be made to
labels. This allows backwards-compatibility; customers won't blow away
all of their labels if they don't immediately use `labels:` in their
YAML

Additionally, some new validation has been added so that label usage is
checked prior to application. This means that when the gitops command is
run, it will verify that any labels referenced elsewhere in the YAML
(e.g. by software installers or mdm profiles) exist, and will bail with
an error message if they don't.

## Testing

**Test label deletion**
1. Add some labels via the UI
2. Run `fleetctl gitops --dry-run` with a default.yml file _without_
`labels:` in it, and verify that it doesn't say it will update or delete
any labels
2. Run `fleetctl gitops` with a default.yml file _without_ `labels:` in
it, and verify that it doesn't modify or remove your labels
4. Run `fleetctl gitops --dry-run` with a default.yml file with
`labels:` in it and nothing underneath, and verify that it says that it
will delete your labels
4. Run `fleetctl gitops` with a default.yml file with `labels:` in it
and nothing underneath, and verify that it removes all your labels

**Test label create/update**
1. Add a label "foo" via the UI
2. Run `fleetctl gitops --dry-run` with a default.yml file with two
`labels:` in it, one named "foo" and one named "bar". Verify that the
output says that one label will be created and one will be updated.
2. Run `fleetctl gitops` with a default.yml file with two `labels:` in
it, one named "foo" and one named "bar". Verify that the two labels now
exist in the UI with the configuration you specified.

**Test label usage**
1. Add a label "foo" in the UI.
1. Run `fleetctl gitops --dry-run` with a default.yml file _without_
`labels:` in it, where a software installer or mdm profile uses the
"foo" label via `labels_include_any`. Verify that the output doesn't
complain about unknown labels.
1. Run `fleetctl gitops --dry-run` with a default.yml file _with_
`labels:` in it with nothing underneath, and a software installer or mdm
profile uses the "foo" label via `labels_include_any`. Verify that the
output complains about unknown label "foo"
1. Run `fleetctl gitops --dry-run` with a default.yml file _with_
`labels:` in it with a "foo" label defined underneath, and a software
installer or mdm profile uses the "foo" label via `labels_include_any`.
Verify that the output doesn't complain about unknown labels.
2025-03-19 16:35:11 -05:00

1107 lines
39 KiB
Go

package spec
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"slices"
"strings"
"unicode"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/ghodss/yaml"
"github.com/hashicorp/go-multierror"
"golang.org/x/text/unicode/norm"
)
type BaseItem struct {
Path *string `json:"path"`
}
type GitOpsControls struct {
BaseItem
MacOSUpdates interface{} `json:"macos_updates"`
IOSUpdates interface{} `json:"ios_updates"`
IPadOSUpdates interface{} `json:"ipados_updates"`
MacOSSettings interface{} `json:"macos_settings"`
MacOSSetup *fleet.MacOSSetup `json:"macos_setup"`
MacOSMigration interface{} `json:"macos_migration"`
WindowsUpdates interface{} `json:"windows_updates"`
WindowsSettings interface{} `json:"windows_settings"`
WindowsEnabledAndConfigured interface{} `json:"windows_enabled_and_configured"`
WindowsMigrationEnabled interface{} `json:"windows_migration_enabled"`
EnableDiskEncryption interface{} `json:"enable_disk_encryption"`
Scripts []BaseItem `json:"scripts"`
Defined bool
}
func (c GitOpsControls) Set() bool {
return c.MacOSUpdates != nil || c.IOSUpdates != nil ||
c.IPadOSUpdates != nil || c.MacOSSettings != nil ||
c.MacOSSetup != nil || c.MacOSMigration != nil ||
c.WindowsUpdates != nil || c.WindowsSettings != nil || c.WindowsEnabledAndConfigured != nil ||
c.WindowsMigrationEnabled != nil || c.EnableDiskEncryption != nil || len(c.Scripts) > 0
}
type Policy struct {
BaseItem
GitOpsPolicySpec
}
type GitOpsPolicySpec struct {
fleet.PolicySpec
RunScript *PolicyRunScript `json:"run_script"`
InstallSoftware *PolicyInstallSoftware `json:"install_software"`
// InstallSoftwareURL is populated after parsing the software installer yaml
// referenced by InstallSoftware.PackagePath.
InstallSoftwareURL string `json:"-"`
// RunScriptName is populated after confirming the script exists on both the file system
// and in the controls scripts list for the same team
RunScriptName *string `json:"-"`
}
type PolicyRunScript struct {
Path string `json:"path"`
}
type PolicyInstallSoftware struct {
PackagePath string `json:"package_path"`
AppStoreID string `json:"app_store_id"`
}
type Query struct {
BaseItem
fleet.QuerySpec
}
type Label struct {
BaseItem
fleet.LabelSpec
}
type SoftwarePackage struct {
BaseItem
fleet.SoftwarePackageSpec
}
type Software struct {
Packages []SoftwarePackage `json:"packages"`
AppStoreApps []fleet.TeamSpecAppStoreApp `json:"app_store_apps"`
}
type GitOps struct {
TeamID *uint
TeamName *string
TeamSettings map[string]interface{}
OrgSettings map[string]interface{}
AgentOptions *json.RawMessage
Controls GitOpsControls
Policies []*GitOpsPolicySpec
Queries []*fleet.QuerySpec
Labels []*fleet.LabelSpec
// Software is only allowed on teams, not on global config.
Software GitOpsSoftware
// FleetSecrets is a map of secret names to their values, extracted from FLEET_SECRET_ environment variables used in profiles and scripts.
FleetSecrets map[string]string
}
type GitOpsSoftware struct {
Packages []*fleet.SoftwarePackageSpec
AppStoreApps []*fleet.TeamSpecAppStoreApp
}
type Logf func(format string, a ...interface{})
// GitOpsFromFile parses a GitOps yaml file.
func GitOpsFromFile(filePath, baseDir string, appConfig *fleet.EnrichedAppConfig, logFn Logf) (*GitOps, error) {
b, err := os.ReadFile(filePath)
if err != nil {
return nil, fmt.Errorf("failed to read file: %s: %w", filePath, err)
}
// Replace $var and ${var} with env values.
b, err = ExpandEnvBytes(b)
if err != nil {
return nil, fmt.Errorf("failed to expand environment in file %s: %w", filePath, err)
}
var top map[string]json.RawMessage
if err := yaml.Unmarshal(b, &top); err != nil {
return nil, fmt.Errorf("failed to unmarshal file %w: \n", err)
}
var multiError *multierror.Error
result := &GitOps{}
result.FleetSecrets = make(map[string]string)
topKeys := []string{"name", "team_settings", "org_settings", "agent_options", "controls", "policies", "queries", "software", "labels"}
for k := range top {
if !slices.Contains(topKeys, k) {
multiError = multierror.Append(multiError, fmt.Errorf("unknown top-level field: %s", k))
}
}
// Figure out if this is an org or team settings file
teamRaw, teamOk := top["name"]
teamSettingsRaw, teamSettingsOk := top["team_settings"]
orgSettingsRaw, orgOk := top["org_settings"]
switch {
case orgOk:
if teamOk || teamSettingsOk {
multiError = multierror.Append(multiError, errors.New("'org_settings' cannot be used with 'name', 'team_settings'"))
} else {
multiError = parseOrgSettings(orgSettingsRaw, result, baseDir, multiError)
}
case teamOk:
multiError = parseName(teamRaw, result, multiError)
if result.IsNoTeam() {
if teamSettingsOk {
multiError = multierror.Append(multiError, fmt.Errorf("cannot set 'team_settings' on 'No team' file: %q", filePath))
}
if filepath.Base(filePath) != "no-team.yml" {
multiError = multierror.Append(multiError, fmt.Errorf("file %q for 'No team' must be named 'no-team.yml'", filePath))
}
} else {
if !teamSettingsOk {
multiError = multierror.Append(multiError, errors.New("'team_settings' is required when 'name' is provided"))
} else {
multiError = parseTeamSettings(teamSettingsRaw, result, baseDir, multiError)
}
}
default:
multiError = multierror.Append(multiError, errors.New("either 'org_settings' or 'name' and 'team_settings' is required"))
}
// Get the labels. If `labels:` is specified but no labels are listed, this will
// set Labels as nil. If `labels:` isn't present at all, it will be set as an
// empty array.
_, ok := top["labels"]
if !ok || !result.IsGlobal() {
if ok && !result.IsGlobal() {
logFn("[!] 'labels' is only supported in global settings. This key will be ignored.\n")
}
result.Labels = make([]*fleet.LabelSpec, 0)
} else {
multiError = parseLabels(top, result, baseDir, multiError)
}
// Get other top-level entities.
multiError = parseControls(top, result, multiError, filePath)
multiError = parseAgentOptions(top, result, baseDir, logFn, multiError)
multiError = parseQueries(top, result, baseDir, logFn, multiError)
if appConfig != nil && appConfig.License.IsPremium() {
multiError = parseSoftware(top, result, baseDir, multiError)
}
// Policies can reference software installers and scripts, thus we parse them after parseSoftware and parseControls.
multiError = parsePolicies(top, result, baseDir, multiError)
return result, multiError.ErrorOrNil()
}
func parseName(raw json.RawMessage, result *GitOps, multiError *multierror.Error) *multierror.Error {
if err := json.Unmarshal(raw, &result.TeamName); err != nil {
return multierror.Append(multiError, fmt.Errorf("failed to unmarshal name: %v", err))
}
if result.TeamName == nil || *result.TeamName == "" {
return multierror.Append(multiError, errors.New("team 'name' is required"))
}
// Normalize team name for full Unicode support, so that we can assume team names are unique going forward
normalized := norm.NFC.String(*result.TeamName)
result.TeamName = &normalized
return multiError
}
func (g *GitOps) global() bool {
return g.TeamName == nil || *g.TeamName == ""
}
func (g *GitOps) IsGlobal() bool {
return g.global()
}
func (g *GitOps) IsNoTeam() bool {
return g.TeamName != nil && isNoTeam(*g.TeamName)
}
func isNoTeam(teamName string) bool {
return strings.EqualFold(teamName, noTeam)
}
const noTeam = "No team"
func parseOrgSettings(raw json.RawMessage, result *GitOps, baseDir string, multiError *multierror.Error) *multierror.Error {
var orgSettingsTop BaseItem
if err := json.Unmarshal(raw, &orgSettingsTop); err != nil {
return multierror.Append(multiError, fmt.Errorf("failed to unmarshal org_settings: %v", err))
}
noError := true
if orgSettingsTop.Path != nil {
fileBytes, err := os.ReadFile(resolveApplyRelativePath(baseDir, *orgSettingsTop.Path))
if err != nil {
noError = false
multiError = multierror.Append(multiError, fmt.Errorf("failed to read org settings file %s: %v", *orgSettingsTop.Path, err))
} else {
// Replace $var and ${var} with env values.
fileBytes, err = ExpandEnvBytes(fileBytes)
if err != nil {
noError = false
multiError = multierror.Append(
multiError, fmt.Errorf("failed to expand environment in file %s: %v", *orgSettingsTop.Path, err),
)
} else {
var pathOrgSettings BaseItem
if err := yaml.Unmarshal(fileBytes, &pathOrgSettings); err != nil {
noError = false
multiError = multierror.Append(
multiError, fmt.Errorf("failed to unmarshal org settings file %s: %v", *orgSettingsTop.Path, err),
)
} else {
if pathOrgSettings.Path != nil {
noError = false
multiError = multierror.Append(
multiError,
fmt.Errorf("nested paths are not supported: %s in %s", *pathOrgSettings.Path, *orgSettingsTop.Path),
)
} else {
raw = fileBytes
}
}
}
}
}
if noError {
if err := yaml.Unmarshal(raw, &result.OrgSettings); err != nil {
// This error is currently unreachable because we know the file is valid YAML when we checked for nested path
multiError = multierror.Append(multiError, fmt.Errorf("failed to unmarshal org settings: %v", err))
} else {
multiError = parseSecrets(result, multiError)
}
// TODO: Validate that integrations.(jira|zendesk)[].api_token is not empty or fleet.MaskedPassword
}
return multiError
}
func parseTeamSettings(raw json.RawMessage, result *GitOps, baseDir string, multiError *multierror.Error) *multierror.Error {
var teamSettingsTop BaseItem
if err := json.Unmarshal(raw, &teamSettingsTop); err != nil {
return multierror.Append(multiError, fmt.Errorf("failed to unmarshal team_settings: %v", err))
}
noError := true
if teamSettingsTop.Path != nil {
fileBytes, err := os.ReadFile(resolveApplyRelativePath(baseDir, *teamSettingsTop.Path))
if err != nil {
noError = false
multiError = multierror.Append(multiError, fmt.Errorf("failed to read team settings file %s: %v", *teamSettingsTop.Path, err))
} else {
// Replace $var and ${var} with env values.
fileBytes, err = ExpandEnvBytes(fileBytes)
if err != nil {
noError = false
multiError = multierror.Append(
multiError, fmt.Errorf("failed to expand environment in file %s: %v", *teamSettingsTop.Path, err),
)
} else {
var pathTeamSettings BaseItem
if err := yaml.Unmarshal(fileBytes, &pathTeamSettings); err != nil {
noError = false
multiError = multierror.Append(
multiError, fmt.Errorf("failed to unmarshal team settings file %s: %v", *teamSettingsTop.Path, err),
)
} else {
if pathTeamSettings.Path != nil {
noError = false
multiError = multierror.Append(
multiError,
fmt.Errorf("nested paths are not supported: %s in %s", *pathTeamSettings.Path, *teamSettingsTop.Path),
)
} else {
raw = fileBytes
}
}
}
}
}
if noError {
if err := yaml.Unmarshal(raw, &result.TeamSettings); err != nil {
// This error is currently unreachable because we know the file is valid YAML when we checked for nested path
multiError = multierror.Append(multiError, fmt.Errorf("failed to unmarshal team settings: %v", err))
} else {
multiError = parseSecrets(result, multiError)
}
}
return multiError
}
func parseSecrets(result *GitOps, multiError *multierror.Error) *multierror.Error {
var rawSecrets interface{}
var ok bool
if result.TeamName == nil {
rawSecrets, ok = result.OrgSettings["secrets"]
if !ok {
return multierror.Append(multiError, errors.New("'org_settings.secrets' is required"))
}
} else {
rawSecrets, ok = result.TeamSettings["secrets"]
if !ok {
return multierror.Append(multiError, errors.New("'team_settings.secrets' is required"))
}
}
// When secrets slice is empty, all secrets are removed.
enrollSecrets := make([]*fleet.EnrollSecret, 0)
if rawSecrets != nil {
secrets, ok := rawSecrets.([]interface{})
if !ok {
return multierror.Append(multiError, errors.New("'secrets' must be a list of secret items"))
}
for _, enrollSecret := range secrets {
var secret string
var secretInterface interface{}
secretMap, ok := enrollSecret.(map[string]interface{})
if ok {
secretInterface, ok = secretMap["secret"]
}
if ok {
secret, ok = secretInterface.(string)
}
if !ok || secret == "" {
multiError = multierror.Append(
multiError, errors.New("each item in 'secrets' must have a 'secret' key containing an ASCII string value"),
)
break
}
enrollSecrets = append(
enrollSecrets, &fleet.EnrollSecret{Secret: secret},
)
}
}
if result.TeamName == nil {
result.OrgSettings["secrets"] = enrollSecrets
} else {
result.TeamSettings["secrets"] = enrollSecrets
}
return multiError
}
func parseAgentOptions(top map[string]json.RawMessage, result *GitOps, baseDir string, logFn Logf, multiError *multierror.Error) *multierror.Error {
agentOptionsRaw, ok := top["agent_options"]
if result.IsNoTeam() {
if ok {
logFn("[!] 'agent_options' is not supported for \"No team\". This key will be ignored.\n")
}
return multiError
} else if !ok {
return multierror.Append(multiError, errors.New("'agent_options' is required"))
}
var agentOptionsTop BaseItem
if err := json.Unmarshal(agentOptionsRaw, &agentOptionsTop); err != nil {
multiError = multierror.Append(multiError, fmt.Errorf("failed to unmarshal agent_options: %v", err))
} else {
if agentOptionsTop.Path == nil {
result.AgentOptions = &agentOptionsRaw
} else {
fileBytes, err := os.ReadFile(resolveApplyRelativePath(baseDir, *agentOptionsTop.Path))
if err != nil {
return multierror.Append(multiError, fmt.Errorf("failed to read agent options file %s: %v", *agentOptionsTop.Path, err))
}
// Replace $var and ${var} with env values.
fileBytes, err = ExpandEnvBytes(fileBytes)
if err != nil {
multiError = multierror.Append(
multiError, fmt.Errorf("failed to expand environment in file %s: %v", *agentOptionsTop.Path, err),
)
} else {
var pathAgentOptions BaseItem
if err := yaml.Unmarshal(fileBytes, &pathAgentOptions); err != nil {
return multierror.Append(
multiError, fmt.Errorf("failed to unmarshal agent options file %s: %v", *agentOptionsTop.Path, err),
)
}
if pathAgentOptions.Path != nil {
return multierror.Append(
multiError,
fmt.Errorf("nested paths are not supported: %s in %s", *pathAgentOptions.Path, *agentOptionsTop.Path),
)
}
var raw json.RawMessage
if err := yaml.Unmarshal(fileBytes, &raw); err != nil {
// This error is currently unreachable because we know the file is valid YAML when we checked for nested path
return multierror.Append(
multiError, fmt.Errorf("failed to unmarshal agent options file %s: %v", *agentOptionsTop.Path, err),
)
}
result.AgentOptions = &raw
}
}
}
return multiError
}
func parseControls(top map[string]json.RawMessage, result *GitOps, multiError *multierror.Error, yamlFilename string) *multierror.Error {
controlsRaw, ok := top["controls"]
if !ok {
// Nothing to do, return.
return multiError
}
var controlsTop GitOpsControls
if err := json.Unmarshal(controlsRaw, &controlsTop); err != nil {
return multierror.Append(multiError, fmt.Errorf("failed to unmarshal controls: %v", err))
}
controlsTop.Defined = true
controlsFilePath := yamlFilename
err := processControlsPathIfNeeded(controlsTop, result, &controlsFilePath)
if err != nil {
return multierror.Append(multiError, err)
}
controlsDir := filepath.Dir(controlsFilePath)
result.Controls.Scripts, err = resolveScriptPaths(result.Controls.Scripts, controlsDir)
if err != nil {
return multierror.Append(multiError, fmt.Errorf("failed to parse scripts list in %s: %v", controlsFilePath, err))
}
// Find Fleet secrets in scripts.
for _, script := range result.Controls.Scripts {
if script.Path == nil {
// This should never happen because we checked for missing paths above (with code added in https://github.com/fleetdm/fleet/pull/24639).
return multierror.Append(multiError, errors.New("controls.scripts.path is missing"))
}
fileBytes, err := os.ReadFile(*script.Path)
if err != nil {
return multierror.Append(multiError, fmt.Errorf("failed to read scripts file %s: %v", *script.Path, err))
}
err = LookupEnvSecrets(string(fileBytes), result.FleetSecrets)
if err != nil {
return multierror.Append(multiError, err)
}
}
// Find Fleet secrets in profiles
if result.Controls.MacOSSettings != nil {
// We are marshalling/unmarshalling to get the data into the fleet.MacOSSettings struct.
// This is inefficient, but it is more robust and less error-prone.
var macOSSettings fleet.MacOSSettings
data, err := json.Marshal(result.Controls.MacOSSettings)
if err != nil {
return multierror.Append(multiError, fmt.Errorf("failed to process controls.macos_settings: %v", err))
}
err = json.Unmarshal(data, &macOSSettings)
if err != nil {
return multierror.Append(multiError, fmt.Errorf("failed to process controls.macos_settings: %v", err))
}
for i := range macOSSettings.CustomSettings {
err := resolveAndUpdateProfilePathToAbsolute(controlsDir, &macOSSettings.CustomSettings[i], result)
if err != nil {
return multierror.Append(multiError, err)
}
}
// Since we already unmarshalled and updated the path, we need to update the result struct.
result.Controls.MacOSSettings = macOSSettings
}
if result.Controls.WindowsSettings != nil {
// We are marshalling/unmarshalling to get the data into the fleet.WindowsSettings struct.
// This is inefficient, but it is more robust and less error-prone.
var windowsSettings fleet.WindowsSettings
data, err := json.Marshal(result.Controls.WindowsSettings)
if err != nil {
return multierror.Append(multiError, fmt.Errorf("failed to process controls.windows_settings: %v", err))
}
err = json.Unmarshal(data, &windowsSettings)
if err != nil {
return multierror.Append(multiError, fmt.Errorf("failed to process controls.windows_settings: %v", err))
}
if windowsSettings.CustomSettings.Valid {
for i := range windowsSettings.CustomSettings.Value {
err := resolveAndUpdateProfilePathToAbsolute(controlsDir, &windowsSettings.CustomSettings.Value[i], result)
if err != nil {
return multierror.Append(multiError, err)
}
}
}
// Since we already unmarshalled and updated the path, we need to update the result struct.
result.Controls.WindowsSettings = windowsSettings
}
return multiError
}
func processControlsPathIfNeeded(controlsTop GitOpsControls, result *GitOps, controlsFilePath *string) error {
if controlsTop.Path == nil {
result.Controls = controlsTop
return nil
}
// There is a path attribute which points to the real controls section in a separate file, so we need to process that.
controlsFilePath = ptr.String(resolveApplyRelativePath(filepath.Dir(*controlsFilePath), *controlsTop.Path))
fileBytes, err := os.ReadFile(*controlsFilePath)
if err != nil {
return fmt.Errorf("failed to read controls file %s: %v", *controlsTop.Path, err)
}
// Replace $var and ${var} with env values.
fileBytes, err = ExpandEnvBytes(fileBytes)
if err != nil {
return fmt.Errorf("failed to expand environment in file %s: %v", *controlsTop.Path, err)
}
var pathControls GitOpsControls
if err := yaml.Unmarshal(fileBytes, &pathControls); err != nil {
return fmt.Errorf("failed to unmarshal controls file %s: %v", *controlsTop.Path, err)
}
if pathControls.Path != nil {
return fmt.Errorf("nested paths are not supported: %s in %s", *pathControls.Path, *controlsTop.Path)
}
pathControls.Defined = true
result.Controls = pathControls
return nil
}
func resolveAndUpdateProfilePathToAbsolute(controlsDir string, profile *fleet.MDMProfileSpec, result *GitOps) error {
resolvedPath := resolveApplyRelativePath(controlsDir, profile.Path)
// We switch to absolute path so that we don't have to keep track of the base directory.
// This is useful because controls section can come from either the global config file or the no-team file.
var err error
profile.Path, err = filepath.Abs(resolvedPath)
if err != nil {
return fmt.Errorf("failed to resolve profile path %s: %v", resolvedPath, err)
}
fileBytes, err := os.ReadFile(resolvedPath)
if err != nil {
return fmt.Errorf("failed to read profile file %s: %v", resolvedPath, err)
}
err = LookupEnvSecrets(string(fileBytes), result.FleetSecrets)
if err != nil {
return err
}
return nil
}
func resolveScriptPaths(input []BaseItem, baseDir string) ([]BaseItem, error) {
var resolved []BaseItem
for _, item := range input {
if item.Path == nil {
return nil, errors.New(`script entry was specified without a path; check for a stray "-" in your scripts list`)
}
resolvedPath := resolveApplyRelativePath(baseDir, *item.Path)
item.Path = &resolvedPath
resolved = append(resolved, item)
}
return resolved, nil
}
func parseLabels(top map[string]json.RawMessage, result *GitOps, baseDir string, multiError *multierror.Error) *multierror.Error {
labelsRaw, ok := top["labels"]
// This shouldn't happen as we check for the property earlier,
// but better safe than sorry.
if !ok {
return multiError
}
var labels []Label
if err := json.Unmarshal(labelsRaw, &labels); err != nil {
return multierror.Append(multiError, fmt.Errorf("failed to unmarshal labels: %v", err))
}
for _, item := range labels {
item := item
if item.Path == nil {
result.Labels = append(result.Labels, &item.LabelSpec)
} else {
fileBytes, err := os.ReadFile(resolveApplyRelativePath(baseDir, *item.Path))
if err != nil {
multiError = multierror.Append(multiError, fmt.Errorf("failed to read labels file %s: %v", *item.Path, err))
continue
}
// Replace $var and ${var} with env values.
fileBytes, err = ExpandEnvBytes(fileBytes)
if err != nil {
multiError = multierror.Append(
multiError, fmt.Errorf("failed to expand environment in file %s: %v", *item.Path, err),
)
} else {
var pathLabels []*Label
if err := yaml.Unmarshal(fileBytes, &pathLabels); err != nil {
multiError = multierror.Append(multiError, fmt.Errorf("failed to unmarshal labels file %s: %v", *item.Path, err))
continue
}
for _, pq := range pathLabels {
pq := pq
if pq != nil {
if pq.Path != nil {
multiError = multierror.Append(
multiError, fmt.Errorf("nested paths are not supported: %s in %s", *pq.Path, *item.Path),
)
} else {
result.Labels = append(result.Labels, &pq.LabelSpec)
}
}
}
}
}
}
// Make sure team name is correct and do additional validation
for _, l := range result.Labels {
if l.Name == "" {
multiError = multierror.Append(multiError, errors.New("name is required for each label"))
}
if l.Query == "" && len(l.Hosts) == 0 {
multiError = multierror.Append(multiError, errors.New("a SQL query or hosts list is required for each label"))
}
// Don't use non-ASCII
if !isASCII(l.Name) {
multiError = multierror.Append(multiError, fmt.Errorf("label name must be in ASCII: %s", l.Name))
}
}
duplicates := getDuplicateNames(
result.Labels, func(l *fleet.LabelSpec) string {
return l.Name
},
)
if len(duplicates) > 0 {
multiError = multierror.Append(multiError, fmt.Errorf("duplicate label names: %v", duplicates))
}
return multiError
}
func parsePolicies(top map[string]json.RawMessage, result *GitOps, baseDir string, multiError *multierror.Error) *multierror.Error {
policiesRaw, ok := top["policies"]
if !ok {
return multierror.Append(multiError, errors.New("'policies' key is required"))
}
var policies []Policy
if err := json.Unmarshal(policiesRaw, &policies); err != nil {
return multierror.Append(multiError, fmt.Errorf("failed to unmarshal policies: %v", err))
}
for _, item := range policies {
item := item
if item.Path == nil {
if err := parsePolicyInstallSoftware(baseDir, result.TeamName, &item, result.Software.Packages, result.Software.AppStoreApps); err != nil {
multiError = multierror.Append(multiError, fmt.Errorf("failed to parse policy install_software %q: %v", item.Name, err))
continue
}
if err := parsePolicyRunScript(baseDir, result.TeamName, &item, result.Controls.Scripts); err != nil {
multiError = multierror.Append(multiError, fmt.Errorf("failed to parse policy run_script %q: %v", item.Name, err))
continue
}
result.Policies = append(result.Policies, &item.GitOpsPolicySpec)
} else {
filePath := resolveApplyRelativePath(baseDir, *item.Path)
fileBytes, err := os.ReadFile(filePath)
if err != nil {
multiError = multierror.Append(multiError, fmt.Errorf("failed to read policies file %s: %v", *item.Path, err))
continue
}
// Replace $var and ${var} with env values.
fileBytes, err = ExpandEnvBytes(fileBytes)
if err != nil {
multiError = multierror.Append(
multiError, fmt.Errorf("failed to expand environment in file %s: %v", *item.Path, err),
)
} else {
var pathPolicies []*Policy
if err := yaml.Unmarshal(fileBytes, &pathPolicies); err != nil {
multiError = multierror.Append(multiError, fmt.Errorf("failed to unmarshal policies file %s: %v", *item.Path, err))
continue
}
for _, pp := range pathPolicies {
pp := pp
if pp != nil {
if pp.Path != nil {
multiError = multierror.Append(
multiError, fmt.Errorf("nested paths are not supported: %s in %s", *pp.Path, *item.Path),
)
} else {
if err := parsePolicyInstallSoftware(filepath.Dir(filePath), result.TeamName, pp, result.Software.Packages, result.Software.AppStoreApps); err != nil {
multiError = multierror.Append(multiError, fmt.Errorf("failed to parse policy install_software %q: %v", pp.Name, err))
continue
}
if err := parsePolicyRunScript(filepath.Dir(filePath), result.TeamName, pp, result.Controls.Scripts); err != nil {
multiError = multierror.Append(multiError, fmt.Errorf("failed to parse policy run_script %q: %v", pp.Name, err))
continue
}
result.Policies = append(result.Policies, &pp.GitOpsPolicySpec)
}
}
}
}
}
}
// Make sure team name is correct, and do additional validation
for _, item := range result.Policies {
if item.Name == "" {
multiError = multierror.Append(multiError, errors.New("policy name is required for each policy"))
} else {
item.Name = norm.NFC.String(item.Name)
}
if item.Query == "" {
multiError = multierror.Append(multiError, errors.New("policy query is required for each policy"))
}
if result.TeamName != nil {
item.Team = *result.TeamName
} else {
item.Team = ""
}
if item.CalendarEventsEnabled && result.IsNoTeam() {
multiError = multierror.Append(multiError, fmt.Errorf("calendar events are not supported on \"No team\" policies: %q", item.Name))
}
}
duplicates := getDuplicateNames(
result.Policies, func(p *GitOpsPolicySpec) string {
return p.Name
},
)
if len(duplicates) > 0 {
multiError = multierror.Append(multiError, fmt.Errorf("duplicate policy names: %v", duplicates))
}
return multiError
}
func parsePolicyRunScript(baseDir string, teamName *string, policy *Policy, scripts []BaseItem) error {
if policy.RunScript == nil {
policy.ScriptID = ptr.Uint(0) // unset the script
return nil
}
if policy.RunScript != nil && policy.RunScript.Path != "" && teamName == nil {
return errors.New("run_script can only be set on team policies")
}
if policy.RunScript.Path == "" {
return errors.New("empty run_script path")
}
scriptPath := resolveApplyRelativePath(baseDir, policy.RunScript.Path)
_, err := os.Stat(scriptPath)
if err != nil {
return fmt.Errorf("script file does not exist %q: %v", policy.RunScript.Path, err)
}
scriptOnTeamFound := false
for _, script := range scripts {
if scriptPath == *script.Path {
scriptOnTeamFound = true
break
}
}
if !scriptOnTeamFound {
if *teamName == noTeam {
return fmt.Errorf("policy script %s was not defined in controls in no-team.yml", scriptPath)
}
return fmt.Errorf("policy script %s was not defined in controls for %s", scriptPath, *teamName)
}
scriptName := filepath.Base(policy.RunScript.Path)
policy.RunScriptName = &scriptName
return nil
}
func parsePolicyInstallSoftware(baseDir string, teamName *string, policy *Policy, packages []*fleet.SoftwarePackageSpec, appStoreApps []*fleet.TeamSpecAppStoreApp) error {
if policy.InstallSoftware == nil {
policy.SoftwareTitleID = ptr.Uint(0) // unset the installer
return nil
}
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 != "" {
return errors.New("install_software must have only one of package_path or app_store_id")
}
if policy.InstallSoftware.PackagePath != "" {
fileBytes, err := os.ReadFile(resolveApplyRelativePath(baseDir, policy.InstallSoftware.PackagePath))
if err != nil {
return fmt.Errorf("failed to read install_software.package_path file %q: %v", policy.InstallSoftware.PackagePath, err)
}
var policyInstallSoftwareSpec fleet.SoftwarePackageSpec
if err := yaml.Unmarshal(fileBytes, &policyInstallSoftwareSpec); err != nil {
return fmt.Errorf("failed to unmarshal install_software.package_path file %s: %v", policy.InstallSoftware.PackagePath, err)
}
installerOnTeamFound := false
for _, pkg := range packages {
if pkg.URL == policyInstallSoftwareSpec.URL {
installerOnTeamFound = true
break
}
}
if !installerOnTeamFound {
return fmt.Errorf("install_software.package_path URL %s not found on team: %s", policyInstallSoftwareSpec.URL, policy.InstallSoftware.PackagePath)
}
policy.InstallSoftwareURL = policyInstallSoftwareSpec.URL
}
if policy.InstallSoftware.AppStoreID != "" {
appOnTeamFound := false
for _, app := range appStoreApps {
if app.AppStoreID == policy.InstallSoftware.AppStoreID {
appOnTeamFound = true
break
}
}
if !appOnTeamFound {
return fmt.Errorf("install_software.app_store_id %s not found on team %s", policy.InstallSoftware.AppStoreID, *teamName)
}
}
return nil
}
func parseQueries(top map[string]json.RawMessage, result *GitOps, baseDir string, logFn Logf, multiError *multierror.Error) *multierror.Error {
queriesRaw, ok := top["queries"]
if result.IsNoTeam() {
if ok {
logFn("[!] 'queries' is not supported for \"No team\". This key will be ignored.\n")
}
return multiError
} else if !ok {
return multierror.Append(multiError, errors.New("'queries' key is required"))
}
var queries []Query
if err := json.Unmarshal(queriesRaw, &queries); err != nil {
return multierror.Append(multiError, fmt.Errorf("failed to unmarshal queries: %v", err))
}
for _, item := range queries {
item := item
if item.Path == nil {
result.Queries = append(result.Queries, &item.QuerySpec)
} else {
fileBytes, err := os.ReadFile(resolveApplyRelativePath(baseDir, *item.Path))
if err != nil {
multiError = multierror.Append(multiError, fmt.Errorf("failed to read queries file %s: %v", *item.Path, err))
continue
}
// Replace $var and ${var} with env values.
fileBytes, err = ExpandEnvBytes(fileBytes)
if err != nil {
multiError = multierror.Append(
multiError, fmt.Errorf("failed to expand environment in file %s: %v", *item.Path, err),
)
} else {
var pathQueries []*Query
if err := yaml.Unmarshal(fileBytes, &pathQueries); err != nil {
multiError = multierror.Append(multiError, fmt.Errorf("failed to unmarshal queries file %s: %v", *item.Path, err))
continue
}
for _, pq := range pathQueries {
pq := pq
if pq != nil {
if pq.Path != nil {
multiError = multierror.Append(
multiError, fmt.Errorf("nested paths are not supported: %s in %s", *pq.Path, *item.Path),
)
} else {
result.Queries = append(result.Queries, &pq.QuerySpec)
}
}
}
}
}
}
// Make sure team name is correct and do additional validation
for _, q := range result.Queries {
if q.Name == "" {
multiError = multierror.Append(multiError, errors.New("query name is required for each query"))
}
if q.Query == "" {
multiError = multierror.Append(multiError, errors.New("query SQL query is required for each query"))
}
// Don't use non-ASCII
if !isASCII(q.Name) {
multiError = multierror.Append(multiError, fmt.Errorf("query name must be in ASCII: %s", q.Name))
}
if result.TeamName != nil {
q.TeamName = *result.TeamName
} else {
q.TeamName = ""
}
}
duplicates := getDuplicateNames(
result.Queries, func(q *fleet.QuerySpec) string {
return q.Name
},
)
if len(duplicates) > 0 {
multiError = multierror.Append(multiError, fmt.Errorf("duplicate query names: %v", duplicates))
}
return multiError
}
func parseSoftware(top map[string]json.RawMessage, result *GitOps, baseDir string, multiError *multierror.Error) *multierror.Error {
softwareRaw, ok := top["software"]
if result.global() {
if ok && string(softwareRaw) != "null" {
return multierror.Append(multiError, errors.New("'software' cannot be set on global file"))
}
} else if !ok {
return multierror.Append(multiError, errors.New("'software' is required"))
}
var software Software
if len(softwareRaw) > 0 {
if err := json.Unmarshal(softwareRaw, &software); err != nil {
var typeErr *json.UnmarshalTypeError
if errors.As(err, &typeErr) {
typeErrField := typeErr.Field
if typeErrField == "" {
// UnmarshalTypeError.Field is empty when trying to set an invalid type on the root node.
typeErrField = "software"
}
return multierror.Append(multiError, fmt.Errorf("Couldn't edit software. %q must be a %s, found %s", typeErrField, typeErr.Type.String(), typeErr.Value))
}
return multierror.Append(multiError, fmt.Errorf("failed to unmarshall softwarespec: %v", err))
}
}
for _, item := range software.AppStoreApps {
item := item
if item.AppStoreID == "" {
multiError = multierror.Append(multiError, errors.New("software app store id required"))
continue
}
if len(item.LabelsExcludeAny) > 0 && len(item.LabelsIncludeAny) > 0 {
multiError = multierror.Append(multiError, fmt.Errorf(`only one of "labels_exclude_any" or "labels_include_any" can be specified for app store app %q`, item.AppStoreID))
continue
}
result.Software.AppStoreApps = append(result.Software.AppStoreApps, &item)
}
for _, item := range software.Packages {
var softwarePackageSpec fleet.SoftwarePackageSpec
if item.Path != nil {
softwarePackageSpec.ReferencedYamlPath = resolveApplyRelativePath(baseDir, *item.Path)
fileBytes, err := os.ReadFile(softwarePackageSpec.ReferencedYamlPath)
if err != nil {
multiError = multierror.Append(multiError, fmt.Errorf("failed to read software package file %s: %v", *item.Path, err))
continue
}
// Replace $var and ${var} with env values.
fileBytes, err = ExpandEnvBytes(fileBytes)
if err != nil {
multiError = multierror.Append(multiError, fmt.Errorf("failed to expand environmet in file %s: %v", *item.Path, err))
continue
}
if err := yaml.Unmarshal(fileBytes, &softwarePackageSpec); err != nil {
multiError = multierror.Append(multiError, fmt.Errorf("failed to unmarshal software package file %s: %v", *item.Path, err))
continue
}
softwarePackageSpec = resolveSoftwarePackagePaths(filepath.Dir(softwarePackageSpec.ReferencedYamlPath), softwarePackageSpec)
} else {
softwarePackageSpec = resolveSoftwarePackagePaths(baseDir, item.SoftwarePackageSpec)
}
if softwarePackageSpec.InstallScript.Path != "" {
if err := gatherFileSecrets(result, softwarePackageSpec.InstallScript.Path); err != nil {
multiError = multierror.Append(multiError, err)
continue
}
}
if softwarePackageSpec.PostInstallScript.Path != "" {
if err := gatherFileSecrets(result, softwarePackageSpec.PostInstallScript.Path); err != nil {
multiError = multierror.Append(multiError, err)
continue
}
}
if softwarePackageSpec.UninstallScript.Path != "" {
if err := gatherFileSecrets(result, softwarePackageSpec.UninstallScript.Path); err != nil {
multiError = multierror.Append(multiError, err)
continue
}
}
if softwarePackageSpec.URL == "" {
multiError = multierror.Append(multiError, errors.New("software URL is required"))
continue
}
if len(softwarePackageSpec.URL) > fleet.SoftwareInstallerURLMaxLength {
multiError = multierror.Append(multiError, fmt.Errorf("software URL %q is too long, must be %d characters or less", softwarePackageSpec.URL, fleet.SoftwareInstallerURLMaxLength))
continue
}
if len(softwarePackageSpec.LabelsExcludeAny) > 0 && len(softwarePackageSpec.LabelsIncludeAny) > 0 {
multiError = multierror.Append(multiError, fmt.Errorf(`only one of "labels_exclude_any" or "labels_include_any" can be specified for software URL %q`, softwarePackageSpec.URL))
continue
}
result.Software.Packages = append(result.Software.Packages, &softwarePackageSpec)
}
return multiError
}
func gatherFileSecrets(result *GitOps, filePath string) error {
fileBytes, err := os.ReadFile(filePath)
if err != nil {
return fmt.Errorf("failed to read file %s: %w", filePath, err)
}
err = LookupEnvSecrets(string(fileBytes), result.FleetSecrets)
if err != nil {
return fmt.Errorf("failed to lookup environment secrets for %s: %w", filePath, err)
}
return nil
}
func resolveSoftwarePackagePaths(baseDir string, softwareSpec fleet.SoftwarePackageSpec) fleet.SoftwarePackageSpec {
if softwareSpec.PreInstallQuery.Path != "" {
softwareSpec.PreInstallQuery.Path = resolveApplyRelativePath(baseDir, softwareSpec.PreInstallQuery.Path)
}
if softwareSpec.InstallScript.Path != "" {
softwareSpec.InstallScript.Path = resolveApplyRelativePath(baseDir, softwareSpec.InstallScript.Path)
}
if softwareSpec.PostInstallScript.Path != "" {
softwareSpec.PostInstallScript.Path = resolveApplyRelativePath(baseDir, softwareSpec.PostInstallScript.Path)
}
if softwareSpec.UninstallScript.Path != "" {
softwareSpec.UninstallScript.Path = resolveApplyRelativePath(baseDir, softwareSpec.UninstallScript.Path)
}
return softwareSpec
}
func getDuplicateNames[T any](slice []T, getComparableString func(T) string) []string {
// We are using the allKeys map as a set here. True means the item is a duplicate.
allKeys := make(map[string]bool)
var duplicates []string
for _, item := range slice {
name := getComparableString(item)
if isDuplicate, exists := allKeys[name]; exists {
// If this name hasn't already been marked as a duplicate.
if !isDuplicate {
duplicates = append(duplicates, name)
}
allKeys[name] = true
} else {
allKeys[name] = false
}
}
return duplicates
}
func isASCII(s string) bool {
for _, c := range s {
if c > unicode.MaxASCII {
return false
}
}
return true
}
// resolves the paths to an absolute path relative to the baseDir, which should
// be the path of the YAML file where the relative paths were specified. If the
// path is already absolute, it is left untouched.
func resolveApplyRelativePath(baseDir string, path string) string {
if baseDir == "" || filepath.IsAbs(path) {
return path
}
return filepath.Join(baseDir, path)
}