Support custom host vitals in host name templates (#49586)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> **Related issue:** Resolves #49489 Custom host vitals were skipped when host name template enforcement (#38806) shipped, since both features were in development at the same time. This adds `$FLEET_HOST_VITAL_<id>` support to host name templates, matching the existing secret-variable pattern (validation, per-host resolution, resend on value change). I also introduced a new `IsInvalidReferencedCustomHostVitalsError` call after Copilot's comment below. # Checklist for submitter - [x] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files) for more information. ## Testing - [x] Added/updated automated tests - [x] QA'd all new/changed functionality manually <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit ## Summary by CodeRabbit * **New Features** * Added support for `$FLEET_HOST_VITAL_<id>` in Apple host name templates. * Device-name template reconciliation now expands referenced per-host vital values and updates automatically when those values change. * **Bug Fixes** * Prevents deleting custom host vitals that are referenced by host name templates. * If a referenced vital has no value for a host, device-name delivery is marked failed for that host (retryable). * **Improved Error Handling** * Refined validation behavior so unknown/malformed vital references return user-facing invalid-argument errors, while infrastructure errors propagate unchanged. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -0,0 +1 @@
|
||||
* Added support for `$FLEET_HOST_VITAL_<id>` custom host vital variables in host name templates, including per-host resolution, validation of referenced vital IDs, and automatic re-delivery when a host's vital value changes.
|
||||
@@ -58,6 +58,9 @@ func (svc *Service) AddFleetMaintainedApp(
|
||||
return 0, ctxerr.Wrap(ctx, err, "transient server issue validating embedded secrets")
|
||||
}
|
||||
if err := svc.ds.ValidateReferencedCustomHostVitals(ctx, []string{installScript, postInstallScript, uninstallScript}); err != nil {
|
||||
if !fleet.IsInvalidReferencedCustomHostVitalsError(err) {
|
||||
return 0, ctxerr.Wrap(ctx, err, "validating referenced custom host vitals")
|
||||
}
|
||||
var argErr *fleet.InvalidArgumentError
|
||||
argErr = svc.validateReferencedCustomHostVitalsOnScript(ctx, "install script", &installScript, argErr)
|
||||
argErr = svc.validateReferencedCustomHostVitalsOnScript(ctx, "post-install script", &postInstallScript, argErr)
|
||||
|
||||
@@ -141,6 +141,9 @@ func (svc *Service) SetSetupExperienceScript(ctx context.Context, teamID *uint,
|
||||
return fleet.NewInvalidArgumentError("script", err.Error())
|
||||
}
|
||||
if err := svc.ds.ValidateReferencedCustomHostVitals(ctx, []string{script.ScriptContents}); err != nil {
|
||||
if !fleet.IsInvalidReferencedCustomHostVitalsError(err) {
|
||||
return ctxerr.Wrap(ctx, err, "validating referenced custom host vitals")
|
||||
}
|
||||
return fleet.NewInvalidArgumentError("script", err.Error())
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package service
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"testing"
|
||||
@@ -10,6 +11,7 @@ import (
|
||||
|
||||
"github.com/WatchBeam/clock"
|
||||
"github.com/fleetdm/fleet/v4/pkg/optjson"
|
||||
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
"github.com/fleetdm/fleet/v4/server/mock"
|
||||
"github.com/fleetdm/fleet/v4/server/ptr"
|
||||
@@ -333,6 +335,28 @@ func TestSetupExperienceScriptRejectsUnknownCustomHostVital(t *testing.T) {
|
||||
require.False(t, ds.SetSetupExperienceScriptFuncInvoked)
|
||||
}
|
||||
|
||||
func TestSetupExperienceScriptCustomHostVitalInfraErrorPropagates(t *testing.T) {
|
||||
ctx := test.UserContext(context.Background(), test.UserAdmin)
|
||||
ds := new(mock.Store)
|
||||
svc, _ := newTestServiceWithMock(t, ds)
|
||||
|
||||
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
|
||||
return &fleet.AppConfig{}, nil
|
||||
}
|
||||
ds.ValidateEmbeddedSecretsFunc = func(ctx context.Context, documents []string) error { return nil }
|
||||
ds.ValidateReferencedCustomHostVitalsFunc = func(ctx context.Context, documents []string) error {
|
||||
return ctxerr.Wrap(ctx, errors.New("connection refused"), "validating custom host vitals")
|
||||
}
|
||||
ds.SetSetupExperienceScriptFunc = func(ctx context.Context, script *fleet.Script) error { return nil }
|
||||
|
||||
err := svc.SetSetupExperienceScript(ctx, nil, "potato.sh", bytes.NewReader([]byte("echo $FLEET_HOST_VITAL_99")))
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "connection refused")
|
||||
var invalidArgErr *fleet.InvalidArgumentError
|
||||
require.NotErrorAs(t, err, &invalidArgErr, "an infrastructure failure must not be reported as invalid input (422)")
|
||||
require.False(t, ds.SetSetupExperienceScriptFuncInvoked)
|
||||
}
|
||||
|
||||
// TestSetupExperienceNextStepPolicyGated covers the policy-gated (Windows/Linux) branch of SetupExperienceNextStep: the policy is
|
||||
// used only as a gate (pass -> skip, fail -> install via the normal ForSetupExperience path), the item is held running while
|
||||
// awaiting a fresh result, an out-of-scope gating policy falls back to installing, and the host policy clock is reset once when a
|
||||
|
||||
@@ -118,6 +118,9 @@ func (svc *Service) UploadSoftwareInstaller(ctx context.Context, payload *fleet.
|
||||
}
|
||||
|
||||
if err := svc.ds.ValidateReferencedCustomHostVitals(ctx, []string{payload.InstallScript, payload.PostInstallScript, payload.UninstallScript}); err != nil {
|
||||
if !fleet.IsInvalidReferencedCustomHostVitalsError(err) {
|
||||
return nil, ctxerr.Wrap(ctx, err, "validating referenced custom host vitals")
|
||||
}
|
||||
// Redo per-script to report which script references the undefined custom host vital.
|
||||
var argErr *fleet.InvalidArgumentError
|
||||
argErr = svc.validateReferencedCustomHostVitalsOnScript(ctx, "install script", &payload.InstallScript, argErr)
|
||||
@@ -399,6 +402,9 @@ func (svc *Service) UpdateSoftwareInstaller(ctx context.Context, payload *fleet.
|
||||
return nil, ctxerr.Wrap(ctx, err, "transient server issue validating embedded secrets")
|
||||
}
|
||||
if err := svc.ds.ValidateReferencedCustomHostVitals(ctx, scripts); err != nil {
|
||||
if !fleet.IsInvalidReferencedCustomHostVitalsError(err) {
|
||||
return nil, ctxerr.Wrap(ctx, err, "validating referenced custom host vitals")
|
||||
}
|
||||
var argErr *fleet.InvalidArgumentError
|
||||
argErr = svc.validateReferencedCustomHostVitalsOnScript(ctx, "install script", payload.InstallScript, argErr)
|
||||
argErr = svc.validateReferencedCustomHostVitalsOnScript(ctx, "post-install script", payload.PostInstallScript, argErr)
|
||||
@@ -2720,6 +2726,9 @@ func (svc *Service) BatchSetSoftwareInstallers(
|
||||
return "", ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("script", err.Error()))
|
||||
}
|
||||
if err := svc.ds.ValidateReferencedCustomHostVitals(ctx, allScripts); err != nil {
|
||||
if !fleet.IsInvalidReferencedCustomHostVitalsError(err) {
|
||||
return "", ctxerr.Wrap(ctx, err, "validating referenced custom host vitals")
|
||||
}
|
||||
return "", ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("script", err.Error()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -351,6 +352,37 @@ func (ds *Datastore) resendDeviceNamesForSecretChange(ctx context.Context, chang
|
||||
return nil
|
||||
}
|
||||
|
||||
// resendDeviceNameForCustomHostVital re-queues the host's device-name
|
||||
// enforcement row if its applicable (team or No-team) name template
|
||||
// references $FLEET_HOST_VITAL_<vitalID>, so the cron re-resolves with the
|
||||
// host's newly-set value. Mirrors resendMDMProfilesForCustomHostVital's
|
||||
// content-match precision: a host whose template doesn't reference this vital
|
||||
// gets no needless resend. Must run in the same transaction as the value
|
||||
// write (see SetHostCustomHostVitalValue) so the reconciler never reads a
|
||||
// stale value.
|
||||
func resendDeviceNameForCustomHostVital(ctx context.Context, tx sqlx.ExtContext, hostID, vitalID uint) error {
|
||||
var tmpl string
|
||||
err := sqlx.GetContext(ctx, tx, &tmpl, `
|
||||
SELECT COALESCE(
|
||||
CASE WHEN h.team_id IS NULL
|
||||
THEN `+deviceNameNoTeamTemplateExpr+`
|
||||
ELSE (SELECT t.config->>'$.mdm.name_template' FROM teams t WHERE t.id = h.team_id)
|
||||
END, '')
|
||||
FROM hosts h WHERE h.id = ?`, hostID)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil
|
||||
}
|
||||
return ctxerr.Wrap(ctx, err, "get host name template for custom host vital resend")
|
||||
}
|
||||
|
||||
if tmpl == "" || !fleet.ContainsVar(tmpl, fmt.Sprintf("%s%d", fleet.CustomHostVitalPrefix, vitalID)) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return reconcileHostDeviceNamesForHostsDB(ctx, tx, []uint{hostID})
|
||||
}
|
||||
|
||||
func (ds *Datastore) ReconcileHostDeviceNamesForHosts(ctx context.Context, hostIDs []uint) error {
|
||||
if len(hostIDs) == 0 {
|
||||
return nil
|
||||
|
||||
@@ -152,7 +152,8 @@ type customHostVitalRefEntity struct {
|
||||
}
|
||||
|
||||
// customHostVitalUsedBy scans script_contents, Apple configuration profiles,
|
||||
// Apple declarations, and Windows configuration profiles for a
|
||||
// Apple declarations, Windows configuration profiles, software installer and
|
||||
// setup-experience scripts, and team/No-team host name templates for a
|
||||
// $FLEET_HOST_VITAL_<id> (or ${FLEET_HOST_VITAL_<id>}) reference to the given
|
||||
// vital id. It returns a *fleet.CustomHostVitalUsedInfo describing the first
|
||||
// referencing entity found, or nil if unreferenced. Mirrors the scan structure
|
||||
@@ -217,6 +218,25 @@ func (ds *Datastore) customHostVitalUsedBy(ctx context.Context, tx sqlx.ExtConte
|
||||
JOIN script_contents sc ON sc.id = ses.script_content_id
|
||||
LEFT JOIN teams t ON t.id = ses.team_id;`,
|
||||
},
|
||||
// Host name templates aren't scripts/profiles, but the token can appear in
|
||||
// a team's (or "No team"'s) name_template, same as DeleteSecretVariable
|
||||
// scans for $FLEET_SECRET_* there. A team's name_template is a plain
|
||||
// string that always serializes into the config JSON (as "" when unset),
|
||||
// and the No-team template is an optjson that serializes to null when
|
||||
// unset, so filter both on a non-empty resolved value rather than
|
||||
// IS NOT NULL (avoids scanning a NULL contents column).
|
||||
{
|
||||
desc: "get host name template contents",
|
||||
stmt: `SELECT 'host_name_template' AS entity, 'Host name' AS name,
|
||||
t.name AS team_name, t.config->>'$.mdm.name_template' AS contents
|
||||
FROM teams t
|
||||
WHERE COALESCE(t.config->>'$.mdm.name_template', '') != ''
|
||||
UNION ALL
|
||||
SELECT 'host_name_template' AS entity, 'Host name' AS name,
|
||||
'Unassigned' AS team_name, json_value->>'$.mdm.name_template' AS contents
|
||||
FROM app_config_json
|
||||
WHERE COALESCE(json_value->>'$.mdm.name_template', '') != '';`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, scan := range scans {
|
||||
@@ -297,6 +317,13 @@ func (ds *Datastore) SetHostCustomHostVitalValue(ctx context.Context, hostID uin
|
||||
if err := resendMDMProfilesForCustomHostVital(ctx, tx, hostID, vitalID); err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "resend mdm profiles for custom host vital value change")
|
||||
}
|
||||
|
||||
// Re-queue the host's device-name enforcement row (if its name template
|
||||
// references the vital), so the cron re-resolves the name with the new
|
||||
// value. Same transaction as the value write, for the same reason as above.
|
||||
if err := resendDeviceNameForCustomHostVital(ctx, tx, hostID, vitalID); err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "resend device name for custom host vital value change")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ func TestCustomHostVitals(t *testing.T) {
|
||||
{"DeleteCustomHostVital", testDeleteCustomHostVital},
|
||||
{"DeleteUsedCustomHostVital", testDeleteUsedCustomHostVital},
|
||||
{"SetHostValueResendsReferencingProfiles", testSetHostCustomHostVitalValueResendsProfiles},
|
||||
{"SetHostValueResendsReferencingDeviceName", testSetHostCustomHostVitalValueResendsDeviceName},
|
||||
{"ReconcileSnapshotMarksVitalDeclarations", testReconcileSnapshotMarksVitalDeclarations},
|
||||
{"ValidateReferencedCustomHostVitalsRejectsMalformed", testValidateReferencedCustomHostVitalsRejectsMalformed},
|
||||
}
|
||||
@@ -484,6 +485,58 @@ func testDeleteUsedCustomHostVital(t *testing.T, ds *Datastore) {
|
||||
require.NoError(t, ds.DeleteSetupExperienceScript(ctx, &foobarTeam.ID))
|
||||
})
|
||||
|
||||
t.Run("host name templates", func(t *testing.T) {
|
||||
teamVitalID := createCustomHostVital(t, ds, "HT_TEAM")
|
||||
_, err := ds.writer(ctx).ExecContext(ctx,
|
||||
`UPDATE teams SET config = JSON_SET(config, '$.mdm.name_template', ?) WHERE id = ?`,
|
||||
fmt.Sprintf("WS-$%s%d", fleet.CustomHostVitalPrefix, teamVitalID), foobarTeam.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = ds.DeleteCustomHostVital(ctx, teamVitalID)
|
||||
require.Error(t, err)
|
||||
var useErr *fleet.CustomHostVitalUsedError
|
||||
require.ErrorAs(t, err, &useErr)
|
||||
require.Equal(t, teamVitalID, useErr.CustomHostVitalID)
|
||||
require.Equal(t, "HT_TEAM", useErr.CustomHostVitalName)
|
||||
require.Equal(t, fleet.CustomHostVitalEntityHostNameTemplate, useErr.Entity.Type)
|
||||
require.Equal(t, "Foobar", useErr.Entity.FleetName)
|
||||
require.Contains(t, err.Error(), "host name template")
|
||||
|
||||
// Clearing the team's template unblocks the delete.
|
||||
_, err = ds.writer(ctx).ExecContext(ctx,
|
||||
`UPDATE teams SET config = JSON_SET(config, '$.mdm.name_template', '') WHERE id = ?`, foobarTeam.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
name, err := ds.DeleteCustomHostVital(ctx, teamVitalID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "HT_TEAM", name)
|
||||
|
||||
// The "No team" (global) template blocks the delete the same way.
|
||||
noTeamVitalID := createCustomHostVital(t, ds, "HT_NOTEAM")
|
||||
_, err = ds.writer(ctx).ExecContext(ctx,
|
||||
`UPDATE app_config_json SET json_value = JSON_SET(json_value, '$.mdm.name_template', ?)`,
|
||||
fmt.Sprintf("WS-${%s%d}", fleet.CustomHostVitalPrefix, noTeamVitalID))
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = ds.DeleteCustomHostVital(ctx, noTeamVitalID)
|
||||
require.Error(t, err)
|
||||
useErr = nil
|
||||
require.ErrorAs(t, err, &useErr)
|
||||
require.Equal(t, noTeamVitalID, useErr.CustomHostVitalID)
|
||||
require.Equal(t, "HT_NOTEAM", useErr.CustomHostVitalName)
|
||||
require.Equal(t, fleet.CustomHostVitalEntityHostNameTemplate, useErr.Entity.Type)
|
||||
require.Equal(t, "Unassigned", useErr.Entity.FleetName)
|
||||
|
||||
// Clearing the global template as well unblocks the delete again.
|
||||
_, err = ds.writer(ctx).ExecContext(ctx,
|
||||
`UPDATE app_config_json SET json_value = JSON_SET(json_value, '$.mdm.name_template', CAST('null' AS JSON))`)
|
||||
require.NoError(t, err)
|
||||
|
||||
name, err = ds.DeleteCustomHostVital(ctx, noTeamVitalID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "HT_NOTEAM", name)
|
||||
})
|
||||
|
||||
t.Run("host vitals labels", func(t *testing.T) {
|
||||
// A host-vitals label references the vital by id in its criteria JSON,
|
||||
// not via the $FLEET_HOST_VITAL_<id> token.
|
||||
@@ -590,6 +643,66 @@ func testSetHostCustomHostVitalValueResendsProfiles(t *testing.T, ds *Datastore)
|
||||
require.Nil(t, declStatus, "declaration should be reset (NULL status) so the DDM reconciler re-delivers it")
|
||||
}
|
||||
|
||||
// testSetHostCustomHostVitalValueResendsDeviceName covers the device-name side
|
||||
// of the resend-on-value-change hook: setting the value of a vital referenced
|
||||
// by the host's (team) name template re-queues its device-name enforcement
|
||||
// row, but setting a vital the template doesn't reference leaves the row
|
||||
// untouched, mirroring the precision of the profile-resend case above. Also
|
||||
// covers the same behavior for a No-team ("Unassigned") host, whose template
|
||||
// lives in the global app config rather than a team's config.
|
||||
func testSetHostCustomHostVitalValueResendsDeviceName(t *testing.T, ds *Datastore) {
|
||||
ctx := t.Context()
|
||||
|
||||
vitalID := createCustomHostVital(t, ds, "FUNCTION")
|
||||
otherID := createCustomHostVital(t, ds, "OTHER")
|
||||
|
||||
team, err := ds.NewTeam(ctx, &fleet.Team{Name: "device-name-vital-team"})
|
||||
require.NoError(t, err)
|
||||
_, err = ds.writer(ctx).ExecContext(ctx,
|
||||
`UPDATE teams SET config = JSON_SET(config, '$.mdm.name_template', ?) WHERE id = ?`,
|
||||
fmt.Sprintf("WS-$%s%d", fleet.CustomHostVitalPrefix, vitalID), team.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
host := enrollAppleHostForDeviceName(t, ds, "vital-mac", "darwin", team.ID, false)
|
||||
require.NoError(t, ds.BulkUpsertHostDeviceNameEnforcement(ctx, &team.ID))
|
||||
require.NoError(t, ds.SetHostDeviceNameStatus(ctx, host.UUID, fleet.MDMDeliveryVerifying, nil, "WS-Engineering", ""))
|
||||
|
||||
// Setting a vital the template doesn't reference leaves the settled row alone.
|
||||
require.NoError(t, ds.SetHostCustomHostVitalValue(ctx, host.ID, otherID, "ignored"))
|
||||
require.Equal(t, fleet.MDMDeliveryVerifying, *getDeviceNameRow(t, ds, host.UUID).Status)
|
||||
|
||||
// Setting the referenced vital's value re-queues the row (status reset to
|
||||
// NULL) so the cron re-resolves the name with the new value.
|
||||
require.NoError(t, ds.SetHostCustomHostVitalValue(ctx, host.ID, vitalID, "Engineering"))
|
||||
require.Nil(t, getDeviceNameRow(t, ds, host.UUID).Status)
|
||||
|
||||
// Same behavior for a No-team ("Unassigned") host: its template lives in
|
||||
// app_config_json instead of a team's config.
|
||||
_, err = ds.writer(ctx).ExecContext(ctx,
|
||||
`UPDATE app_config_json SET json_value = JSON_SET(json_value, '$.mdm.name_template', ?)`,
|
||||
fmt.Sprintf("NT-$%s%d", fleet.CustomHostVitalPrefix, vitalID))
|
||||
require.NoError(t, err)
|
||||
defer func() {
|
||||
_, err := ds.writer(ctx).ExecContext(ctx,
|
||||
`UPDATE app_config_json SET json_value = JSON_SET(json_value, '$.mdm.name_template', CAST('null' AS JSON))`)
|
||||
require.NoError(t, err)
|
||||
}()
|
||||
|
||||
noTeamHost := enrollAppleHostForDeviceName(t, ds, "vital-mac-noteam", "darwin", team.ID, false)
|
||||
_, err = ds.writer(ctx).ExecContext(ctx, `UPDATE hosts SET team_id = NULL WHERE id = ?`, noTeamHost.ID)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, ds.BulkUpsertHostDeviceNameEnforcement(ctx, nil))
|
||||
require.NoError(t, ds.SetHostDeviceNameStatus(ctx, noTeamHost.UUID, fleet.MDMDeliveryVerifying, nil, "NT-Engineering", ""))
|
||||
|
||||
// Setting a vital the No-team template doesn't reference leaves the row alone.
|
||||
require.NoError(t, ds.SetHostCustomHostVitalValue(ctx, noTeamHost.ID, otherID, "ignored"))
|
||||
require.Equal(t, fleet.MDMDeliveryVerifying, *getDeviceNameRow(t, ds, noTeamHost.UUID).Status)
|
||||
|
||||
// Setting the referenced vital's value re-queues the No-team row too.
|
||||
require.NoError(t, ds.SetHostCustomHostVitalValue(ctx, noTeamHost.ID, vitalID, "Engineering"))
|
||||
require.Nil(t, getDeviceNameRow(t, ds, noTeamHost.UUID).Status)
|
||||
}
|
||||
|
||||
// The DDM reconcile snapshot must flag declarations that reference a custom host
|
||||
// vital as HasFleetVariables, so the reconciler stamps variables_updated_at on
|
||||
// the host declaration row — the signal handleDeclarationItems relies on to load
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package fleet
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -51,7 +52,7 @@ func (e MissingCustomHostVitalsError) Error() string {
|
||||
if len(tokens) > 1 {
|
||||
plural = "s"
|
||||
}
|
||||
return fmt.Sprintf("Couldn't add. Custom host vital%s %s is not defined", plural, strings.Join(tokens, ", "))
|
||||
return fmt.Sprintf("Custom host vital%s %s is not defined", plural, strings.Join(tokens, ", "))
|
||||
}
|
||||
|
||||
// InvalidCustomHostVitalRefError is returned on upload when a document contains a
|
||||
@@ -72,7 +73,7 @@ func (e InvalidCustomHostVitalRefError) Error() string {
|
||||
plural = "s"
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"Couldn't add. Invalid custom host vital reference%s %s; the value after $%s must be a custom host vital ID",
|
||||
"Invalid custom host vital reference%s %s; the value after $%s must be a custom host vital ID",
|
||||
plural, strings.Join(tokens, ", "), CustomHostVitalPrefix,
|
||||
)
|
||||
}
|
||||
@@ -105,6 +106,15 @@ func (e MissingCustomHostVitalValueError) Error() string {
|
||||
)
|
||||
}
|
||||
|
||||
// IsInvalidReferencedCustomHostVitalsError reports whether err is a user-input validation failure:
|
||||
// - an unknown vital ID (MissingCustomHostVitalsError)
|
||||
// - or a malformed $FLEET_HOST_VITAL_<x> reference (InvalidCustomHostVitalRefError)
|
||||
func IsInvalidReferencedCustomHostVitalsError(err error) bool {
|
||||
var missing *MissingCustomHostVitalsError
|
||||
var invalid *InvalidCustomHostVitalRefError
|
||||
return errors.As(err, &missing) || errors.As(err, &invalid)
|
||||
}
|
||||
|
||||
// CustomHostVitalEntity identifies the kind of entity that can reference a custom host vital.
|
||||
type CustomHostVitalEntity string
|
||||
|
||||
@@ -116,6 +126,7 @@ const (
|
||||
CustomHostVitalEntitySoftwareInstaller CustomHostVitalEntity = "software_installer"
|
||||
CustomHostVitalEntitySetupExperienceScript CustomHostVitalEntity = "setup_experience_script"
|
||||
CustomHostVitalEntityLabel CustomHostVitalEntity = "label"
|
||||
CustomHostVitalEntityHostNameTemplate CustomHostVitalEntity = "host_name_template"
|
||||
)
|
||||
|
||||
// Describes an entity that references a custom host vital.
|
||||
@@ -136,6 +147,14 @@ type CustomHostVitalUsedInfo struct {
|
||||
|
||||
// Message returns the human-readable "X is used by Y" explanation.
|
||||
func (i CustomHostVitalUsedInfo) Message() string {
|
||||
if i.Entity.Type == CustomHostVitalEntityHostNameTemplate {
|
||||
// there's no separate entity name to report, just the fleet whose template references the vital.
|
||||
return fmt.Sprintf(
|
||||
"Custom host vital %q (used as $%s%d) is used by the host name template in the %q fleet. Please edit or clear the host name template and try again.",
|
||||
i.CustomHostVitalName, CustomHostVitalPrefix, i.CustomHostVitalID, i.Entity.FleetName,
|
||||
)
|
||||
}
|
||||
|
||||
noun, action := "configuration profile", "Please delete the configuration profile and try again."
|
||||
switch i.Entity.Type {
|
||||
case CustomHostVitalEntityScript:
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package fleet
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@@ -45,6 +47,22 @@ func TestMissingCustomHostVitalsError(t *testing.T) {
|
||||
require.Contains(t, multi.Error(), `"$FLEET_HOST_VITAL_9"`)
|
||||
}
|
||||
|
||||
func TestCustomHostVitalUsedInfoMessageHostNameTemplate(t *testing.T) {
|
||||
info := CustomHostVitalUsedInfo{
|
||||
CustomHostVitalID: 5,
|
||||
CustomHostVitalName: "FUNCTION",
|
||||
Entity: EntityUsingCustomHostVital{
|
||||
Type: CustomHostVitalEntityHostNameTemplate,
|
||||
FleetName: "Workstations",
|
||||
},
|
||||
}
|
||||
want := `Custom host vital "FUNCTION" (used as $FLEET_HOST_VITAL_5) is used by the host name template in the "Workstations" fleet. Please edit or clear the host name template and try again.`
|
||||
require.Equal(t, want, info.Message())
|
||||
|
||||
err := (&CustomHostVitalUsedError{CustomHostVitalUsedInfo: info}).Error()
|
||||
require.Equal(t, want, err)
|
||||
}
|
||||
|
||||
func TestMissingCustomHostVitalValueError(t *testing.T) {
|
||||
single := MissingCustomHostVitalValueError{MissingIDs: []uint{5}, MissingNames: []string{"Asset tag"}}
|
||||
require.Equal(
|
||||
@@ -60,3 +78,26 @@ func TestMissingCustomHostVitalValueError(t *testing.T) {
|
||||
require.Contains(t, multi.Error(), "Asset tag ($FLEET_HOST_VITAL_5)")
|
||||
require.Contains(t, multi.Error(), "Department ($FLEET_HOST_VITAL_9)")
|
||||
}
|
||||
|
||||
// TestIsInvalidReferencedCustomHostVitalsError covers the classification
|
||||
// callers of ValidateReferencedCustomHostVitals rely on to decide whether to
|
||||
// report a 422 (unknown ID / malformed reference) or propagate the error as-is
|
||||
// (any other error, e.g. a wrapped DB failure, which must surface as a 500).
|
||||
func TestIsInvalidReferencedCustomHostVitalsError(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{"unknown vital id", &MissingCustomHostVitalsError{MissingIDs: []uint{5}}, true},
|
||||
{"malformed reference", &InvalidCustomHostVitalRefError{Refs: []string{"FLEET_HOST_VITAL_asset_tag"}}, true},
|
||||
{"plain infra error", errors.New("connection refused"), false},
|
||||
{"wrapped infra error", ctxerr.Wrap(t.Context(), errors.New("connection refused"), "validating custom host vitals"), false},
|
||||
{"nil", nil, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
require.Equal(t, c.want, IsInvalidReferencedCustomHostVitalsError(c.err))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,6 +73,12 @@ func varAlternationRegexp(vars []FleetVarName) *regexp.Regexp {
|
||||
// so this is used to strip secret tokens out of a template when computing its fixed-text byte floor.
|
||||
var nameTemplateSecretRegexp = regexp.MustCompile(`\$` + ServerSecretPrefix + `\w+|\$\{` + ServerSecretPrefix + `\w+\}`)
|
||||
|
||||
// nameTemplateVitalRegexp matches a $FLEET_HOST_VITAL_<id> / ${FLEET_HOST_VITAL_<id>}
|
||||
// custom host vital token. Vital values, like secrets, are only known at
|
||||
// resolve time, so this is used to strip vital tokens out of a template when
|
||||
// computing its fixed-text byte floor.
|
||||
var nameTemplateVitalRegexp = regexp.MustCompile(`\$` + CustomHostVitalPrefix + `\w+|\$\{` + CustomHostVitalPrefix + `\w+\}`)
|
||||
|
||||
// ValidateHostNameTemplate validates a host name template and returns the
|
||||
// normalized (trimmed) template that callers should persist.
|
||||
func ValidateHostNameTemplate(tmpl string) (string, error) {
|
||||
@@ -112,6 +118,7 @@ func ValidateHostNameTemplate(tmpl string) (string, error) {
|
||||
// still caught by the cron when it resolves against a host's actual values.
|
||||
literal := nameTemplateVarRegexp.ReplaceAllString(tmpl, "")
|
||||
literal = nameTemplateSecretRegexp.ReplaceAllString(literal, "")
|
||||
literal = nameTemplateVitalRegexp.ReplaceAllString(literal, "")
|
||||
if len(literal) > MaxResolvedHostNameBytes {
|
||||
return "", NewInvalidArgumentError("name_template",
|
||||
fmt.Sprintf("Host name template's fixed text can't be longer than %d bytes (the device name limit).", MaxResolvedHostNameBytes))
|
||||
@@ -124,7 +131,9 @@ func ValidateHostNameTemplate(tmpl string) (string, error) {
|
||||
// syntactically (see ValidateHostNameTemplate) and additionally verifies that
|
||||
// every custom (secret, $FLEET_SECRET_*) variable it references is defined in
|
||||
// the datastore, mirroring how scripts and profiles validate embedded secrets at
|
||||
// save time. It returns the normalized template to persist.
|
||||
// save time, and that every custom host vital ($FLEET_HOST_VITAL_<id>) it
|
||||
// references is a known vital ID. It returns the normalized template to
|
||||
// persist.
|
||||
func ValidateHostNameTemplateWithSecrets(ctx context.Context, ds Datastore, tmpl string) (string, error) {
|
||||
validated, err := ValidateHostNameTemplate(tmpl)
|
||||
if err != nil {
|
||||
@@ -143,6 +152,15 @@ func ValidateHostNameTemplateWithSecrets(ctx context.Context, ds Datastore, tmpl
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
// Vital IDs are dynamic (not a fixed allow-list), so an unknown or malformed
|
||||
// $FLEET_HOST_VITAL_<id> reference is only caught here, same as scripts and
|
||||
// profiles validate their own embedded vital references.
|
||||
if err := ds.ValidateReferencedCustomHostVitals(ctx, []string{validated}); err != nil {
|
||||
if IsInvalidReferencedCustomHostVitalsError(err) {
|
||||
return "", NewInvalidArgumentError("name_template", err.Error())
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
return validated, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -75,6 +75,30 @@ func TestValidateHostNameTemplate(t *testing.T) {
|
||||
tmpl: "WS-${FLEET_SECRET_" + strings.Repeat("A", 80) + "}",
|
||||
wantNorm: "WS-${FLEET_SECRET_" + strings.Repeat("A", 80) + "}",
|
||||
},
|
||||
// Custom host vital references are allowed syntactically here too; their
|
||||
// existence is checked separately (needs the datastore), same as secrets.
|
||||
{name: "vital var", tmpl: "$FLEET_HOST_VITAL_5", wantNorm: "$FLEET_HOST_VITAL_5"},
|
||||
{name: "vital var braced", tmpl: "${FLEET_HOST_VITAL_5}", wantNorm: "${FLEET_HOST_VITAL_5}"},
|
||||
{
|
||||
name: "built-in, secret, and vital vars mixed",
|
||||
tmpl: "WS-$FLEET_VAR_HOST_HARDWARE_SERIAL-$FLEET_SECRET_SITE-$FLEET_HOST_VITAL_5",
|
||||
wantNorm: "WS-$FLEET_VAR_HOST_HARDWARE_SERIAL-$FLEET_SECRET_SITE-$FLEET_HOST_VITAL_5",
|
||||
},
|
||||
{
|
||||
// A long vital token doesn't count toward the fixed-text byte floor
|
||||
// either: the substituted value's length isn't known until resolve time.
|
||||
name: "long vital token doesn't hit byte floor",
|
||||
tmpl: "WS-${FLEET_HOST_VITAL_" + strings.Repeat("9", 80) + "}",
|
||||
wantNorm: "WS-${FLEET_HOST_VITAL_" + strings.Repeat("9", 80) + "}",
|
||||
},
|
||||
{
|
||||
// A malformed vital reference (non-numeric suffix) is allowed
|
||||
// syntactically here; ValidateHostNameTemplateWithSecrets rejects it once
|
||||
// the datastore is available to distinguish malformed from unknown IDs.
|
||||
name: "malformed vital ref allowed at the syntax layer",
|
||||
tmpl: "$FLEET_HOST_VITAL_asset_tag",
|
||||
wantNorm: "$FLEET_HOST_VITAL_asset_tag",
|
||||
},
|
||||
{name: "tab control char", tmpl: "bad\tname", wantErr: "control characters"},
|
||||
{name: "rtl override format char", tmpl: "bad\u202ename", wantErr: "control characters"},
|
||||
{name: "zero-width joiner format char", tmpl: "bad\u200dname", wantErr: "control characters"},
|
||||
|
||||
@@ -141,6 +141,25 @@ func ReconcileHostDeviceNames(
|
||||
expandedTmpl = exp.value
|
||||
}
|
||||
|
||||
// Expand any custom host vital ($FLEET_HOST_VITAL_<id>) references with this
|
||||
// host's stored value. Unlike secrets, vital values are per-host, so this
|
||||
// can't be memoized across hosts sharing a template.
|
||||
if len(fleet.ContainsCustomHostVitalIDs(expandedTmpl)) > 0 {
|
||||
withVitals, vitalErr := ds.ExpandCustomHostVitals(ctx, host.HostID, expandedTmpl)
|
||||
if vitalErr != nil {
|
||||
if _, ok := errors.AsType[*fleet.MissingCustomHostVitalValueError](vitalErr); !ok {
|
||||
return ctxerr.Wrap(ctx, vitalErr, "expand host name template custom host vitals")
|
||||
}
|
||||
// A referenced vital exists but has no value set for this host.
|
||||
if err := ds.SetHostDeviceNameStatus(ctx, host.HostUUID, fleet.MDMDeliveryFailed, nil, "",
|
||||
vitalErr.Error()); err != nil {
|
||||
logger.ErrorContext(ctx, "mark device name row failed for missing custom host vital value", "host_uuid", host.HostUUID, "err", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
expandedTmpl = withVitals
|
||||
}
|
||||
|
||||
resolved := fleet.ResolveHostNameTemplate(expandedTmpl, &fleet.Host{
|
||||
UUID: host.HostUUID,
|
||||
HardwareSerial: host.HardwareSerial,
|
||||
|
||||
@@ -2,8 +2,10 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"testing"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/pkg/optjson"
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
"github.com/fleetdm/fleet/v4/server/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -70,3 +72,69 @@ func TestResolveHostNameIDPVars(t *testing.T) {
|
||||
require.Contains(t, detail, "no IdP username for this host")
|
||||
})
|
||||
}
|
||||
|
||||
// TestReconcileHostDeviceNamesExpandsCustomHostVitals covers the per-host
|
||||
// $FLEET_HOST_VITAL_<id> expansion step: a host with no value set for a
|
||||
// referenced vital fails the row (mirroring the missing-secret path), and a
|
||||
// host with a value gets it substituted before resolution. Both scenarios
|
||||
// here settle on a name matching the host's current ComputerName, so the
|
||||
// cron never reaches the MDM commander (nil is safe to pass).
|
||||
func TestReconcileHostDeviceNamesExpandsCustomHostVitals(t *testing.T) {
|
||||
ds := new(mock.Store)
|
||||
logger := slog.New(slog.DiscardHandler)
|
||||
|
||||
const tmpl = "WS-$FLEET_HOST_VITAL_5"
|
||||
ds.AppConfigFunc = func(_ context.Context) (*fleet.AppConfig, error) {
|
||||
return &fleet.AppConfig{
|
||||
MDM: fleet.MDM{
|
||||
EnabledAndConfigured: true,
|
||||
HostNameTemplate: optjson.SetString(tmpl),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
ds.DeactivateHostDeviceNameCommandsFunc = func(_ context.Context, _ []string) error { return nil }
|
||||
|
||||
type recordedStatus struct {
|
||||
status fleet.MDMDeliveryStatus
|
||||
detail string
|
||||
}
|
||||
statuses := map[string]recordedStatus{}
|
||||
ds.SetHostDeviceNameStatusFunc = func(_ context.Context, hostUUID string, status fleet.MDMDeliveryStatus, _ *string, _, detail string) error {
|
||||
statuses[hostUUID] = recordedStatus{status, detail}
|
||||
return nil
|
||||
}
|
||||
|
||||
t.Run("no value set for the host fails the row", func(t *testing.T) {
|
||||
ds.ListHostsPendingDeviceNameCommandFunc = func(_ context.Context, _ int) ([]fleet.HostDeviceNamePending, error) {
|
||||
return []fleet.HostDeviceNamePending{
|
||||
{HostID: 1, HostUUID: "host-1", HardwareSerial: "SERIAL1", Platform: "darwin", ComputerName: "old-name"},
|
||||
}, nil
|
||||
}
|
||||
ds.ExpandCustomHostVitalsFunc = func(_ context.Context, hostID uint, document string) (string, error) {
|
||||
require.Equal(t, uint(1), hostID)
|
||||
require.Equal(t, tmpl, document)
|
||||
return "", &fleet.MissingCustomHostVitalValueError{MissingIDs: []uint{5}}
|
||||
}
|
||||
|
||||
require.NoError(t, ReconcileHostDeviceNames(t.Context(), ds, nil, logger))
|
||||
require.Equal(t, fleet.MDMDeliveryFailed, statuses["host-1"].status)
|
||||
require.Contains(t, statuses["host-1"].detail, "no value set for this host")
|
||||
})
|
||||
|
||||
t.Run("host's value is substituted and a matching name verifies without a command", func(t *testing.T) {
|
||||
ds.ListHostsPendingDeviceNameCommandFunc = func(_ context.Context, _ int) ([]fleet.HostDeviceNamePending, error) {
|
||||
return []fleet.HostDeviceNamePending{
|
||||
{HostID: 2, HostUUID: "host-2", HardwareSerial: "SERIAL2", Platform: "darwin", ComputerName: "WS-engineering"},
|
||||
}, nil
|
||||
}
|
||||
ds.ExpandCustomHostVitalsFunc = func(_ context.Context, hostID uint, document string) (string, error) {
|
||||
require.Equal(t, uint(2), hostID)
|
||||
require.Equal(t, tmpl, document)
|
||||
return "WS-engineering", nil
|
||||
}
|
||||
|
||||
require.NoError(t, ReconcileHostDeviceNames(t.Context(), ds, nil, logger))
|
||||
require.Equal(t, fleet.MDMDeliveryVerified, statuses["host-2"].status)
|
||||
require.Empty(t, statuses["host-2"].detail)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -471,6 +471,9 @@ func (svc *Service) parseAndValidateAppleConfigProfile(ctx context.Context, team
|
||||
}
|
||||
|
||||
if err := svc.ds.ValidateReferencedCustomHostVitals(ctx, []string{string(data)}); err != nil {
|
||||
if !fleet.IsInvalidReferencedCustomHostVitalsError(err) {
|
||||
return nil, nil, "", ctxerr.Wrap(ctx, err, "validating referenced custom host vitals")
|
||||
}
|
||||
return nil, nil, "", ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("profile", err.Error()))
|
||||
}
|
||||
|
||||
@@ -1056,6 +1059,9 @@ func (svc *Service) parseAndValidateAppleDeclaration(ctx context.Context, teamID
|
||||
|
||||
// Validate custom host vital references (top-level $FLEET_HOST_VITAL_<id>).
|
||||
if err := svc.ds.ValidateReferencedCustomHostVitals(ctx, []string{string(data)}); err != nil {
|
||||
if !fleet.IsInvalidReferencedCustomHostVitalsError(err) {
|
||||
return nil, nil, "", ctxerr.Wrap(ctx, err, "validating referenced custom host vitals")
|
||||
}
|
||||
return nil, nil, "", fleet.NewInvalidArgumentError("profile", err.Error())
|
||||
}
|
||||
|
||||
@@ -3306,6 +3312,9 @@ func (svc *Service) BatchSetMDMAppleProfiles(ctx context.Context, tmID *uint, tm
|
||||
"missing fleet secrets")
|
||||
}
|
||||
if err := svc.ds.ValidateReferencedCustomHostVitals(ctx, []string{string(prof)}); err != nil {
|
||||
if !fleet.IsInvalidReferencedCustomHostVitalsError(err) {
|
||||
return ctxerr.Wrap(ctx, err, "validating referenced custom host vitals")
|
||||
}
|
||||
return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError(fmt.Sprintf("profiles[%d]", i), err.Error()))
|
||||
}
|
||||
mdmProf, err := fleet.NewMDMAppleConfigProfile([]byte(expanded), tmID)
|
||||
|
||||
@@ -31,6 +31,7 @@ import (
|
||||
activity_api "github.com/fleetdm/fleet/v4/server/activity/api"
|
||||
"github.com/fleetdm/fleet/v4/server/authz"
|
||||
"github.com/fleetdm/fleet/v4/server/config"
|
||||
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
|
||||
"github.com/fleetdm/fleet/v4/server/contexts/license"
|
||||
"github.com/fleetdm/fleet/v4/server/contexts/viewer"
|
||||
"github.com/fleetdm/fleet/v4/server/datastore/mysql/mysqltest"
|
||||
@@ -848,6 +849,65 @@ func TestNewMDMAppleConfigProfile(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestNewMDMAppleConfigProfileCustomHostVitalErrors(t *testing.T) {
|
||||
svc, ctx, ds, _ := setupAppleMDMService(t, &fleet.LicenseInfo{Tier: fleet.TierPremium})
|
||||
ctx = viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{GlobalRole: new(fleet.RoleAdmin)}})
|
||||
ds.GetGroupedCertificateAuthoritiesFunc = func(ctx context.Context, includeSecrets bool) (*fleet.GroupedCertificateAuthorities, error) {
|
||||
return &fleet.GroupedCertificateAuthorities{}, nil
|
||||
}
|
||||
mcBytes := mcBytesForTest("Foo", "test.identifier.$FLEET_HOST_VITAL_5", "UUID")
|
||||
|
||||
t.Run("unknown vital id is rejected", func(t *testing.T) {
|
||||
ds.ValidateReferencedCustomHostVitalsFunc = func(ctx context.Context, documents []string) error {
|
||||
return &fleet.MissingCustomHostVitalsError{MissingIDs: []uint{5}}
|
||||
}
|
||||
_, err := svc.NewMDMAppleConfigProfile(ctx, 0, mcBytes, nil, fleet.LabelsIncludeAll, nil)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "FLEET_HOST_VITAL_5")
|
||||
var invalidArgErr *fleet.InvalidArgumentError
|
||||
require.ErrorAs(t, err, &invalidArgErr)
|
||||
})
|
||||
|
||||
t.Run("infrastructure failure propagates instead of being reported as invalid input", func(t *testing.T) {
|
||||
ds.ValidateReferencedCustomHostVitalsFunc = func(ctx context.Context, documents []string) error {
|
||||
return ctxerr.Wrap(ctx, errors.New("connection refused"), "validating custom host vitals")
|
||||
}
|
||||
_, err := svc.NewMDMAppleConfigProfile(ctx, 0, mcBytes, nil, fleet.LabelsIncludeAll, nil)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "connection refused")
|
||||
var invalidArgErr *fleet.InvalidArgumentError
|
||||
require.NotErrorAs(t, err, &invalidArgErr, "an infrastructure failure must not be reported as invalid input (422)")
|
||||
})
|
||||
}
|
||||
|
||||
func TestNewMDMAppleDeclarationCustomHostVitalErrors(t *testing.T) {
|
||||
svc, ctx, ds, _ := setupAppleMDMService(t, &fleet.LicenseInfo{Tier: fleet.TierPremium})
|
||||
ctx = viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{GlobalRole: new(fleet.RoleAdmin)}})
|
||||
b := declBytesForTest("D1", "$FLEET_HOST_VITAL_5")
|
||||
|
||||
t.Run("unknown vital id is rejected", func(t *testing.T) {
|
||||
ds.ValidateReferencedCustomHostVitalsFunc = func(ctx context.Context, documents []string) error {
|
||||
return &fleet.MissingCustomHostVitalsError{MissingIDs: []uint{5}}
|
||||
}
|
||||
_, err := svc.NewMDMAppleDeclaration(ctx, 0, b, nil, "name", fleet.LabelsIncludeAll, nil)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "FLEET_HOST_VITAL_5")
|
||||
var invalidArgErr *fleet.InvalidArgumentError
|
||||
require.ErrorAs(t, err, &invalidArgErr)
|
||||
})
|
||||
|
||||
t.Run("infrastructure failure propagates instead of being reported as invalid input", func(t *testing.T) {
|
||||
ds.ValidateReferencedCustomHostVitalsFunc = func(ctx context.Context, documents []string) error {
|
||||
return ctxerr.Wrap(ctx, errors.New("connection refused"), "validating custom host vitals")
|
||||
}
|
||||
_, err := svc.NewMDMAppleDeclaration(ctx, 0, b, nil, "name", fleet.LabelsIncludeAll, nil)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "connection refused")
|
||||
var invalidArgErr *fleet.InvalidArgumentError
|
||||
require.NotErrorAs(t, err, &invalidArgErr, "an infrastructure failure must not be reported as invalid input (422)")
|
||||
})
|
||||
}
|
||||
|
||||
func mcBytesForTest(name, identifier, uuid string) []byte {
|
||||
return []byte(fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
|
||||
@@ -2524,6 +2524,9 @@ func (svc *Service) BatchSetMDMProfiles(
|
||||
customHostVitalDocs = append(customHostVitalDocs, string(p.Contents))
|
||||
}
|
||||
if err := svc.ds.ValidateReferencedCustomHostVitals(ctx, customHostVitalDocs); err != nil {
|
||||
if !fleet.IsInvalidReferencedCustomHostVitalsError(err) {
|
||||
return ctxerr.Wrap(ctx, err, "validating referenced custom host vitals")
|
||||
}
|
||||
return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("profiles", err.Error()))
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
|
||||
"github.com/fleetdm/fleet/v4/pkg/optjson"
|
||||
activity_api "github.com/fleetdm/fleet/v4/server/activity/api"
|
||||
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
|
||||
"github.com/fleetdm/fleet/v4/server/datastore/mysql/mysqltest"
|
||||
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig"
|
||||
@@ -3184,6 +3185,84 @@ func TestResendHostNameTemplate(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestUpdateMDMHostNameTemplateValidatesCustomHostVitals(t *testing.T) {
|
||||
ds := new(mock.Store)
|
||||
license := &fleet.LicenseInfo{Tier: fleet.TierPremium}
|
||||
svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: license, SkipCreateTestUsers: true})
|
||||
adminCtx := viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{GlobalRole: new(fleet.RoleAdmin)}})
|
||||
|
||||
// Simulate the real datastore: only vital id 1 exists.
|
||||
ds.ValidateReferencedCustomHostVitalsFunc = func(ctx context.Context, documents []string) error {
|
||||
var malformed []string
|
||||
var missing []uint
|
||||
for _, d := range documents {
|
||||
malformed = append(malformed, fleet.ContainsMalformedCustomHostVitalRefs(d)...)
|
||||
for _, id := range fleet.ContainsCustomHostVitalIDs(d) {
|
||||
if id != 1 {
|
||||
missing = append(missing, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(malformed) > 0 {
|
||||
return &fleet.InvalidCustomHostVitalRefError{Refs: malformed}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
return &fleet.MissingCustomHostVitalsError{MissingIDs: missing}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
t.Run("unknown vital id is rejected", func(t *testing.T) {
|
||||
err := svc.UpdateMDMHostNameTemplate(adminCtx, nil, "WS-$FLEET_HOST_VITAL_999")
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "FLEET_HOST_VITAL_999")
|
||||
require.Contains(t, err.Error(), "is not defined")
|
||||
require.True(t, ds.ValidateReferencedCustomHostVitalsFuncInvoked)
|
||||
})
|
||||
|
||||
t.Run("malformed vital ref is rejected", func(t *testing.T) {
|
||||
ds.ValidateReferencedCustomHostVitalsFuncInvoked = false
|
||||
err := svc.UpdateMDMHostNameTemplate(adminCtx, nil, "WS-$FLEET_HOST_VITAL_asset_tag")
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "must be a custom host vital ID")
|
||||
require.True(t, ds.ValidateReferencedCustomHostVitalsFuncInvoked)
|
||||
})
|
||||
|
||||
t.Run("known vital id passes validation and is persisted", func(t *testing.T) {
|
||||
ds.ValidateReferencedCustomHostVitalsFuncInvoked = false
|
||||
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
|
||||
return &fleet.AppConfig{}, nil
|
||||
}
|
||||
var savedTemplate string
|
||||
ds.SaveAppConfigFunc = func(ctx context.Context, conf *fleet.AppConfig) error {
|
||||
savedTemplate = conf.MDM.HostNameTemplate.Value
|
||||
return nil
|
||||
}
|
||||
svc.SetEnterpriseOverrides(fleet.EnterpriseOverrides{
|
||||
ApplyHostNameTemplateChange: func(ctx context.Context, team *fleet.Team, nameTemplate string) error { return nil },
|
||||
})
|
||||
|
||||
err := svc.UpdateMDMHostNameTemplate(adminCtx, nil, "WS-$FLEET_HOST_VITAL_1")
|
||||
require.NoError(t, err)
|
||||
require.True(t, ds.ValidateReferencedCustomHostVitalsFuncInvoked)
|
||||
require.Equal(t, "WS-$FLEET_HOST_VITAL_1", savedTemplate)
|
||||
})
|
||||
|
||||
t.Run("infrastructure failure propagates instead of being reported as invalid input", func(t *testing.T) {
|
||||
ds.ValidateReferencedCustomHostVitalsFuncInvoked = false
|
||||
ds.ValidateReferencedCustomHostVitalsFunc = func(ctx context.Context, documents []string) error {
|
||||
return ctxerr.Wrap(ctx, errors.New("connection refused"), "validating custom host vitals")
|
||||
}
|
||||
|
||||
err := svc.UpdateMDMHostNameTemplate(adminCtx, nil, "WS-$FLEET_HOST_VITAL_1")
|
||||
require.Error(t, err)
|
||||
require.True(t, ds.ValidateReferencedCustomHostVitalsFuncInvoked)
|
||||
require.Contains(t, err.Error(), "connection refused")
|
||||
var invalidArgErr *fleet.InvalidArgumentError
|
||||
require.NotErrorAs(t, err, &invalidArgErr, "an infrastructure failure must not be reported as invalid input (422)")
|
||||
})
|
||||
}
|
||||
|
||||
func TestBatchSetMDMProfilesLabels(t *testing.T) {
|
||||
ds := new(mock.Store)
|
||||
// while the config profiles are not premium-only, teams are and we want to test with teams.
|
||||
|
||||
Reference in New Issue
Block a user