Add fleetctl new command (#41909)

<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #41345 

# Details

This PR:

* Adds a new `fleetctl new` command which creates a starter GitOps repo
file structure
* Adds support for file globs for the `configuration_profiles:` key in
GitOps, to support its use in the `fleetctl new` templates. This
involved moving the `BaseItem` type and `SupportsFileInclude` interface
into the `fleet` package so that the `MDMProfileSpec` type could
implement the interface and do glob expansion.

# Checklist for submitter

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

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

## Testing

- [X] Added/updated automated tests
  - [X] added unit and intg tests for globbing profiles
  - [ ] added tests for `fleetctl new`
- [X] QA'd all new/changed functionality manually
- [X] `fleetctl new` with no args prompted for org name and created a
new `it-and-security` folder under current folder w/ correct files
- [X] `fleetctl new --dir /tmp/testnew` created correct files under
`/tmp/testnew`
- [X] `fleetctl new --dir /tmp/testexisting --force` with an existing
`/tmp/testexisting` folder created correct files under
`/tmp/testexisting`
- [X] `fleetctl new --org-name=foo` created correct files under
`it-and-security` without prompting for org name
- [X] `paths:` in `configuration_profiles` picks up multiple matching
profiles
- [X] `paths:` + `path:` in `configuration_profiles` will error if the
same profile is picked up twice


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

## Summary by CodeRabbit

* **New Features**
* Added `fleetctl new` command to initialize GitOps repository structure
via CLI.
* Added glob pattern support for `configuration_profiles` field,
enabling flexible profile selection.

* **Chores**
  * Updated CLI dependencies to support enhanced user interactions.
  * Removed legacy website generator configuration files.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Scott Gress 2026-03-20 17:27:27 -05:00 committed by GitHub
parent 8154fa9c57
commit 91362ba2ca
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
56 changed files with 752 additions and 241 deletions

View file

@ -0,0 +1,2 @@
- Added `fleetctl new` command to initialize a GitOps folder
- Added glob support for `configuration_profiles`

View file

@ -74,6 +74,7 @@ func CreateApp(
runScriptCommand(),
gitopsCommand(),
generateGitopsCommand(),
newCommand(),
}
return app
}

View file

@ -0,0 +1,182 @@
package fleetctl
import (
"embed"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"strings"
"text/template"
"github.com/ghodss/yaml"
"github.com/manifoldco/promptui"
"github.com/urfave/cli/v2"
)
//go:embed all:templates/new
var newTemplateFS embed.FS
func renderTemplate(content []byte, vars map[string]string) ([]byte, error) {
tmpl, err := template.New("").Delims("<%=", "%>").Parse(string(content))
if err != nil {
return nil, err
}
var buf strings.Builder
if err := tmpl.Execute(&buf, vars); err != nil {
return nil, err
}
return []byte(buf.String()), nil
}
func printNextSteps(w io.Writer) {
fmt.Fprintln(w, "")
fmt.Fprintln(w, "Next steps:")
fmt.Fprintln(w, "")
fmt.Fprintln(w, " 1. Create a repository on GitHub or GitLab and push this directory to it.")
fmt.Fprintln(w, "")
fmt.Fprintln(w, " 2. Create a Fleet GitOps user and get an API token:")
fmt.Fprintln(w, " fleetctl user create --name GitOps --email gitops@example.com \\")
fmt.Fprintln(w, " --password <password> --global-role gitops --api-only")
fmt.Fprintln(w, "")
fmt.Fprintln(w, " 3. Add FLEET_URL and FLEET_API_TOKEN as secrets (GitHub) or CI/CD variables (GitLab).")
}
func newCommand() *cli.Command {
var (
orgName string
outputDir string
force bool
)
return &cli.Command{
Name: "new",
Usage: "Create a new Fleet GitOps repository structure",
UsageText: "fleetctl new [options]",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "org-name",
Usage: "The name of your organization",
Destination: &orgName,
},
&cli.StringFlag{
Name: "dir",
Aliases: []string{"d"},
Usage: "Output directory path",
Value: "it-and-security",
Destination: &outputDir,
},
&cli.BoolFlag{
Name: "force",
Aliases: []string{"f"},
Usage: "Write files into an existing directory",
Destination: &force,
},
},
Action: func(c *cli.Context) error {
if !force {
if _, err := os.Stat(outputDir); err == nil {
return fmt.Errorf("%s already exists; use --force to write into an existing directory", outputDir)
}
}
cleanOrgName := func(name string) string {
return strings.Map(func(r rune) rune {
if r < 0x20 || r == 0x7f {
return -1 // strip control characters
}
return r
}, strings.TrimSpace(name))
}
validateOrgName := func(name string) error {
name = cleanOrgName(name)
if name == "" {
return errors.New("organization name is required")
}
if len(name) > 255 {
return errors.New("organization name must be 255 characters or fewer")
}
return nil
}
if orgName == "" {
prompt := promptui.Prompt{
Label: "Organization name",
Default: "My organization",
Validate: func(s string) error { return validateOrgName(s) },
}
result, err := prompt.Run()
if err != nil {
return fmt.Errorf("prompt failed: %w", err)
}
orgName = cleanOrgName(result)
} else {
orgName = cleanOrgName(orgName)
if err := validateOrgName(orgName); err != nil {
return err
}
}
// Marshal through YAML to get a properly escaped scalar value.
yamlOrgName, err := yaml.Marshal(orgName)
if err != nil {
return fmt.Errorf("marshaling org name: %w", err)
}
vars := map[string]string{
"org_name": strings.TrimSpace(string(yamlOrgName)),
}
templateRoot := "templates/new"
err = fs.WalkDir(newTemplateFS, templateRoot, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
relPath, err := filepath.Rel(templateRoot, path)
if err != nil {
return err
}
if relPath == "." {
return nil
}
// Strip .template from filenames (e.g. foo.template.yml -> foo.yml).
relPath = strings.Replace(relPath, ".template.", ".", 1)
outPath := filepath.Join(outputDir, relPath)
if d.IsDir() {
return os.MkdirAll(outPath, 0o755)
}
content, err := newTemplateFS.ReadFile(path)
if err != nil {
return fmt.Errorf("reading template %s: %w", path, err)
}
content, err = renderTemplate(content, vars)
if err != nil {
return fmt.Errorf("rendering template %s: %w", path, err)
}
if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil {
return err
}
return os.WriteFile(outPath, content, 0o644)
})
if err != nil {
return fmt.Errorf("creating GitOps directory structure: %w", err)
}
fmt.Fprintf(c.App.Writer, "Created new Fleet GitOps repository at %s\n", outputDir)
fmt.Fprintf(c.App.Writer, "Organization name: %s\n", orgName)
printNextSteps(c.App.Writer)
return nil
},
}
}

View file

@ -0,0 +1,241 @@
package fleetctl
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/ghodss/yaml"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/urfave/cli/v2"
)
const testAppVersion = "fleet-v4.83.0"
// runNewCommand runs the "new" command with the given args and returns stdout and any error.
func runNewCommand(t *testing.T, args ...string) (string, error) {
t.Helper()
buf := &strings.Builder{}
app := &cli.App{
Name: "fleetctl",
Version: testAppVersion,
Writer: buf,
ErrWriter: buf,
Commands: []*cli.Command{newCommand()},
}
cliArgs := append([]string{"fleetctl", "new"}, args...)
err := app.Run(cliArgs)
return buf.String(), err
}
func TestNewBasicFileStructure(t *testing.T) {
dir := t.TempDir()
outDir := filepath.Join(dir, "out")
output, err := runNewCommand(t, "--org-name", `Acme "Corp" \ Inc`, "--dir", outDir)
require.NoError(t, err, output)
t.Run("has expected files", func(t *testing.T) {
// Spot-check key files exist.
expectedFiles := []string{
"default.yml",
".gitignore",
".github/workflows/workflow.yml",
".github/fleet-gitops/action.yml",
".gitlab-ci.yml",
"README.md",
"fleets/workstations.yml",
"labels/apple-silicon-macos-hosts.yml",
"platforms/macos/policies/all-software-updates-installed.yml",
}
for _, f := range expectedFiles {
path := filepath.Join(outDir, f)
_, err := os.Stat(path)
assert.NoError(t, err, "expected file %s to exist", f)
}
})
t.Run("strips .template. from output filenames", func(t *testing.T) {
// .template. should be stripped from output filenames.
err = filepath.Walk(outDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
assert.NotContains(t, info.Name(), ".template.", "file %s should not contain .template.", path)
return nil
})
require.NoError(t, err)
})
t.Run("replaces and escapes org_name template var", func(t *testing.T) {
content, err := os.ReadFile(filepath.Join(outDir, "default.yml"))
require.NoError(t, err)
assert.Contains(t, string(content), `Acme "Corp" \ Inc`)
assert.NotContains(t, string(content), "<%=")
// Verify the output is valid YAML that round-trips correctly.
var parsed map[string]any
require.NoError(t, yaml.Unmarshal(content, &parsed))
orgSettings, _ := parsed["org_settings"].(map[string]any)
orgInfo, _ := orgSettings["org_info"].(map[string]any)
assert.Equal(t, `Acme "Corp" \ Inc`, orgInfo["org_name"])
})
}
func TestNewOrgNameYAMLQuoting(t *testing.T) {
dir := t.TempDir()
outDir := filepath.Join(dir, "out")
// A colon followed by a space is special in YAML, so yaml.Marshal
// wraps the value in quotes to produce valid output.
_, err := runNewCommand(t, "--org-name", "Ops: IT & Security", "--dir", outDir)
require.NoError(t, err)
content, err := os.ReadFile(filepath.Join(outDir, "default.yml"))
require.NoError(t, err)
assert.Contains(t, string(content), `'Ops: IT & Security'`)
var parsed map[string]any
require.NoError(t, yaml.Unmarshal(content, &parsed))
orgSettings, _ := parsed["org_settings"].(map[string]any)
orgInfo, _ := orgSettings["org_info"].(map[string]any)
assert.Equal(t, "Ops: IT & Security", orgInfo["org_name"])
}
func TestNewTemplateStripping(t *testing.T) {
dir := t.TempDir()
outDir := filepath.Join(dir, "out")
_, err := runNewCommand(t, "--org-name", "Test", "--dir", outDir)
require.NoError(t, err)
}
func TestNewDirFlag(t *testing.T) {
dir := t.TempDir()
outDir := filepath.Join(dir, "custom-dir")
output, err := runNewCommand(t, "--org-name", "Test", "--dir", outDir)
require.NoError(t, err)
assert.Contains(t, output, "custom-dir")
_, err = os.Stat(filepath.Join(outDir, "default.yml"))
assert.NoError(t, err)
}
func TestNewExistingDirWithoutForce(t *testing.T) {
dir := t.TempDir()
outDir := filepath.Join(dir, "existing")
require.NoError(t, os.Mkdir(outDir, 0o755))
_, err := runNewCommand(t, "--org-name", "Test", "--dir", outDir)
require.Error(t, err)
assert.Contains(t, err.Error(), "already exists")
assert.Contains(t, err.Error(), "--force")
// Verify no default.yml was created.
_, err = os.Stat(filepath.Join(outDir, "default.yml"))
assert.Error(t, err)
}
func TestNewExistingDirWithForce(t *testing.T) {
dir := t.TempDir()
outDir := filepath.Join(dir, "existing")
require.NoError(t, os.Mkdir(outDir, 0o755))
_, err := runNewCommand(t, "--org-name", "Test", "--dir", outDir, "--force")
require.NoError(t, err)
_, err = os.Stat(filepath.Join(outDir, "default.yml"))
assert.NoError(t, err)
}
func TestNewOrgNameValidation(t *testing.T) {
tests := []struct {
name string
orgName string
wantErr string // empty means no error expected
checkOutput func(t *testing.T, outDir string)
}{
{
name: "only control characters",
orgName: "\x01\x02\x03",
wantErr: "organization name is required",
},
{
name: "too long",
orgName: strings.Repeat("a", 256),
wantErr: "255 characters",
},
{
name: "at max length",
orgName: strings.Repeat("a", 255),
},
{
name: "control characters stripped",
orgName: "ACME\x00Corp",
checkOutput: func(t *testing.T, outDir string) {
content, err := os.ReadFile(filepath.Join(outDir, "default.yml"))
require.NoError(t, err)
assert.Contains(t, string(content), "ACMECorp")
assert.NotContains(t, string(content), "\x00")
},
},
{
name: "only whitespace",
orgName: " ",
wantErr: "organization name is required",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
outDir := filepath.Join(t.TempDir(), "out")
_, err := runNewCommand(t, "--org-name", tt.orgName, "--dir", outDir)
if tt.wantErr != "" {
require.Error(t, err)
assert.Contains(t, err.Error(), tt.wantErr)
} else {
require.NoError(t, err)
}
if tt.checkOutput != nil {
tt.checkOutput(t, outDir)
}
})
}
}
func TestNewOutputMessages(t *testing.T) {
dir := t.TempDir()
outDir := filepath.Join(dir, "out")
output, err := runNewCommand(t, "--org-name", "Test Org", "--dir", outDir)
require.NoError(t, err)
assert.Contains(t, output, "Created new Fleet GitOps repository")
assert.Contains(t, output, "Organization name: Test Org")
assert.Contains(t, output, "Next steps:")
}
func TestRenderTemplate(t *testing.T) {
vars := map[string]string{
"name": "Fleet",
"version": "4.83.0",
}
t.Run("replaces known vars", func(t *testing.T) {
result, err := renderTemplate([]byte(`app: <%= .name %> v<%= .version %>`), vars)
require.NoError(t, err)
assert.Equal(t, "app: Fleet v4.83.0", string(result))
})
t.Run("unknown vars produce no value", func(t *testing.T) {
result, err := renderTemplate([]byte(`<%= .unknown %>`), vars)
require.NoError(t, err)
assert.Equal(t, "<no value>", string(result))
})
t.Run("handles no vars", func(t *testing.T) {
result, err := renderTemplate([]byte("no vars here"), vars)
require.NoError(t, err)
assert.Equal(t, "no vars here", string(result))
})
}

View file

@ -21,7 +21,7 @@ runs:
run: |
FLEET_URL="${FLEET_URL%/}"
FLEET_VERSION="$(curl "$FLEET_URL/api/v1/fleet/version" --header "Authorization: Bearer $FLEET_API_TOKEN" --fail --silent | jq --raw-output '.version')"
DEFAULT_FLEETCTL_VERSION="4.80.2"
DEFAULT_FLEETCTL_VERSION="latest"
# Decide which fleetctl version to install:
# If the server returns a clean version (e.g. 4.74.0), use that.
@ -56,4 +56,4 @@ runs:
env:
FLEET_DRY_RUN_ONLY: ${{ inputs.dry-run-only }}
FLEET_DELETE_OTHER_FLEETS: ${{ inputs.delete-other-fleets }}
run: ./.github/fleet-gitops/gitops.sh
run: ./.github/fleet-gitops/gitops.sh

View file

@ -26,7 +26,7 @@ fi
# FLEET_SSO_METADATA=$( sed '2,$s/^/ /' <<< "${FLEET_MDM_SSO_METADATA}")
# FLEET_MDM_SSO_METADATA=$( sed '2,$s/^/ /' <<< "${FLEET_MDM_SSO_METADATA}")
# Copy/pasting raw SSO metadata into GitHub secrets will result in malformed yaml.
# Copy/pasting raw SSO metadata into GitHub secrets will result in malformed yaml.
# Adds spaces to all but the first line of metadata keeps the multiline string in bounds.
if compgen -G "$FLEET_GITOPS_DIR"/fleets/*.yml > /dev/null; then
@ -56,4 +56,4 @@ if [ "$FLEET_DRY_RUN_ONLY" = true ]; then
fi
# Real run
$FLEETCTL gitops "${args[@]}"
$FLEETCTL gitops "${args[@]}"

View file

@ -1,17 +1,17 @@
name: 'Apply latest configuration to Fleet'
name: "Apply latest configuration to Fleet"
on:
push:
branches:
- main
paths:
- '**'
- "**"
pull_request:
paths:
- '**'
- "**"
workflow_dispatch: # allows manual triggering
schedule:
- cron: '0 6 * * *' # Nightly 6AM UTC
- cron: "0 6 * * *" # Nightly 6AM UTC
# Prevent concurrent runs of this workflow.
concurrency:
@ -41,11 +41,10 @@ jobs:
with:
# Run GitOps in dry-run mode for pull requests.
dry-run-only: ${{ github.event_name == 'pull_request' && 'true' || 'false' }}
# These environment variables can be set as repository secrets,
# alongside any other environment variables mentioned in your .yml files.
env:
###########################################################
# The server URL where Fleet is running.
#
@ -60,7 +59,7 @@ jobs:
# will have to take action to restore MDM functionality (due
# to the way Apple's device management protocol works.)
###########################################################
FLEET_URL: ${{ secrets.FLEET_URL && secrets.FLEET_URL || "https://fleet.example.com" }}
FLEET_URL: ${{ secrets.FLEET_URL && secrets.FLEET_URL || 'https://fleet.example.com' }}
###########################################################
# A Fleet API token that your CI/CD builds will use
@ -74,5 +73,3 @@ jobs:
# or that never expires.)
###########################################################
FLEET_API_TOKEN: ${{ secrets.FLEET_API_TOKEN }}

View file

@ -24,4 +24,4 @@ fleet-gitops:
npm install -g fleetctl
fi
- fleetctl config set --address $FLEET_URL --token $FLEET_API_TOKEN
- ./.github/fleet-gitops/gitops.sh
- ./.github/fleet-gitops/gitops.sh

View file

@ -1,4 +1,4 @@
# Fleet
# Fleet
These files allow you to configure, patch, and secure computing devices for your organization.

View file

@ -1,28 +1,27 @@
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
#
# default.yml
#
#
# Use this global manifest (`default.yml`) to configure
# top-level settings for your organization as a whole, and
# controls/reports/etc that apply to all of your fleets.
# controls/reports/etc that apply to all of your fleets.
#
# To see all supported options, check out:
# • https://fleetdm.com/docs/configuration/yaml-files
#
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
org_settings:
org_settings:
org_info:
###########################################################
# The name of your organization is displayed to end users
# during the setup experience for new hardware, and to admins
# in the Fleet UI.
#
#
# Read more:
# • https://fleetdm.com/docs/configuration/yaml-files#org-info
###########################################################
org_name: "My organization"
# ^TODO: Replace with the name of your organization
org_name: <%= .org_name %>
server_settings:
###########################################################
@ -36,7 +35,7 @@ org_settings:
###########################################################
# Uncomment to use single sign-on (SSO) for admins accessing Fleet.
#
#
# Read more:
# • https://fleetdm.com/docs/configuration/yaml-files#sso-settings
# • https://fleetdm.com/docs/deploy/single-sign-on-sso
@ -48,7 +47,7 @@ org_settings:
mdm:
###########################################################
# Uncomment to use single-sign on (SSO) to authenticate
# Uncomment to use single sign-on (SSO) to authenticate
# new computers enrolling in Fleet during end user setup.
#
# Read more:
@ -59,7 +58,7 @@ org_settings:
# idp_name: "Okta" e.g. "Entra", "Okta", "Google Workspace", etc. (Displayed to end users.)
# metadata_url: "https://okta.com/replace-this-url" # This must exactly match the "IdP metadata URL" provided by your identity provider (IdP) when setting up this integration.
# entity_id: "fleet-end-users" # This must exactly match the "Entity ID" field you chose when setting up this integration in your identity provider (IdP).
###########################################################
# Uncomment when you are ready to start using zero-touch enrollment
# for Apple devices via Apple Business Manager (ABM).
@ -70,7 +69,7 @@ org_settings:
###########################################################
# apple_business_manager:
# - macos_fleet: "💻 Workstations" # Where new macOS devices from ABM will appear
###########################################################
# Uncomment to start using Apple's volume purchase program (VPP)
# for making software available from the App Store and managing
@ -90,7 +89,7 @@ controls:
# Uncomment when you are ready to migrate Macs to Fleet
# from a different MDM server, especially devices running
# macOS ≤v26.
#
#
# For more information about what this does, see:
# • https://fleetdm.com/docs/configuration/yaml-files#macos-migration
# • https://fleetdm.com/guides/mdm-migration#end-user-workflow
@ -127,4 +126,3 @@ controls:
###########################################################
labels:
- paths: ./labels/*.yml

View file

@ -1,11 +1,11 @@
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
#
# personal-mobile-devices.yml
#
#
# Use this fleet manifest to configure controls, software,
# and settings that apply only to computing devices (hosts)
# in this particular fleet.
#
#
# > Note: By convention, the "📱🔐 Personal mobile devices"
# > fleet is where employee-owned iPhone and Android phones
# > are enrolled, for example as part of a BYOD ("bring your
@ -20,7 +20,7 @@ name: "📱🔐 Personal mobile devices"
controls:
setup_experience:
###########################################################
# Uncomment to use single-sign on (SSO) to authenticate
# Uncomment to use single-sign on (SSO) to authenticate
# end users enrolling their personal device.
#
# Read more:
@ -72,7 +72,7 @@ controls:
# Apps to make available from the Apple App Store or Google Play
###########################################################
software:
app_store_apps:
app_store_apps:
###########################################################
# iOS apps
###########################################################

View file

@ -1,11 +1,11 @@
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
#
# workstations.yml
#
#
# Use this fleet manifest to configure controls, software,
# automations, policies, and reports that apply only to
# computing devices (hosts) in this particular fleet.
#
#
# > Note: By convention, the "💻 Workstations" fleet is
# > where all company-owned laptops, PCs, and other
# > productivity endpoints (i.e. computers) are enrolled.
@ -19,21 +19,24 @@ controls:
setup_experience:
###########################################################
# Configure the macOS setup experience
#
#
# (Optional) edit the automatic enrollment profile referenced
# here to change which items are skipped during macOS setup
# and other aspects of the end user's experience during
# their first few minutes with their new Mac.
#
# Note: This requires activating MDM in your Fleet instance.
#
# For more, see:
# • https://fleetdm.com/guides/apple-mdm-setup
# • https://fleetdm.com/docs/configuration/yaml-files#macos-setup
# • https://developer.apple.com/documentation/devicemanagement/profile
# • https://support.apple.com/guide/deployment/automated-device-enrollment-management-dep73069dd57/web
###########################################################
apple_setup_assistant: ../platforms/macos/enrollment-profiles/automatic-enrollment.dep.json
# apple_setup_assistant: ../platforms/macos/enrollment-profiles/automatic-enrollment.dep.json
###########################################################
# Uncomment to use single-sign on (SSO) to authenticate
# Uncomment to use single-sign on (SSO) to authenticate
# end users during first-time setup of new computers.
#
# Read more:
@ -44,19 +47,23 @@ controls:
###########################################################
# Configuration profiles
#
#
# Note: This requires activating MDM in your Fleet instance.
#
# For more, see:
# • https://fleetdm.com/guides/apple-mdm-setup
# • https://fleetdm.com/guides/windows-mdm-setup
# • https://fleetdm.com/docs/configuration/yaml-files#apple-settings-and-windows-settings
#
# Note: Instead of including all profiles with `paths`,
# you can also switch to using `path` and including each
# specific configuration profile one by one, which allows
# for scoping using labels. For example:
# Profiles can be scoped using labels. For example:
# ```
# - path: ../platforms/macos/configuration-profiles/1password-managed-settings.mobileconfig
# labels_include_any:
# - "Macs with 1Password installed"
# ```
# - paths: ../platforms/windows/configuration-profiles/*.xml
# labels_include_all:
# - "x86-based Windows hosts"
# ```
###########################################################
apple_settings:
configuration_profiles:
@ -65,7 +72,7 @@ controls:
windows_settings:
configuration_profiles:
- paths: ../platforms/windows/configuration-profiles/*.xml
###########################################################
# Managed disk encryption
#
@ -98,7 +105,7 @@ controls:
###########################################################
# Script library
#
#
# Note: You probably don't need to change the next few lines.
#
# > To make a script available for use with Fleet for helpdesk

View file

@ -3,7 +3,7 @@
critical: false
description: This Mac may have outdated system software, which could lead to security vulnerabilities, performance issues, and incompatibility with other systems.
resolution: |
Please take some time and run all available updates from Software Update ( > System Settings > Software Update) and all available app store updates ( > App Store > Updates).
Please take some time and run all available updates from Software Update ( > System Settings > Software Update) and all available app store updates ( > App Store > Updates).
If you see the message "This feature isn't available with the Apple Account you're currently using." you will need to delete and re-install the app.
platform: darwin
platform: darwin

2
go.mod
View file

@ -94,6 +94,7 @@ require (
github.com/kolide/launcher v1.0.12
github.com/lib/pq v1.10.9
github.com/macadmins/osquery-extension v1.3.2
github.com/manifoldco/promptui v0.9.0
github.com/mattn/go-sqlite3 v1.14.22
github.com/micromdm/micromdm v1.9.0
github.com/micromdm/nanolib v0.2.0
@ -226,6 +227,7 @@ require (
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash v1.1.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/containerd/cgroups v1.1.0 // indirect
github.com/containerd/containerd/api v1.8.0 // indirect

9
go.sum
View file

@ -201,6 +201,12 @@ github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghf
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/clbanning/mxj v1.8.4 h1:HuhwZtbyvyOw+3Z1AowPkU87JkJUSv751ELWaiTpj8I=
github.com/clbanning/mxj v1.8.4/go.mod h1:BVjHeAH+rl9rs6f+QIpeRl0tfu10SXn1pUSa5PVGJng=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
@ -591,6 +597,8 @@ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2
github.com/macadmins/osquery-extension v1.3.2 h1:X6x4jD6Te9uYW1s7LQPjrux5G6WC78UF/6p4qFw2Mb8=
github.com/macadmins/osquery-extension v1.3.2/go.mod h1:/4WhG7sh9qyEi2WkacxOUJAmVciiDFmT468MbkiXBfE=
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA=
github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg=
github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE=
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/mattermost/xml-roundtrip-validator v0.1.0 h1:RXbVD2UAl7A7nOTR4u7E3ILa4IbtvKBHw64LDsmu9hU=
@ -1021,6 +1029,7 @@ golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=

View file

@ -160,13 +160,8 @@ func YamlUnmarshal(yamlBytes []byte, out any) error {
return nil
}
type BaseItem struct {
Path *string `json:"path"`
Paths *string `json:"paths"`
}
type GitOpsControls struct {
BaseItem
fleet.BaseItem
MacOSUpdates any `json:"macos_updates"`
IOSUpdates any `json:"ios_updates"`
IPadOSUpdates any `json:"ipados_updates"`
@ -184,10 +179,10 @@ type GitOpsControls struct {
AndroidEnabledAndConfigured any `json:"android_enabled_and_configured"`
AndroidSettings any `json:"android_settings"`
EnableDiskEncryption any `json:"enable_disk_encryption"`
EnableRecoveryLockPassword any `json:"enable_recovery_lock_password"`
RequireBitLockerPIN any `json:"windows_require_bitlocker_pin,omitempty"`
Scripts []BaseItem `json:"scripts"`
EnableDiskEncryption any `json:"enable_disk_encryption"`
EnableRecoveryLockPassword any `json:"enable_recovery_lock_password"`
RequireBitLockerPIN any `json:"windows_require_bitlocker_pin,omitempty"`
Scripts []fleet.BaseItem `json:"scripts"`
Defined bool
}
@ -202,7 +197,7 @@ func (c GitOpsControls) Set() bool {
}
type Policy struct {
BaseItem
fleet.BaseItem
GitOpsPolicySpec
}
@ -233,12 +228,12 @@ type PolicyInstallSoftware struct {
}
type Query struct {
BaseItem
fleet.BaseItem
fleet.QuerySpec
}
type Label struct {
BaseItem
fleet.BaseItem
fleet.LabelSpec
}
@ -268,29 +263,10 @@ func (l *Label) UnmarshalJSON(data []byte) error {
}
type SoftwarePackage struct {
BaseItem
fleet.BaseItem
fleet.SoftwarePackageSpec
}
// SupportsFileInclude is implemented by types that embed BaseItem and can
// reference external files via path/paths fields.
type SupportsFileInclude interface {
GetBaseItem() BaseItem
SetBaseItem(v BaseItem)
}
// GetBaseItem returns the current BaseItem value.
// Types that embed BaseItem inherit this method via promotion.
func (b *BaseItem) GetBaseItem() BaseItem {
return *b
}
// SetBaseItem sets the BaseItem value.
// Types that embed BaseItem inherit this method via promotion.
func (b *BaseItem) SetBaseItem(v BaseItem) {
*b = v
}
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 != "" {
@ -575,7 +551,7 @@ const (
)
func parseOrgSettings(raw json.RawMessage, result *GitOps, baseDir string, filePath string, multiError *multierror.Error) *multierror.Error {
var orgSettingsTop BaseItem
var orgSettingsTop fleet.BaseItem
if err := json.Unmarshal(raw, &orgSettingsTop); err != nil {
return multierror.Append(multiError, MaybeParseTypeError(filePath, []string{"org_settings"}, err))
}
@ -596,7 +572,7 @@ func parseOrgSettings(raw json.RawMessage, result *GitOps, baseDir string, fileP
multiError, fmt.Errorf("failed to expand environment in file %s: %v", *orgSettingsTop.Path, err),
)
} else {
var pathOrgSettings BaseItem
var pathOrgSettings fleet.BaseItem
if err := YamlUnmarshal(fileBytes, &pathOrgSettings); err != nil {
noError = false
multiError = multierror.Append(
@ -631,7 +607,7 @@ func parseOrgSettings(raw json.RawMessage, result *GitOps, baseDir string, fileP
}
func parseTeamSettings(raw json.RawMessage, result *GitOps, baseDir string, filePath string, multiError *multierror.Error) *multierror.Error {
var teamSettingsTop BaseItem
var teamSettingsTop fleet.BaseItem
if err := json.Unmarshal(raw, &teamSettingsTop); err != nil {
return multierror.Append(multiError, MaybeParseTypeError(filePath, []string{"settings"}, err))
}
@ -652,7 +628,7 @@ func parseTeamSettings(raw json.RawMessage, result *GitOps, baseDir string, file
multiError, fmt.Errorf("failed to expand environment in file %s: %v", *teamSettingsTop.Path, err),
)
} else {
var pathTeamSettings BaseItem
var pathTeamSettings fleet.BaseItem
if err := YamlUnmarshal(fileBytes, &pathTeamSettings); err != nil {
noError = false
multiError = multierror.Append(
@ -847,7 +823,7 @@ func parseAgentOptions(top map[string]json.RawMessage, result *GitOps, baseDir s
} else if !ok {
return multierror.Append(multiError, errors.New("'agent_options' is required"))
}
var agentOptionsTop BaseItem
var agentOptionsTop fleet.BaseItem
if err := json.Unmarshal(agentOptionsRaw, &agentOptionsTop); err != nil {
multiError = multierror.Append(multiError, MaybeParseTypeError(filePath, []string{"agent_options"}, err))
} else {
@ -865,7 +841,7 @@ func parseAgentOptions(top map[string]json.RawMessage, result *GitOps, baseDir s
multiError, fmt.Errorf("failed to expand environment in file %s: %v", *agentOptionsTop.Path, err),
)
} else {
var pathAgentOptions BaseItem
var pathAgentOptions fleet.BaseItem
if err := YamlUnmarshal(fileBytes, &pathAgentOptions); err != nil {
return multierror.Append(
multiError, MaybeParseTypeError(*agentOptionsTop.Path, []string{"agent_options"}, err),
@ -965,8 +941,17 @@ func parseControls(top map[string]json.RawMessage, result *GitOps, logFn Logf, y
return multierror.Append(multiError, MaybeParseTypeError(controlsFilePath, []string{"controls", "macos_settings"}, err))
}
// Expand globs in profile paths.
var errs []error
macOSSettings.CustomSettings, errs = expandBaseItems(macOSSettings.CustomSettings, controlsDir, "profile", GlobExpandOptions{
AllowedExtensions: map[string]bool{".mobileconfig": true, ".json": true},
LogFn: logFn,
})
multiError = multierror.Append(multiError, errs...)
// Then resolve the paths to absolute and find Fleet secrets in the profile files.
for i := range macOSSettings.CustomSettings {
err := resolveAndUpdateProfilePathToAbsolute(controlsDir, &macOSSettings.CustomSettings[i], result)
err := resolveAndUpdateProfilePath(&macOSSettings.CustomSettings[i], result)
if err != nil {
return multierror.Append(multiError, err)
}
@ -991,8 +976,16 @@ func parseControls(top map[string]json.RawMessage, result *GitOps, logFn Logf, y
return multierror.Append(multiError, MaybeParseTypeError(controlsFilePath, []string{"controls", "windows_settings"}, err))
}
if windowsSettings.CustomSettings.Valid {
var errs []error
windowsSettings.CustomSettings.Value, errs = expandBaseItems(windowsSettings.CustomSettings.Value, controlsDir, "profile", GlobExpandOptions{
AllowedExtensions: map[string]bool{".xml": true},
LogFn: logFn,
})
multiError = multierror.Append(multiError, errs...)
for i := range windowsSettings.CustomSettings.Value {
err := resolveAndUpdateProfilePathToAbsolute(controlsDir, &windowsSettings.CustomSettings.Value[i], result)
err := resolveAndUpdateProfilePath(&windowsSettings.CustomSettings.Value[i], result)
if err != nil {
return multierror.Append(multiError, err)
}
@ -1020,8 +1013,14 @@ func parseControls(top map[string]json.RawMessage, result *GitOps, logFn Logf, y
}
if androidSettings.CustomSettings.Valid {
var errs []error
androidSettings.CustomSettings.Value, errs = expandBaseItems(androidSettings.CustomSettings.Value, controlsDir, "profile", GlobExpandOptions{
AllowedExtensions: map[string]bool{".json": true},
LogFn: logFn,
})
multiError = multierror.Append(multiError, errs...)
for i := range androidSettings.CustomSettings.Value {
err := resolveAndUpdateProfilePathToAbsolute(controlsDir, &androidSettings.CustomSettings.Value[i], result)
err := resolveAndUpdateProfilePath(&androidSettings.CustomSettings.Value[i], result)
if err != nil {
return multierror.Append(multiError, err)
}
@ -1091,18 +1090,16 @@ func processControlsPathIfNeeded(controlsTop GitOpsControls, result *GitOps, con
return errs
}
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.
func resolveAndUpdateProfilePath(profile *fleet.MDMProfileSpec, result *GitOps) error {
// Path has already been resolved by expandBaseItems; just ensure it's absolute.
var err error
profile.Path, err = filepath.Abs(resolvedPath)
profile.Path, err = filepath.Abs(profile.Path)
if err != nil {
return fmt.Errorf("failed to resolve profile path %s: %v", resolvedPath, err)
return fmt.Errorf("failed to resolve profile path %s: %v", profile.Path, err)
}
fileBytes, err := os.ReadFile(resolvedPath)
fileBytes, err := os.ReadFile(profile.Path)
if err != nil {
return fmt.Errorf("failed to read profile file %s: %v", resolvedPath, err)
return fmt.Errorf("failed to read profile file %s: %v", profile.Path, err)
}
err = LookupEnvSecrets(string(fileBytes), result.FleetSecrets)
if err != nil {
@ -1194,7 +1191,7 @@ func expandGlobPattern(pattern string, baseDir string, entityType string, opts G
// problems in one pass.
func expandBaseItems[T any, PT interface {
*T
SupportsFileInclude
fleet.SupportsFileInclude
}](inputEntities []T, baseDir string, entityType string, opts GlobExpandOptions) ([]T, []error) {
opts.setDefaults()
var result []T
@ -1233,7 +1230,7 @@ func expandBaseItems[T any, PT interface {
}
seenBasenames[base] = *baseItem.Path
}
PT(&entity).SetBaseItem(BaseItem{Path: &resolved})
PT(&entity).SetBaseItem(fleet.BaseItem{Path: &resolved})
result = append(result, entity)
// Glob -- expand and add files to result.
case hasPaths:
@ -1260,8 +1257,8 @@ func expandBaseItems[T any, PT interface {
}
seenBasenames[base] = *baseItem.Paths
}
var newItem T
PT(&newItem).SetBaseItem(BaseItem{Path: &p})
newItem := entity // clone to preserve non-BaseItem fields (e.g. labels)
PT(&newItem).SetBaseItem(fleet.BaseItem{Path: &p})
result = append(result, newItem)
}
}
@ -1270,7 +1267,7 @@ func expandBaseItems[T any, PT interface {
return result, errs
}
func resolveScriptPaths(input []BaseItem, baseDir string, logFn Logf) ([]BaseItem, []error) {
func resolveScriptPaths(input []fleet.BaseItem, baseDir string, logFn Logf) ([]fleet.BaseItem, []error) {
return expandBaseItems(input, baseDir, "script", GlobExpandOptions{
AllowedExtensions: allowedScriptExtensions,
RequireUniqueBasenames: true,
@ -1502,7 +1499,7 @@ func parsePolicies(top map[string]json.RawMessage, result *GitOps, baseDir strin
return multiError
}
func parsePolicyRunScript(baseDir string, parentFilePath string, teamName *string, policy *Policy, scripts []BaseItem) error {
func parsePolicyRunScript(baseDir string, parentFilePath string, teamName *string, policy *Policy, scripts []fleet.BaseItem) error {
if policy.RunScript == nil {
policy.ScriptID = ptr.Uint(0) // unset the script
return nil

View file

@ -646,7 +646,7 @@ func TestInvalidGitOpsYaml(t *testing.T) {
config = getConfig([]string{"settings"})
config += fmt.Sprintf("%s:\n path: %s\n", "settings", tmpFile.Name())
_, err = gitOpsFromString(t, config)
assert.ErrorContains(t, err, "expected type spec.BaseItem but got array")
assert.ErrorContains(t, err, "expected type fleet.BaseItem but got array")
// Invalid secrets 1
config = getConfig([]string{"settings"})
@ -818,7 +818,7 @@ func TestInvalidGitOpsYaml(t *testing.T) {
config = getConfig([]string{"org_settings"})
config += fmt.Sprintf("%s:\n path: %s\n", "org_settings", tmpFile.Name())
_, err = gitOpsFromString(t, config)
assert.ErrorContains(t, err, "expected type spec.BaseItem but got array")
assert.ErrorContains(t, err, "expected type fleet.BaseItem but got array")
// Invalid secrets 1
config = getConfig([]string{"org_settings"})
@ -873,7 +873,7 @@ func TestInvalidGitOpsYaml(t *testing.T) {
config = getConfig([]string{"agent_options"})
config += fmt.Sprintf("%s:\n path: %s\n", "agent_options", tmpFile.Name())
_, err = gitOpsFromString(t, config)
assert.ErrorContains(t, err, "expected type spec.BaseItem but got array")
assert.ErrorContains(t, err, "expected type fleet.BaseItem but got array")
// Invalid controls
config = getConfig([]string{"controls"})
@ -1793,7 +1793,7 @@ func TestExpandBaseItems(t *testing.T) {
require.NoError(t, os.WriteFile(filepath.Join(dir, "b.yml"), []byte(""), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(dir, "c.txt"), []byte(""), 0o644))
items := []BaseItem{{Paths: ptr.String("*.yml")}}
items := []fleet.BaseItem{{Paths: ptr.String("*.yml")}} //nolint:modernize
result, errs := expandBaseItems(items, dir, "test", GlobExpandOptions{})
require.Empty(t, errs)
require.Len(t, result, 2)
@ -1811,7 +1811,7 @@ func TestExpandBaseItems(t *testing.T) {
require.NoError(t, os.WriteFile(filepath.Join(dir, "top.yml"), []byte(""), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(subdir, "nested.yml"), []byte(""), 0o644))
items := []BaseItem{{Paths: ptr.String("**/*.yml")}}
items := []fleet.BaseItem{{Paths: ptr.String("**/*.yml")}} //nolint:modernize
result, errs := expandBaseItems(items, dir, "test", GlobExpandOptions{})
require.Empty(t, errs)
require.Len(t, result, 2)
@ -1826,9 +1826,9 @@ func TestExpandBaseItems(t *testing.T) {
require.NoError(t, os.WriteFile(filepath.Join(dir, "glob1.yaml"), []byte(""), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(dir, "glob2.yaml"), []byte(""), 0o644))
items := []BaseItem{
{Path: ptr.String("single.yml")},
{Paths: ptr.String("*.yaml")},
items := []fleet.BaseItem{
{Path: ptr.String("single.yml")}, //nolint:modernize
{Paths: ptr.String("*.yaml")}, //nolint:modernize
}
result, errs := expandBaseItems(items, dir, "test", GlobExpandOptions{})
require.Empty(t, errs)
@ -1840,28 +1840,28 @@ func TestExpandBaseItems(t *testing.T) {
t.Run("paths_without_glob_error", func(t *testing.T) {
t.Parallel()
items := []BaseItem{{Paths: ptr.String("foo.yml")}}
items := []fleet.BaseItem{{Paths: ptr.String("foo.yml")}} //nolint:modernize
_, errs := expandBaseItems(items, "/tmp", "test", GlobExpandOptions{})
requireErrorContains(t, errs, `does not contain glob characters`)
})
t.Run("path_with_glob_error", func(t *testing.T) {
t.Parallel()
items := []BaseItem{{Path: ptr.String("*.yml")}}
items := []fleet.BaseItem{{Path: ptr.String("*.yml")}} //nolint:modernize
_, errs := expandBaseItems(items, "/tmp", "test", GlobExpandOptions{})
requireErrorContains(t, errs, `contains glob characters`)
})
t.Run("both_path_and_paths_error", func(t *testing.T) {
t.Parallel()
items := []BaseItem{{Path: ptr.String("foo.yml"), Paths: ptr.String("*.yml")}}
items := []fleet.BaseItem{{Path: ptr.String("foo.yml"), Paths: ptr.String("*.yml")}} //nolint:modernize
_, errs := expandBaseItems(items, "/tmp", "test", GlobExpandOptions{})
requireErrorContains(t, errs, `cannot have both "path" and "paths"`)
})
t.Run("inline_items_passed_through", func(t *testing.T) {
t.Parallel()
items := []BaseItem{{}}
items := []fleet.BaseItem{{}}
result, errs := expandBaseItems(items, "/tmp", "test", GlobExpandOptions{})
require.Empty(t, errs)
require.Len(t, result, 1)
@ -1871,7 +1871,7 @@ func TestExpandBaseItems(t *testing.T) {
t.Run("require_file_reference_error", func(t *testing.T) {
t.Parallel()
items := []BaseItem{{}}
items := []fleet.BaseItem{{}}
_, errs := expandBaseItems(items, "/tmp", "test", GlobExpandOptions{
RequireFileReference: true,
})
@ -1885,7 +1885,7 @@ func TestExpandBaseItems(t *testing.T) {
logFn := func(format string, args ...any) {
warnings = append(warnings, fmt.Sprintf(format, args...))
}
items := []BaseItem{{Paths: ptr.String("*.yml")}}
items := []fleet.BaseItem{{Paths: ptr.String("*.yml")}} //nolint:modernize
result, errs := expandBaseItems(items, dir, "test", GlobExpandOptions{LogFn: logFn})
require.Empty(t, errs)
assert.Empty(t, result)
@ -1903,7 +1903,7 @@ func TestExpandBaseItems(t *testing.T) {
require.NoError(t, os.WriteFile(filepath.Join(sub1, "dup.yml"), []byte(""), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(sub2, "dup.yml"), []byte(""), 0o644))
items := []BaseItem{{Paths: ptr.String("**/*.yml")}}
items := []fleet.BaseItem{{Paths: ptr.String("**/*.yml")}} //nolint:modernize
_, errs := expandBaseItems(items, dir, "test", GlobExpandOptions{
RequireUniqueBasenames: true,
})
@ -1918,9 +1918,9 @@ func TestExpandBaseItems(t *testing.T) {
require.NoError(t, os.WriteFile(filepath.Join(dir, "item.yml"), []byte(""), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(sub, "item.yml"), []byte(""), 0o644))
items := []BaseItem{
{Path: ptr.String("item.yml")},
{Paths: ptr.String("sub/*.yml")},
items := []fleet.BaseItem{
{Path: ptr.String("item.yml")}, //nolint:modernize
{Paths: ptr.String("sub/*.yml")}, //nolint:modernize
}
_, errs := expandBaseItems(items, dir, "test", GlobExpandOptions{
RequireUniqueBasenames: true,
@ -1941,7 +1941,7 @@ func TestExpandBaseItems(t *testing.T) {
warnings = append(warnings, fmt.Sprintf(format, args...))
}
items := []BaseItem{{Paths: ptr.String("*")}}
items := []fleet.BaseItem{{Paths: ptr.String("*")}} //nolint:modernize
result, errs := expandBaseItems(items, dir, "test", GlobExpandOptions{
AllowedExtensions: map[string]bool{".sh": true},
LogFn: logFn,
@ -1959,7 +1959,7 @@ func TestExpandBaseItems(t *testing.T) {
require.NoError(t, os.WriteFile(filepath.Join(dir, "a.yml"), []byte(""), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(dir, "m.yml"), []byte(""), 0o644))
items := []BaseItem{{Paths: ptr.String("*.yml")}}
items := []fleet.BaseItem{{Paths: ptr.String("*.yml")}} //nolint:modernize
result, errs := expandBaseItems(items, dir, "test", GlobExpandOptions{})
require.Empty(t, errs)
require.Len(t, result, 3)
@ -1970,7 +1970,7 @@ func TestExpandBaseItems(t *testing.T) {
t.Run("multiple_errors_collected", func(t *testing.T) {
t.Parallel()
items := []BaseItem{{Path: ptr.String("*.yml")}, {Paths: ptr.String("noglob.yml")}}
items := []fleet.BaseItem{{Path: ptr.String("*.yml")}, {Paths: ptr.String("noglob.yml")}} //nolint:modernize
_, errs := expandBaseItems(items, "", "test", GlobExpandOptions{})
require.Len(t, errs, 2)
assert.Contains(t, errs[0].Error(), `contains glob characters`)
@ -1986,7 +1986,7 @@ func TestResolveScriptPaths(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "script.sh"), []byte("#!/bin/bash"), 0o644))
items := []BaseItem{{Path: ptr.String("script.sh")}}
items := []fleet.BaseItem{{Path: ptr.String("script.sh")}} //nolint:modernize
result, errs := resolveScriptPaths(items, dir, nopLogf)
require.Empty(t, errs)
require.Len(t, result, 1)
@ -1999,7 +1999,7 @@ func TestResolveScriptPaths(t *testing.T) {
require.NoError(t, os.WriteFile(filepath.Join(dir, "a.sh"), []byte("#!/bin/bash"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(dir, "b.sh"), []byte("#!/bin/bash"), 0o644))
items := []BaseItem{{Paths: ptr.String("*.sh")}}
items := []fleet.BaseItem{{Paths: ptr.String("*.sh")}} //nolint:modernize
result, errs := resolveScriptPaths(items, dir, nopLogf)
require.Empty(t, errs)
require.Len(t, result, 2)
@ -2007,7 +2007,7 @@ func TestResolveScriptPaths(t *testing.T) {
t.Run("inline_not_allowed", func(t *testing.T) {
t.Parallel()
items := []BaseItem{{}}
items := []fleet.BaseItem{{}}
_, errs := resolveScriptPaths(items, "/tmp", nopLogf)
require.NotEmpty(t, errs)
assert.Contains(t, errs[0].Error(), `no "path" or "paths" field`)
@ -2193,6 +2193,127 @@ func TestGitOpsGlobScripts(t *testing.T) {
assert.Equal(t, filepath.Join(scriptsDir, "gamma.ps1"), *result.Controls.Scripts[2].Path)
}
func TestGitOpsGlobProfiles(t *testing.T) {
t.Parallel()
t.Run("macos_profiles", func(t *testing.T) {
t.Parallel()
dir := t.TempDir()
profilesDir := filepath.Join(dir, "profiles")
require.NoError(t, os.MkdirAll(profilesDir, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(profilesDir, "alpha.mobileconfig"), []byte("<plist></plist>"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(profilesDir, "beta.json"), []byte("{}"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(profilesDir, "gamma.mobileconfig"), []byte("<plist></plist>"), 0o644))
config := getGlobalConfig([]string{"controls"})
config += `controls:
apple_settings:
configuration_profiles:
- paths: profiles/*.mobileconfig
- path: profiles/beta.json
`
yamlPath := filepath.Join(dir, "gitops.yml")
require.NoError(t, os.WriteFile(yamlPath, []byte(config), 0o644))
result, err := GitOpsFromFile(yamlPath, dir, nil, nopLogf)
require.NoError(t, err)
macSettings, ok := result.Controls.MacOSSettings.(fleet.MacOSSettings)
require.True(t, ok)
require.Len(t, macSettings.CustomSettings, 3)
// Glob results come first (sorted), then the explicit path
assert.Contains(t, macSettings.CustomSettings[0].Path, "alpha.mobileconfig")
assert.Contains(t, macSettings.CustomSettings[1].Path, "gamma.mobileconfig")
assert.Contains(t, macSettings.CustomSettings[2].Path, "beta.json")
})
t.Run("windows_profiles", func(t *testing.T) {
t.Parallel()
dir := t.TempDir()
profilesDir := filepath.Join(dir, "profiles")
require.NoError(t, os.MkdirAll(profilesDir, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(profilesDir, "alpha.xml"), []byte("<xml/>"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(profilesDir, "beta.xml"), []byte("<xml/>"), 0o644))
config := getGlobalConfig([]string{"controls"})
config += `controls:
windows_settings:
configuration_profiles:
- paths: profiles/*.xml
`
yamlPath := filepath.Join(dir, "gitops.yml")
require.NoError(t, os.WriteFile(yamlPath, []byte(config), 0o644))
result, err := GitOpsFromFile(yamlPath, dir, nil, nopLogf)
require.NoError(t, err)
winSettings, ok := result.Controls.WindowsSettings.(fleet.WindowsSettings)
require.True(t, ok)
require.True(t, winSettings.CustomSettings.Valid)
require.Len(t, winSettings.CustomSettings.Value, 2)
assert.Contains(t, winSettings.CustomSettings.Value[0].Path, "alpha.xml")
assert.Contains(t, winSettings.CustomSettings.Value[1].Path, "beta.xml")
})
t.Run("android_profiles", func(t *testing.T) {
t.Parallel()
dir := t.TempDir()
profilesDir := filepath.Join(dir, "profiles")
require.NoError(t, os.MkdirAll(profilesDir, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(profilesDir, "skip.xml"), []byte("<xml/>"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(profilesDir, "beta.json"), []byte("{}"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(profilesDir, "skip.txt"), []byte("nope"), 0o644))
config := getGlobalConfig([]string{"controls"})
config += `controls:
android_settings:
configuration_profiles:
- paths: profiles/*
`
yamlPath := filepath.Join(dir, "gitops.yml")
require.NoError(t, os.WriteFile(yamlPath, []byte(config), 0o644))
result, err := GitOpsFromFile(yamlPath, dir, nil, nopLogf)
require.NoError(t, err)
androidSettings, ok := result.Controls.AndroidSettings.(fleet.AndroidSettings)
require.True(t, ok)
require.True(t, androidSettings.CustomSettings.Valid)
require.Len(t, androidSettings.CustomSettings.Value, 1)
// Sorted alphabetically by path
assert.Contains(t, androidSettings.CustomSettings.Value[0].Path, "beta.json")
})
t.Run("macos_profiles_with_labels", func(t *testing.T) {
t.Parallel()
dir := t.TempDir()
profilesDir := filepath.Join(dir, "profiles")
require.NoError(t, os.MkdirAll(profilesDir, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(profilesDir, "a.mobileconfig"), []byte("<plist></plist>"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(profilesDir, "b.mobileconfig"), []byte("<plist></plist>"), 0o644))
config := getGlobalConfig([]string{"controls"})
config += `controls:
apple_settings:
configuration_profiles:
- paths: profiles/*.mobileconfig
labels_include_all:
- MyLabel
`
yamlPath := filepath.Join(dir, "gitops.yml")
require.NoError(t, os.WriteFile(yamlPath, []byte(config), 0o644))
result, err := GitOpsFromFile(yamlPath, dir, nil, nopLogf)
require.NoError(t, err)
macSettings, ok := result.Controls.MacOSSettings.(fleet.MacOSSettings)
require.True(t, ok)
require.Len(t, macSettings.CustomSettings, 2)
for _, p := range macSettings.CustomSettings {
assert.Equal(t, []string{"MyLabel"}, p.LabelsIncludeAll)
}
})
}
func TestUnknownKeyDetection(t *testing.T) {
t.Parallel()

View file

@ -679,7 +679,8 @@ func NewMDMConfigProfilePayloadFromAndroid(cp *MDMAndroidConfigProfile) *MDMConf
// MDMProfileSpec represents the spec used to define configuration
// profiles via yaml files.
type MDMProfileSpec struct {
Path string `json:"path,omitempty"`
Path string `json:"path,omitempty"`
Paths string `json:"paths,omitempty"`
// Deprecated: the Labels field is now deprecated, it is superseded by
// LabelsIncludeAll, so any value set via this field will be transferred to
@ -700,6 +701,39 @@ type MDMProfileSpec struct {
LabelsExcludeAny []string `json:"labels_exclude_any,omitempty"`
}
// Implement the SupportsFileInclude interface so that MDMProfileSpec
// can support globs in GitOps.
// GetBaseItem converts MDMProfileSpec's string Path/Paths to a BaseItem.
// Nil pointers are returned for empty strings.
func (p *MDMProfileSpec) GetBaseItem() BaseItem {
var b BaseItem
if p.Path != "" {
path := p.Path
b.Path = &path
}
if p.Paths != "" {
paths := p.Paths
b.Paths = &paths
}
return b
}
// SetBaseItem updates MDMProfileSpec's Path/Paths from a BaseItem.
// Nil pointers become empty strings.
func (p *MDMProfileSpec) SetBaseItem(v BaseItem) {
if v.Path != nil {
p.Path = *v.Path
} else {
p.Path = ""
}
if v.Paths != nil {
p.Paths = *v.Paths
} else {
p.Paths = ""
}
}
// UnmarshalJSON implements the json.Unmarshaler interface to add backwards
// compatibility to previous ways to define profile specs.
func (p *MDMProfileSpec) UnmarshalJSON(data []byte) error {

27
server/fleet/spec.go Normal file
View file

@ -0,0 +1,27 @@
package fleet
// BaseItem provides path/paths fields for types that can reference external
// files in GitOps YAML configurations.
type BaseItem struct {
Path *string `json:"path"`
Paths *string `json:"paths"`
}
// SupportsFileInclude is implemented by types that can reference external
// files via path/paths fields in GitOps YAML.
type SupportsFileInclude interface {
GetBaseItem() BaseItem
SetBaseItem(v BaseItem)
}
// GetBaseItem returns the current BaseItem value.
// Types that embed BaseItem inherit this method via promotion.
func (b *BaseItem) GetBaseItem() BaseItem {
return *b
}
// SetBaseItem sets the BaseItem value.
// Types that embed BaseItem inherit this method via promotion.
func (b *BaseItem) SetBaseItem(v BaseItem) {
*b = v
}

View file

@ -135,6 +135,7 @@ github.com/fleetdm/fleet/v4/server/fleet/WindowsUpdates GracePeriodDays optjson.
github.com/fleetdm/fleet/v4/server/fleet/MDM MacOSSettings fleet.MacOSSettings
github.com/fleetdm/fleet/v4/server/fleet/MacOSSettings CustomSettings []fleet.MDMProfileSpec
github.com/fleetdm/fleet/v4/server/fleet/MDMProfileSpec Path string
github.com/fleetdm/fleet/v4/server/fleet/MDMProfileSpec Paths string
github.com/fleetdm/fleet/v4/server/fleet/MDMProfileSpec Labels []string
github.com/fleetdm/fleet/v4/server/fleet/MDMProfileSpec LabelsIncludeAll []string
github.com/fleetdm/fleet/v4/server/fleet/MDMProfileSpec LabelsIncludeAny []string

View file

@ -1,4 +1,5 @@
github.com/fleetdm/fleet/v4/server/fleet/MDMProfileSpec Path string
github.com/fleetdm/fleet/v4/server/fleet/MDMProfileSpec Paths string
github.com/fleetdm/fleet/v4/server/fleet/MDMProfileSpec Labels []string
github.com/fleetdm/fleet/v4/server/fleet/MDMProfileSpec LabelsIncludeAll []string
github.com/fleetdm/fleet/v4/server/fleet/MDMProfileSpec LabelsIncludeAny []string

View file

@ -51,6 +51,7 @@ github.com/fleetdm/fleet/v4/server/fleet/WindowsUpdates GracePeriodDays optjson.
github.com/fleetdm/fleet/v4/server/fleet/TeamMDM MacOSSettings fleet.MacOSSettings
github.com/fleetdm/fleet/v4/server/fleet/MacOSSettings CustomSettings []fleet.MDMProfileSpec
github.com/fleetdm/fleet/v4/server/fleet/MDMProfileSpec Path string
github.com/fleetdm/fleet/v4/server/fleet/MDMProfileSpec Paths string
github.com/fleetdm/fleet/v4/server/fleet/MDMProfileSpec Labels []string
github.com/fleetdm/fleet/v4/server/fleet/MDMProfileSpec LabelsIncludeAll []string
github.com/fleetdm/fleet/v4/server/fleet/MDMProfileSpec LabelsIncludeAny []string

View file

@ -22,6 +22,7 @@ github.com/fleetdm/fleet/v4/server/fleet/WindowsUpdates GracePeriodDays optjson.
github.com/fleetdm/fleet/v4/server/fleet/TeamMDM MacOSSettings fleet.MacOSSettings
github.com/fleetdm/fleet/v4/server/fleet/MacOSSettings CustomSettings []fleet.MDMProfileSpec
github.com/fleetdm/fleet/v4/server/fleet/MDMProfileSpec Path string
github.com/fleetdm/fleet/v4/server/fleet/MDMProfileSpec Paths string
github.com/fleetdm/fleet/v4/server/fleet/MDMProfileSpec Labels []string
github.com/fleetdm/fleet/v4/server/fleet/MDMProfileSpec LabelsIncludeAll []string
github.com/fleetdm/fleet/v4/server/fleet/MDMProfileSpec LabelsIncludeAny []string

3
website/.sailsrc vendored
View file

@ -1,8 +1,7 @@
{
"generators": {
"modules": {
"landing-page": "./generators/landing-page",
"gitops": "./generators/gitops"
"landing-page": "./generators/landing-page"
}
},
"_generatedWith": {

View file

@ -1,17 +0,0 @@
To test:
`cd website/`
Next:
`rm -rf /tmp/it-and-security/ && sails generate gitops && mv it-and-security /tmp/it-and-security`
Last:
If you use VS Code or Cursor:
`code /tmp/it-and-security/`
If you use Sublime:
`subl /tmp/it-and-security/`

View file

@ -1,93 +0,0 @@
module.exports = {
targets: {
'./it-and-security/README.md': { copy: './README.md.template' },
'./it-and-security/.github': { folder: {} },
'./it-and-security/.github/fleet-gitops': { folder: {} },
'./it-and-security/.github/fleet-gitops/action.yml': { copy: './github/fleet-gitops/action.yml.template' },
'./it-and-security/.github/fleet-gitops/gitops.sh': { copy: './github/fleet-gitops/gitops.sh.template' },
'./it-and-security/.github/workflows': { folder: {} },
'./it-and-security/.github/workflows/workflow.yml': { copy: './github/workflows/workflow.yml.template' },
'./it-and-security/.gitlab-ci.yml': { copy: './gitlab-ci.yml.template' },
'./it-and-security/.gitignore': { copy: './gitignore.template' },
'./it-and-security/default.yml': { copy: './default.yml.template' },
'./it-and-security/fleets/': { folder: {} },
'./it-and-security/fleets/workstations.yml': { copy: './fleets/workstations.yml.template' },
'./it-and-security/fleets/personal-mobile-devices.yml': { copy: './fleets/personal-mobile-devices.yml.template' },
'./it-and-security/labels/': { folder: {} },
'./it-and-security/labels/apple-silicon-macos-hosts.yml': { copy: './labels/apple-silicon-macos-hosts.yml.template' },
'./it-and-security/labels/x86-based-windows-hosts.yml': { copy: './labels/x86-based-windows-hosts.yml.template' },
'./it-and-security/labels/arm-based-windows-hosts.yml': { copy: './labels/arm-based-windows-hosts.yml.template' },
'./it-and-security/labels/debian-based-linux-hosts.yml': { copy: './labels/debian-based-linux-hosts.yml.template' },
'./it-and-security/platforms/': { folder: {} },
'./it-and-security/platforms/linux': { folder: {} },
'./it-and-security/platforms/linux/policies/.gitkeep': { copy: './gitkeep.template' },
'./it-and-security/platforms/linux/reports/.gitkeep': { copy: './gitkeep.template' },
'./it-and-security/platforms/linux/scripts/.gitkeep': { copy: './gitkeep.template' },
'./it-and-security/platforms/linux/software/.gitkeep': { copy: './gitkeep.template' },
'./it-and-security/platforms/windows': { folder: {} },
'./it-and-security/platforms/windows/configuration-profiles/.gitkeep': { copy: './gitkeep.template' },
'./it-and-security/platforms/windows/policies/.gitkeep': { copy: './gitkeep.template' },
'./it-and-security/platforms/windows/reports/.gitkeep': { copy: './gitkeep.template' },
'./it-and-security/platforms/windows/scripts/.gitkeep': { copy: './gitkeep.template' },
'./it-and-security/platforms/windows/software/.gitkeep': { copy: './gitkeep.template' },
'./it-and-security/platforms/macos': { folder: {} },
'./it-and-security/platforms/macos/configuration-profiles/.gitkeep': { copy: './gitkeep.template' },
'./it-and-security/platforms/macos/declaration-profiles/.gitkeep': { copy: './gitkeep.template' },
'./it-and-security/platforms/macos/enrollment-profiles/.gitkeep': { copy: './gitkeep.template' },
'./it-and-security/platforms/macos/enrollment-profiles/automatic-enrollment.dep.json': { copy: './platforms/macos/enrollment-profiles/automatic-enrollment.dep.json.template' },
'./it-and-security/platforms/macos/commands/.gitkeep': { copy: './gitkeep.template' },
'./it-and-security/platforms/macos/policies/.gitkeep': { copy: './gitkeep.template' },
'./it-and-security/platforms/macos/policies/all-software-updates-installed.yml': { copy: './platforms/macos/policies/all-software-updates-installed.yml' },
'./it-and-security/platforms/macos/reports/.gitkeep': { copy: './gitkeep.template' },
'./it-and-security/platforms/macos/scripts/.gitkeep': { copy: './gitkeep.template' },
'./it-and-security/platforms/macos/software/.gitkeep': { copy: './gitkeep.template' },
'./it-and-security/platforms/ios': { folder: {} },
'./it-and-security/platforms/ios/configuration-profiles/.gitkeep': { copy: './gitkeep.template' },
'./it-and-security/platforms/ios/declaration-profiles/.gitkeep': { copy: './gitkeep.template' },
'./it-and-security/platforms/ipados': { folder: {} },
'./it-and-security/platforms/ipados/configuration-profiles/.gitkeep': { copy: './gitkeep.template' },
'./it-and-security/platforms/ipados/declaration-profiles/.gitkeep': { copy: './gitkeep.template' },
// './it-and-security/platforms/tvos': { folder: {} },
// './it-and-security/platforms/tvos/configuration-profiles/.gitkeep': { copy: './gitkeep.template' },
'./it-and-security/platforms/android': { folder: {} },
'./it-and-security/platforms/android/configuration-profiles/.gitkeep': { copy: './gitkeep.template' },
'./it-and-security/platforms/android/managed-app-configurations/.gitkeep': { copy: './gitkeep.template' },
'./it-and-security/platforms/all/': { folder: {} },
'./it-and-security/platforms/all/icons/.gitkeep': { copy: './gitkeep.template' },
'./it-and-security/platforms/all/reports/.gitkeep': { copy: './gitkeep.template' },
'./it-and-security/platforms/all/policies/.gitkeep': { copy: './gitkeep.template' },
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
// • e.g. create a folder:
// ```
// './hey_look_a_folder': { folder: {} }
// ```
//
// • e.g. create a dynamically-named file relative to `scope.rootPath`
// (defined by the `filename` scope variable).
//
// The `template` helper reads the specified template, making the
// entire scope available to it (uses underscore/JST/ejs syntax).
// Then the file is copied into the specified destination (on the left).
// ```
// './:filename': { template: 'example.template.js' },
// ```
//
// • See https://sailsjs.com/docs/concepts/extending-sails/generators for more documentation.
// (Or visit https://sailsjs.com/support and talk to a maintainer of a core or community generator.)
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
},
/**
* The absolute path to the `templates` for this generator
* (for use with the `template` and `copy` builtins)
*
* @type {String}
*/
templatesDirectory: require('path').resolve(__dirname, './templates')
};