<!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> **Related issue:** For #39344 # Details This PR builds on the previous PR (https://github.com/fleetdm/fleet/pull/39847) which added `renameto` tags to certain API parameters to mark them as deprecated. How this is used: ### In requests * When decoding requests, log a warning if a `json` or `query` param is used that has a `renameto` tag, e.g. if a `team_id` param is sent but the related struct has `renameto:"fleet_id"` in it. * If the `renamedto` version (e.g. `fleet_id`) is sent in the request, rewrite it to the deprecated name so that it can be unmarshalled into the struct * If both versions are sent (e.g. `team_id` AND `fleet_id`), throw an error and quit * URLs with deprecated terms have new aliases using `WithAltPaths` -- warning on using old URLSs a TODO that will be handled in a subsequent PR. ### In responses * Output _both_ the deprecated and new names for fields that have `renameto` tags, so that we don't break existing workflows expecting the old keys. Uses a shared `DuplicateJSONKeys` to do the duplication. * Most API responses are handled in `EncodeCommonResponse`. Exceptions are activities, failing policy webhooks and the streaming "list hosts" endpoints which call the function directly. ### In fleetctl * Similar to requests, log warnings when deprecated keys are used and rewrite the new keys internally so that they can be unmarshalled. * For `fleetctl get` and `fleetctl generate-gitops`, _only_ output the new names * The set of keys to replace is hardcoded in `fleetctl` rather than being dynamically generated as it is for API endpoints. Given the mixture of typed and untyped data and the level of nesting, dynamic map generation was very fragile and error-prone. ### Performance considerations * The biggest performance hit is the addition of the JSON key rewriter to the request pipeline. The rewriter buffers the entire request into memory before eventually passing it to the decoder than unmarshals the data into structs. I tried implementing this as a true streaming rewriter but encountered issues where the request would hang if the downstream reader (the decoder) encountered any errors. It's possible we could implement this in a streaming fashion if we replace our [current request decoder](https://github.com/fleetdm/fleet/blob/da43bf8371695382c4af0972d5da456c6b94bdaf/server/service/endpoint_utils.go#L108) with the v2 version, which is a bigger change requiring more thoughtful discussion in the engineering team. As it stands, memory usage for requests with deprecated fields will double while the request is being decoded. * The "alias rules" used to determine the old and new key names are cached per struct type and for most endpoints are generated on server start, so no performance impact is expected. * Some `fleetctl` commands may have an extra unmarshal/marshal step but as these are user-initiated and not performed in tight loops, the impact should be minimal. ### TODO * Log deprecation warnings when old URLs like "/fleet/teams" are used * Update API fields that the front-end uses to avoid deprecation warnings * Update `fleetctl apply` to accept/return `kind: fleet` rather than `kind: team` * Find/update any fleet server config vars with old language * Update all error messages that use old language # Checklist for submitter If some of the following don't apply, delete the relevant line. - [X] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files) for more information. - [X] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements) ## Testing - [X] Added/updated automated tests - [X] Where appropriate, [automated tests simulate multiple hosts and test for host isolation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/reference/patterns-backend.md#unit-testing) (updates to one hosts's records do not affect another) - [X] QA'd all new/changed functionality manually * Clicking around the front-end, no broken pages due to request ingestion errors or bad responses * Looking in network tab to verify that responses have both the old and new keys * Running `fleetctl generate-gitops` and verifying that the output looks correct and can be ingested by `fleetctl gitops` * Running `fleetctl get` and `fleetctl apply` --------- Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com>
1557 lines
58 KiB
Go
1557 lines
58 KiB
Go
package spec
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"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"
|
|
)
|
|
|
|
const LabelAPIGlobalTeamName = "global"
|
|
|
|
// LabelChangesSummary carries extra context of the labels operations for a config.
|
|
type LabelChangesSummary struct {
|
|
LabelsToUpdate []string
|
|
LabelsToAdd []string
|
|
LabelsToRemove []string
|
|
LabelsMovements []LabelMovement
|
|
}
|
|
|
|
func NewLabelChangesSummary(changes []LabelChange, moves []LabelMovement) LabelChangesSummary {
|
|
r := LabelChangesSummary{
|
|
LabelsMovements: moves,
|
|
}
|
|
|
|
lookUp := make(map[string]any)
|
|
for _, m := range moves {
|
|
lookUp[m.Name] = nil
|
|
}
|
|
|
|
for _, change := range changes {
|
|
if _, ok := lookUp[change.Name]; ok {
|
|
continue
|
|
}
|
|
switch change.Op {
|
|
case "+":
|
|
|
|
r.LabelsToAdd = append(r.LabelsToAdd, change.Name)
|
|
case "-":
|
|
r.LabelsToRemove = append(r.LabelsToRemove, change.Name)
|
|
case "=":
|
|
r.LabelsToUpdate = append(r.LabelsToUpdate, change.Name)
|
|
}
|
|
}
|
|
return r
|
|
}
|
|
|
|
// LabelMovement specifies a label movement, a label is moved if its removed from one team and added to another
|
|
type LabelMovement struct {
|
|
FromTeamName string // Source team name
|
|
ToTeamName string // Dest. team name
|
|
Name string // The globally unique label name
|
|
}
|
|
|
|
// LabelChange used for keeping track of label operations
|
|
type LabelChange struct {
|
|
Name string // The globally unique label name
|
|
Op string // What operation to perform on the label. +:add, -:remove, =:no-op
|
|
TeamName string // The team this label belongs to.
|
|
FileName string // The filename that contains the label change
|
|
}
|
|
|
|
type ParseTypeError struct {
|
|
Filename string // The name of the file being parsed
|
|
Keys []string // The complete path to the field
|
|
Field string // The field we tried to assign to
|
|
Type string // The type that we want to have
|
|
Value string // The type of the value that we received
|
|
err error // The original error
|
|
}
|
|
|
|
func (e *ParseTypeError) Error() string {
|
|
var keyPath []string
|
|
keyPath = append(keyPath, e.Keys...)
|
|
if e.Field != "" {
|
|
var clearFields []string
|
|
fields := strings.Split(e.Field, ".")
|
|
fieldcheck:
|
|
for _, field := range fields {
|
|
for _, r := range field {
|
|
// Any field name that contains an upper case letter is probably an embedded struct,
|
|
// remove it from the field path to reduce end user confusion
|
|
if unicode.IsUpper(r) {
|
|
continue fieldcheck
|
|
}
|
|
}
|
|
clearFields = append(clearFields, field)
|
|
}
|
|
keyPath = append(keyPath, strings.Join(clearFields, "."))
|
|
}
|
|
return fmt.Sprintf("Couldn't edit \"%s\" at \"%s\", expected type %s but got %s", e.Filename, strings.Join(keyPath, "."), e.Type, e.Value)
|
|
}
|
|
|
|
func (e *ParseTypeError) Unwrap() error {
|
|
return e.err
|
|
}
|
|
|
|
func MaybeParseTypeError(filename string, keysPath []string, err error) error {
|
|
unmarshallErr := &json.UnmarshalTypeError{}
|
|
if errors.As(err, &unmarshallErr) {
|
|
return &ParseTypeError{
|
|
Filename: filename,
|
|
Keys: keysPath,
|
|
Field: unmarshallErr.Field,
|
|
Type: unmarshallErr.Type.String(),
|
|
Value: unmarshallErr.Value,
|
|
err: err,
|
|
}
|
|
}
|
|
|
|
return fmt.Errorf("failed to unmarshal file \"%s\" key \"%s\": %w", filename, strings.Join(keysPath, "."), err)
|
|
}
|
|
|
|
// YamlUnmarshal unmarshals YAML bytes into JSON and then into the output struct. We have to do this
|
|
// because the yaml package stringifys the JSON parsing error before returning it, so we can't
|
|
// extract field information to produce a helpful error for users.
|
|
func YamlUnmarshal(yamlBytes []byte, out any) error {
|
|
jsonBytes, err := yaml.YAMLToJSON(yamlBytes)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to unmarshal YAML to JSON: %w", err)
|
|
}
|
|
if err := json.Unmarshal(jsonBytes, out); err != nil {
|
|
return fmt.Errorf("failed to unmarshal JSON bytes: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
type BaseItem struct {
|
|
Path *string `json:"path"`
|
|
}
|
|
|
|
type GitOpsControls struct {
|
|
BaseItem
|
|
MacOSUpdates any `json:"macos_updates"`
|
|
IOSUpdates any `json:"ios_updates"`
|
|
IPadOSUpdates any `json:"ipados_updates"`
|
|
MacOSSettings any `json:"macos_settings"`
|
|
MacOSSetup *fleet.MacOSSetup `json:"macos_setup"`
|
|
MacOSMigration any `json:"macos_migration"`
|
|
|
|
WindowsUpdates any `json:"windows_updates"`
|
|
WindowsSettings any `json:"windows_settings"`
|
|
WindowsEnabledAndConfigured any `json:"windows_enabled_and_configured"`
|
|
WindowsMigrationEnabled any `json:"windows_migration_enabled"`
|
|
EnableTurnOnWindowsMDMManually any `json:"enable_turn_on_windows_mdm_manually"`
|
|
WindowsEntraTenantIDs any `json:"windows_entra_tenant_ids"`
|
|
|
|
AndroidEnabledAndConfigured any `json:"android_enabled_and_configured"`
|
|
AndroidSettings any `json:"android_settings"`
|
|
|
|
EnableDiskEncryption any `json:"enable_disk_encryption"`
|
|
RequireBitLockerPIN any `json:"windows_require_bitlocker_pin,omitempty"`
|
|
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 ||
|
|
c.AndroidEnabledAndConfigured != nil || c.AndroidSettings != nil
|
|
}
|
|
|
|
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"`
|
|
HashSHA256 string `json:"hash_sha256"`
|
|
}
|
|
|
|
type Query struct {
|
|
BaseItem
|
|
fleet.QuerySpec
|
|
}
|
|
|
|
type Label struct {
|
|
BaseItem
|
|
fleet.LabelSpec
|
|
}
|
|
|
|
type SoftwarePackage struct {
|
|
BaseItem
|
|
fleet.SoftwarePackageSpec
|
|
}
|
|
|
|
func (spec SoftwarePackage) HydrateToPackageLevel(packageLevel fleet.SoftwarePackageSpec) (fleet.SoftwarePackageSpec, error) {
|
|
if spec.Icon.Path != "" || spec.InstallScript.Path != "" || spec.UninstallScript.Path != "" ||
|
|
spec.PostInstallScript.Path != "" || spec.URL != "" || spec.SHA256 != "" || spec.PreInstallQuery.Path != "" {
|
|
return packageLevel, fmt.Errorf("the software package defined in %s must not have icons, scripts, queries, URL, or hash specified at the team level", *spec.Path)
|
|
}
|
|
|
|
packageLevel.Categories = spec.Categories
|
|
packageLevel.LabelsIncludeAny = spec.LabelsIncludeAny
|
|
packageLevel.LabelsExcludeAny = spec.LabelsExcludeAny
|
|
packageLevel.InstallDuringSetup = spec.InstallDuringSetup
|
|
packageLevel.SelfService = spec.SelfService
|
|
|
|
// This will only override display name set at path: path/to/software.yml level
|
|
// if display_name is specified at the team level yml
|
|
if spec.DisplayName != "" {
|
|
packageLevel.DisplayName = spec.DisplayName
|
|
}
|
|
|
|
return packageLevel, nil
|
|
}
|
|
|
|
type Software struct {
|
|
Packages []SoftwarePackage `json:"packages"`
|
|
AppStoreApps []fleet.TeamSpecAppStoreApp `json:"app_store_apps"`
|
|
FleetMaintainedApps []fleet.MaintainedAppSpec `json:"fleet_maintained_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
|
|
LabelChangesSummary LabelChangesSummary
|
|
|
|
// 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
|
|
FleetMaintainedApps []*fleet.MaintainedAppSpec
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
// First unmarshal to map[string]any for deprecation handling
|
|
var rawData map[string]any
|
|
if err := yaml.Unmarshal(b, &rawData); err != nil {
|
|
return nil, fmt.Errorf("failed to unmarshal file %w: \n", err)
|
|
}
|
|
|
|
// Apply deprecated key mappings (e.g., team_settings -> settings, queries -> reports)
|
|
if err := ApplyDeprecatedKeyMappings(rawData, logFn); err != nil {
|
|
return nil, fmt.Errorf("failed to process deprecated keys in file %s: %w", filePath, err)
|
|
}
|
|
|
|
// Re-marshal and unmarshal to map[string]json.RawMessage for existing parsing logic
|
|
updatedBytes, err := json.Marshal(rawData)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to re-marshal file %s: %w", filePath, err)
|
|
}
|
|
var top map[string]json.RawMessage
|
|
if err := json.Unmarshal(updatedBytes, &top); err != nil {
|
|
return nil, fmt.Errorf("failed to unmarshal file %s: %w", filePath, err)
|
|
}
|
|
|
|
var multiError *multierror.Error
|
|
result := &GitOps{}
|
|
result.FleetSecrets = make(map[string]string)
|
|
|
|
topKeys := []string{"name", "settings", "org_settings", "agent_options", "controls", "policies", "reports", "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 fleet settings file
|
|
teamRaw, teamOk := top["name"]
|
|
settingsRaw, settingsOk := top["settings"]
|
|
orgSettingsRaw, orgOk := top["org_settings"]
|
|
switch {
|
|
case orgOk:
|
|
if teamOk || settingsOk {
|
|
multiError = multierror.Append(multiError, errors.New("'org_settings' cannot be used with 'name', 'settings'"))
|
|
} else {
|
|
multiError = parseOrgSettings(orgSettingsRaw, result, baseDir, filePath, multiError)
|
|
}
|
|
case teamOk:
|
|
multiError = parseName(teamRaw, result, filePath, multiError)
|
|
if result.IsNoTeam() {
|
|
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))
|
|
}
|
|
// For No Team, we allow settings but only process webhook_settings from it
|
|
if settingsOk {
|
|
multiError = parseNoTeamSettings(settingsRaw, result, filePath, multiError)
|
|
}
|
|
} else {
|
|
if !settingsOk {
|
|
multiError = multierror.Append(multiError, errors.New("'settings' is required when 'name' is provided"))
|
|
} else {
|
|
multiError = parseTeamSettings(settingsRaw, result, baseDir, filePath, multiError)
|
|
}
|
|
}
|
|
default:
|
|
multiError = multierror.Append(multiError, errors.New("either 'org_settings' or 'name' and '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.
|
|
if _, ok := top["labels"]; !ok {
|
|
result.Labels = make([]*fleet.LabelSpec, 0)
|
|
} else {
|
|
multiError = parseLabels(top, result, baseDir, filePath, multiError)
|
|
}
|
|
// Get other top-level entities.
|
|
multiError = parseControls(top, result, multiError, filePath)
|
|
multiError = parseAgentOptions(top, result, baseDir, logFn, filePath, multiError)
|
|
multiError = parseQueries(top, result, baseDir, logFn, filePath, multiError)
|
|
|
|
if appConfig != nil && appConfig.License.IsPremium() {
|
|
multiError = parseSoftware(top, result, baseDir, filePath, multiError)
|
|
}
|
|
|
|
// Policies can reference software installers and scripts, thus we parse them after parseSoftware and parseControls.
|
|
multiError = parsePolicies(top, result, baseDir, filePath, multiError)
|
|
|
|
return result, multiError.ErrorOrNil()
|
|
}
|
|
|
|
func parseName(raw json.RawMessage, result *GitOps, filePath string, multiError *multierror.Error) *multierror.Error {
|
|
if err := json.Unmarshal(raw, &result.TeamName); err != nil {
|
|
return multierror.Append(multiError, MaybeParseTypeError(filePath, []string{"name"}, 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)
|
|
}
|
|
|
|
func (g *GitOps) CoercedTeamName() string {
|
|
if g.global() {
|
|
return LabelAPIGlobalTeamName
|
|
}
|
|
return *g.TeamName
|
|
}
|
|
|
|
const noTeam = "No team"
|
|
|
|
func parseOrgSettings(raw json.RawMessage, result *GitOps, baseDir string, filePath string, multiError *multierror.Error) *multierror.Error {
|
|
var orgSettingsTop BaseItem
|
|
if err := json.Unmarshal(raw, &orgSettingsTop); err != nil {
|
|
return multierror.Append(multiError, MaybeParseTypeError(filePath, []string{"org_settings"}, 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 := YamlUnmarshal(fileBytes, &pathOrgSettings); err != nil {
|
|
noError = false
|
|
multiError = multierror.Append(
|
|
multiError, MaybeParseTypeError(*orgSettingsTop.Path, []string{"org_settings"}, 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 := YamlUnmarshal(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, MaybeParseTypeError(filePath, []string{"org_settings"}, 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, filePath string, multiError *multierror.Error) *multierror.Error {
|
|
var teamSettingsTop BaseItem
|
|
if err := json.Unmarshal(raw, &teamSettingsTop); err != nil {
|
|
return multierror.Append(multiError, MaybeParseTypeError(filePath, []string{"settings"}, 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 := YamlUnmarshal(fileBytes, &pathTeamSettings); err != nil {
|
|
noError = false
|
|
multiError = multierror.Append(
|
|
multiError, MaybeParseTypeError(*teamSettingsTop.Path, []string{"settings"}, 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 := YamlUnmarshal(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, MaybeParseTypeError(filePath, []string{"settings"}, err))
|
|
} else {
|
|
multiError = parseSecrets(result, multiError)
|
|
// Validate webhook settings for regular teams
|
|
multiError = validateTeamWebhookSettings(result.TeamSettings, multiError)
|
|
}
|
|
}
|
|
return multiError
|
|
}
|
|
|
|
// validateTeamWebhookSettings validates webhook settings for regular teams
|
|
func validateTeamWebhookSettings(teamSettings map[string]any, multiError *multierror.Error) *multierror.Error {
|
|
if webhookSettings, hasWebhook := teamSettings["webhook_settings"]; hasWebhook && webhookSettings != nil {
|
|
webhookMap, ok := webhookSettings.(map[string]any)
|
|
if !ok {
|
|
return multierror.Append(multiError, errors.New("'settings.webhook_settings' must be an object or null"))
|
|
}
|
|
|
|
// Validate failing_policies_webhook if present
|
|
if fpw, hasFPW := webhookMap["failing_policies_webhook"]; hasFPW && fpw != nil {
|
|
fpwMap, ok := fpw.(map[string]any)
|
|
if !ok {
|
|
multiError = multierror.Append(multiError, errors.New("'settings.webhook_settings.failing_policies_webhook' must be an object or null"))
|
|
} else {
|
|
// Validate failing_policies_webhook structure
|
|
if err := validateFailingPoliciesWebhook(fpwMap, "settings.webhook_settings.failing_policies_webhook"); err != nil {
|
|
multiError = multierror.Append(multiError, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Could add validation for other webhook types here in the future
|
|
// e.g., host_status_webhook, vulnerabilities_webhook, etc.
|
|
}
|
|
return multiError
|
|
}
|
|
|
|
// validateFailingPoliciesWebhook validates the failing_policies_webhook configuration.
|
|
// It ensures policy_ids is an array if present.
|
|
func validateFailingPoliciesWebhook(fpwMap map[string]any, keyPath string) error {
|
|
// Validate policy_ids is an array if present
|
|
if policyIDs, hasPolicyIDs := fpwMap["policy_ids"]; hasPolicyIDs && policyIDs != nil {
|
|
// Check if it's an array
|
|
_, isArray := policyIDs.([]any)
|
|
if !isArray {
|
|
return fmt.Errorf("'%s.policy_ids' must be an array, got %T", keyPath, policyIDs)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// parseNoTeamSettings parses settings for "No Team" files, but only processes webhook_settings
|
|
func parseNoTeamSettings(raw json.RawMessage, result *GitOps, filePath string, multiError *multierror.Error) *multierror.Error {
|
|
// Parse the raw JSON into a map to extract only webhook_settings
|
|
var teamSettingsMap map[string]interface{}
|
|
if err := json.Unmarshal(raw, &teamSettingsMap); err != nil {
|
|
return multierror.Append(multiError, MaybeParseTypeError(filePath, []string{"settings"}, err))
|
|
}
|
|
|
|
// For No Team, only webhook_settings is allowed in settings
|
|
// Jira/Zendesk integrations are not supported in gitops: https://github.com/fleetdm/fleet/issues/20287
|
|
// Check for any other keys and error if found
|
|
for key := range teamSettingsMap {
|
|
if key != "webhook_settings" {
|
|
multiError = multierror.Append(multiError,
|
|
fmt.Errorf("unsupported settings option '%s' for 'No team' - only 'webhook_settings' is allowed", key))
|
|
}
|
|
}
|
|
|
|
// Initialize TeamSettings if nil
|
|
if result.TeamSettings == nil {
|
|
result.TeamSettings = make(map[string]interface{})
|
|
}
|
|
|
|
// For No Team, we only care about webhook_settings
|
|
if webhookRaw, ok := teamSettingsMap["webhook_settings"]; ok {
|
|
// Handle null webhook_settings (which means clear webhook settings)
|
|
if webhookRaw == nil {
|
|
// Store as nil to indicate webhook settings should be cleared
|
|
result.TeamSettings["webhook_settings"] = nil
|
|
} else {
|
|
webhookMap, ok := webhookRaw.(map[string]any)
|
|
if !ok {
|
|
return multierror.Append(multiError, errors.New("'settings.webhook_settings' must be an object or null"))
|
|
}
|
|
for key := range webhookMap {
|
|
if key != "failing_policies_webhook" {
|
|
multiError = multierror.Append(multiError,
|
|
fmt.Errorf("unsupported webhook_settings option '%s' for 'No team'; only 'failing_policies_webhook' is allowed", key))
|
|
}
|
|
}
|
|
// If present, ensure failing_policies_webhook is an object or null
|
|
if fpw, ok := webhookMap["failing_policies_webhook"]; ok && fpw != nil {
|
|
fpwMap, ok := fpw.(map[string]any)
|
|
if !ok {
|
|
multiError = multierror.Append(multiError, errors.New("'settings.webhook_settings.failing_policies_webhook' must be an object or null"))
|
|
} else {
|
|
// Validate failing_policies_webhook structure
|
|
if err := validateFailingPoliciesWebhook(fpwMap, "settings.webhook_settings.failing_policies_webhook"); err != nil {
|
|
multiError = multierror.Append(multiError, err)
|
|
}
|
|
}
|
|
}
|
|
// Store the webhook settings for later processing
|
|
result.TeamSettings["webhook_settings"] = webhookMap
|
|
}
|
|
}
|
|
|
|
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("'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, filePath string, 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, MaybeParseTypeError(filePath, []string{"agent_options"}, 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 := YamlUnmarshal(fileBytes, &pathAgentOptions); err != nil {
|
|
return multierror.Append(
|
|
multiError, MaybeParseTypeError(*agentOptionsTop.Path, []string{"agent_options"}, 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 := YamlUnmarshal(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, MaybeParseTypeError(*agentOptionsTop.Path, []string{"agent_options"}, 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, MaybeParseTypeError(yamlFilename, []string{"controls"}, 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 the Fleet Secrets in the macos setup script file
|
|
if result.Controls.MacOSSetup != nil {
|
|
if result.Controls.MacOSSetup.Script.Set {
|
|
startupScriptPath := resolveApplyRelativePath(controlsDir, result.Controls.MacOSSetup.Script.Value)
|
|
fileBytes, err := os.ReadFile(startupScriptPath)
|
|
if err != nil {
|
|
return multierror.Append(multiError, fmt.Errorf("failed to read macos_setup script file %s: %v", startupScriptPath, 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, MaybeParseTypeError(controlsFilePath, []string{"controls", "macos_settings"}, 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, MaybeParseTypeError(controlsFilePath, []string{"controls", "windows_settings"}, 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
|
|
}
|
|
|
|
if result.Controls.AndroidSettings != nil {
|
|
// We are marshalling/unmarshalling to get the data into the fleet.AndroidSettings struct.
|
|
// This is inefficient, but it is more robust and less error-prone.
|
|
var androidSettings fleet.AndroidSettings
|
|
data, err := json.Marshal(result.Controls.AndroidSettings)
|
|
if err != nil {
|
|
return multierror.Append(multiError, fmt.Errorf("failed to process controls.android_settings: %v", err))
|
|
}
|
|
err = json.Unmarshal(data, &androidSettings)
|
|
if err != nil {
|
|
return multierror.Append(multiError, MaybeParseTypeError(controlsFilePath, []string{"controls", "android_settings"}, err))
|
|
}
|
|
|
|
if androidSettings.CustomSettings.Valid {
|
|
for i := range androidSettings.CustomSettings.Value {
|
|
err := resolveAndUpdateProfilePathToAbsolute(controlsDir, &androidSettings.CustomSettings.Value[i], result)
|
|
if err != nil {
|
|
return multierror.Append(multiError, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
if androidSettings.Certificates.Valid {
|
|
for i, cert := range androidSettings.Certificates.Value {
|
|
if cert.Name == "" {
|
|
multiError = multierror.Append(multiError, fmt.Errorf("android_settings.certificates[%d]: name is required", i))
|
|
}
|
|
if cert.CertificateAuthorityName == "" {
|
|
multiError = multierror.Append(multiError, fmt.Errorf("android_settings.certificates[%d]: certificate_authority_name is required", i))
|
|
}
|
|
if cert.SubjectName == "" {
|
|
multiError = multierror.Append(multiError, fmt.Errorf("android_settings.certificates[%d]: subject_name is required", i))
|
|
}
|
|
}
|
|
}
|
|
|
|
// Since we already unmarshalled and updated the path, we need to update the result struct.
|
|
result.Controls.AndroidSettings = androidSettings
|
|
}
|
|
|
|
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 := YamlUnmarshal(fileBytes, &pathControls); err != nil {
|
|
return MaybeParseTypeError(*controlsTop.Path, []string{"controls"}, 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, filePath 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, MaybeParseTypeError(filePath, []string{"labels"}, err))
|
|
}
|
|
for _, item := range labels {
|
|
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 := YamlUnmarshal(fileBytes, &pathLabels); err != nil {
|
|
multiError = multierror.Append(multiError, MaybeParseTypeError(*item.Path, []string{"labels"}, 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.LabelMembershipType != fleet.LabelMembershipTypeManual && l.Query == "" && l.HostVitalsCriteria == nil {
|
|
multiError = multierror.Append(multiError, errors.New("a SQL query or host vitals criteria is required for each non-manual label"))
|
|
}
|
|
|
|
// Manual labels can have empty hosts lists, just make sure we initialize the empty list
|
|
if l.LabelMembershipType == fleet.LabelMembershipTypeManual && l.Hosts == nil {
|
|
l.Hosts = []string{}
|
|
}
|
|
// Don't use non-ASCII
|
|
if !isASCII(l.Name) {
|
|
multiError = multierror.Append(multiError, fmt.Errorf("label name must be in ASCII: %s", l.Name))
|
|
}
|
|
// Check that host vitals criteria is valid
|
|
if l.HostVitalsCriteria != nil {
|
|
criteriaJson, err := json.Marshal(l.HostVitalsCriteria)
|
|
if err != nil {
|
|
multiError = multierror.Append(multiError, fmt.Errorf("failed to marshal host vitals criteria for label %s: %v", l.Name, err))
|
|
continue
|
|
}
|
|
label := fleet.Label{
|
|
Name: l.Name,
|
|
HostVitalsCriteria: ptr.RawMessage(criteriaJson),
|
|
}
|
|
if _, _, err := label.CalculateHostVitalsQuery(); err != nil {
|
|
multiError = multierror.Append(multiError, fmt.Errorf("invalid host vitals criteria for label %s: %v", l.Name, err))
|
|
}
|
|
}
|
|
}
|
|
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, filePath 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, MaybeParseTypeError(filePath, []string{"policies"}, err))
|
|
}
|
|
for _, item := range policies {
|
|
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 := YamlUnmarshal(fileBytes, &pathPolicies); err != nil {
|
|
multiError = multierror.Append(multiError, MaybeParseTypeError(*item.Path, []string{"policies"}, 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 == "" && 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")
|
|
}
|
|
|
|
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 := YamlUnmarshal(fileBytes, &policyInstallSoftwareSpec); err != nil {
|
|
// see if the issue is that a package path was passed in that references multiple packages
|
|
var multiplePackages []fleet.SoftwarePackageSpec
|
|
if err := YamlUnmarshal(fileBytes, &multiplePackages); err != nil || len(multiplePackages) == 0 {
|
|
return fmt.Errorf("file %q does not contain a valid software package definition", policy.InstallSoftware.PackagePath)
|
|
}
|
|
|
|
if len(multiplePackages) > 1 {
|
|
return fmt.Errorf("file %q contains multiple packages, so cannot be used as a target for policy automation", policy.InstallSoftware.PackagePath)
|
|
}
|
|
|
|
policyInstallSoftwareSpec = multiplePackages[0]
|
|
}
|
|
installerOnTeamFound := false
|
|
for _, pkg := range packages {
|
|
if (pkg.URL != "" && pkg.URL == policyInstallSoftwareSpec.URL) || (pkg.SHA256 != "" && pkg.SHA256 == policyInstallSoftwareSpec.SHA256) {
|
|
installerOnTeamFound = true
|
|
break
|
|
}
|
|
}
|
|
if !installerOnTeamFound {
|
|
if policyInstallSoftwareSpec.URL != "" {
|
|
return fmt.Errorf("install_software.package_path URL %s not found on team: %s", policyInstallSoftwareSpec.URL, policy.InstallSoftware.PackagePath)
|
|
}
|
|
return fmt.Errorf("install_software.package_path SHA256 %s not found on team: %s", policyInstallSoftwareSpec.SHA256, policy.InstallSoftware.PackagePath)
|
|
}
|
|
|
|
policy.InstallSoftwareURL = policyInstallSoftwareSpec.URL
|
|
policy.InstallSoftware.HashSHA256 = policyInstallSoftwareSpec.SHA256
|
|
}
|
|
|
|
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, filePath string, multiError *multierror.Error) *multierror.Error {
|
|
reportsRaw, ok := top["reports"]
|
|
if result.IsNoTeam() {
|
|
if ok {
|
|
logFn("[!] 'reports' is not supported for \"No team\". This key will be ignored.\n")
|
|
}
|
|
return multiError
|
|
} else if !ok {
|
|
return multierror.Append(multiError, errors.New("'reports' key is required"))
|
|
}
|
|
var queries []Query
|
|
if err := json.Unmarshal(reportsRaw, &queries); err != nil {
|
|
return multierror.Append(multiError, MaybeParseTypeError(filePath, []string{"reports"}, err))
|
|
}
|
|
for _, item := range queries {
|
|
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 reports 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 := YamlUnmarshal(fileBytes, &pathQueries); err != nil {
|
|
multiError = multierror.Append(multiError, MaybeParseTypeError(*item.Path, []string{"reports"}, 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
|
|
}
|
|
|
|
var validSHA256Value = regexp.MustCompile(`\b[a-f0-9]{64}\b`)
|
|
|
|
func parseSoftware(top map[string]json.RawMessage, result *GitOps, baseDir string, filePath 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 {
|
|
return multierror.Append(multiError, MaybeParseTypeError(filePath, []string{"software"}, err))
|
|
}
|
|
}
|
|
for _, item := range software.AppStoreApps {
|
|
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
|
|
}
|
|
|
|
// Validate display_name length (matches database VARCHAR(255))
|
|
if len(item.DisplayName) > 255 {
|
|
multiError = multierror.Append(multiError, fmt.Errorf("app_store_id %q display_name is too long (max 255 characters)", item.AppStoreID))
|
|
continue
|
|
}
|
|
|
|
item = item.ResolvePaths(baseDir)
|
|
|
|
result.Software.AppStoreApps = append(result.Software.AppStoreApps, &item)
|
|
}
|
|
for _, maintainedAppSpec := range software.FleetMaintainedApps {
|
|
if maintainedAppSpec.Slug == "" {
|
|
multiError = multierror.Append(multiError, errors.New("fleet maintained app slug is required"))
|
|
continue
|
|
}
|
|
|
|
if len(maintainedAppSpec.LabelsExcludeAny) > 0 && len(maintainedAppSpec.LabelsIncludeAny) > 0 {
|
|
multiError = multierror.Append(multiError, fmt.Errorf(`only one of "labels_exclude_any" or "labels_include_any" can be specified for fleet maintained app %q`, maintainedAppSpec.Slug))
|
|
continue
|
|
}
|
|
|
|
maintainedAppSpec = maintainedAppSpec.ResolveSoftwarePackagePaths(baseDir)
|
|
|
|
// handle secrets
|
|
if maintainedAppSpec.InstallScript.Path != "" {
|
|
if err := gatherFileSecrets(result, maintainedAppSpec.InstallScript.Path); err != nil {
|
|
multiError = multierror.Append(multiError, err)
|
|
continue
|
|
}
|
|
}
|
|
if maintainedAppSpec.PostInstallScript.Path != "" {
|
|
if err := gatherFileSecrets(result, maintainedAppSpec.PostInstallScript.Path); err != nil {
|
|
multiError = multierror.Append(multiError, err)
|
|
continue
|
|
}
|
|
}
|
|
if maintainedAppSpec.UninstallScript.Path != "" {
|
|
if err := gatherFileSecrets(result, maintainedAppSpec.UninstallScript.Path); err != nil {
|
|
multiError = multierror.Append(multiError, err)
|
|
continue
|
|
}
|
|
}
|
|
|
|
result.Software.FleetMaintainedApps = append(result.Software.FleetMaintainedApps, &maintainedAppSpec)
|
|
}
|
|
for _, teamLevelPackage := range software.Packages {
|
|
// A single item in Packages can result in multiple SoftwarePackageSpecs being generated
|
|
var softwarePackageSpecs []*fleet.SoftwarePackageSpec
|
|
if teamLevelPackage.Path != nil {
|
|
yamlPath := resolveApplyRelativePath(baseDir, *teamLevelPackage.Path)
|
|
fileBytes, err := os.ReadFile(yamlPath)
|
|
if err != nil {
|
|
multiError = multierror.Append(multiError, fmt.Errorf("failed to read software package file %s: %w", *teamLevelPackage.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: %w", *teamLevelPackage.Path, err))
|
|
continue
|
|
}
|
|
var singlePackageSpec SoftwarePackage
|
|
singlePackageSpec.ReferencedYamlPath = yamlPath
|
|
if err := YamlUnmarshal(fileBytes, &singlePackageSpec); err == nil {
|
|
if singlePackageSpec.IncludesFieldsDisallowedInPackageFile() {
|
|
multiError = multierror.Append(multiError, fmt.Errorf("labels, categories, setup_experience, and self_service values must be specified at the team level; package-level specified in %s", *teamLevelPackage.Path))
|
|
continue
|
|
}
|
|
softwarePackageSpecs = append(softwarePackageSpecs, &singlePackageSpec.SoftwarePackageSpec)
|
|
} else if err = YamlUnmarshal(fileBytes, &softwarePackageSpecs); err == nil {
|
|
// Failing that, try to unmarshal as a list of SoftwarePackageSpecs
|
|
for i, spec := range softwarePackageSpecs {
|
|
if spec.IncludesFieldsDisallowedInPackageFile() {
|
|
multiError = multierror.Append(multiError, fmt.Errorf("labels, categories, setup_experience, and self_service values must be specified at the team level; package-level specified in %s", *teamLevelPackage.Path))
|
|
continue
|
|
}
|
|
|
|
softwarePackageSpecs[i].ReferencedYamlPath = yamlPath
|
|
}
|
|
} else {
|
|
// If we reached here, we couldn't unmarshal as either format.
|
|
multiError = multierror.Append(multiError, MaybeParseTypeError(*teamLevelPackage.Path, []string{"software", "packages"}, err))
|
|
continue
|
|
}
|
|
|
|
for i, spec := range softwarePackageSpecs {
|
|
softwarePackageSpec := spec.ResolveSoftwarePackagePaths(filepath.Dir(spec.ReferencedYamlPath))
|
|
softwarePackageSpec, err = teamLevelPackage.HydrateToPackageLevel(softwarePackageSpec)
|
|
if err != nil {
|
|
multiError = multierror.Append(multiError, err)
|
|
continue
|
|
}
|
|
softwarePackageSpecs[i] = &softwarePackageSpec
|
|
}
|
|
} else {
|
|
softwarePackageSpec := teamLevelPackage.SoftwarePackageSpec.ResolveSoftwarePackagePaths(baseDir)
|
|
softwarePackageSpecs = append(softwarePackageSpecs, &softwarePackageSpec)
|
|
}
|
|
|
|
for i, softwarePackageSpec := range softwarePackageSpecs {
|
|
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 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
|
|
}
|
|
if softwarePackageSpec.SHA256 != "" && !validSHA256Value.MatchString(softwarePackageSpec.SHA256) {
|
|
multiError = multierror.Append(multiError, fmt.Errorf("hash_sha256 value %q must be a valid lower-case hex-encoded (64-character) SHA-256 hash value", softwarePackageSpec.SHA256))
|
|
continue
|
|
}
|
|
if softwarePackageSpec.SHA256 == "" && softwarePackageSpec.URL == "" {
|
|
errorMessage := "at least one of hash_sha256 or url is required for each software package"
|
|
if softwarePackageSpec.ReferencedYamlPath != "" {
|
|
errorMessage += fmt.Sprintf("; missing in %s", softwarePackageSpec.ReferencedYamlPath)
|
|
}
|
|
if len(softwarePackageSpecs) > 1 {
|
|
errorMessage += fmt.Sprintf(", list item #%d", i+1)
|
|
}
|
|
|
|
multiError = multierror.Append(multiError, errors.New(errorMessage))
|
|
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
|
|
}
|
|
parsedUrl, err := url.Parse(softwarePackageSpec.URL)
|
|
if err != nil {
|
|
multiError = multierror.Append(multiError, fmt.Errorf("software URL %s is not a valid URL", softwarePackageSpec.URL))
|
|
continue
|
|
}
|
|
if softwarePackageSpec.InstallScript.Path == "" || softwarePackageSpec.UninstallScript.Path == "" {
|
|
// URL checks won't catch everything, but might as well include a lightweight check here to fail fast if it's
|
|
// certain that the package will fail later.
|
|
if strings.HasSuffix(parsedUrl.Path, ".exe") {
|
|
multiError = multierror.Append(multiError, fmt.Errorf("software URL %s refers to an .exe package, which requires both install_script and uninstall_script", softwarePackageSpec.URL))
|
|
continue
|
|
} else if strings.HasSuffix(parsedUrl.Path, ".tar.gz") || strings.HasSuffix(parsedUrl.Path, ".tgz") {
|
|
multiError = multierror.Append(multiError, fmt.Errorf("software URL %s refers to a .tar.gz archive, which requires both install_script and uninstall_script", softwarePackageSpec.URL))
|
|
continue
|
|
}
|
|
}
|
|
|
|
// Validate display_name length (matches database VARCHAR(255))
|
|
if len(softwarePackageSpec.DisplayName) > 255 {
|
|
multiError = multierror.Append(multiError, fmt.Errorf("software package %q display_name is too long (max 255 characters)", 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 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)
|
|
}
|