Files
fleet/server/service/custom_host_vitals.go
T
Nico b10642be3c 44954 custom host vitals (#49334)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #44954 

# 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

* **New Features**
* Added Custom Host Vitals management (create/edit/delete, search) with
copyable variable tokens and per-host values.
* Show Custom Host Vitals on host details, including role-based editing.
* Enabled Custom Host Vitals in host-vitals labels and the activity
feed.
* Extended GitOps to manage global Custom Host Vitals declaratively
(including dry-run behavior).
* Split Controls → Variables into Global Variables and Custom Host
Vitals, including routing updates.

* **Bug Fixes**
* Improved validation and expansion of Custom Host Vital references
across scripts, profiles, installers, and deployments, with clearer
failures when values are missing or invalid.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-15 19:35:27 -03:00

309 lines
10 KiB
Go

package service
import (
"context"
"errors"
"fmt"
"strings"
"github.com/fleetdm/fleet/v4/server/authz"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/fleet"
common_mysql "github.com/fleetdm/fleet/v4/server/platform/mysql"
"golang.org/x/text/unicode/norm"
)
//////////////////////////////////////////////////////////////////////////////////
// List custom host vitals
//////////////////////////////////////////////////////////////////////////////////
func listCustomHostVitalsEndpoint(ctx context.Context, request any, svc fleet.Service) (fleet.Errorer, error) {
req := request.(*fleet.ListCustomHostVitalsRequest)
vitals, meta, count, err := svc.ListCustomHostVitals(ctx, req.ListOptions)
return fleet.ListCustomHostVitalsResponse{
CustomHostVitals: vitals,
Meta: meta,
Count: count,
Err: err,
}, nil
}
func (svc *Service) ListCustomHostVitals(
ctx context.Context,
opts fleet.ListOptions,
) (customHostVitals []fleet.CustomHostVital, meta *fleet.PaginationMetadata, count int, err error) {
if err := svc.authz.Authorize(ctx, &fleet.CustomHostVital{}, fleet.ActionRead); err != nil {
return nil, nil, 0, err
}
// Always include pagination info.
opts.IncludeMetadata = true
if opts.OrderKey == "" {
opts.OrderKey = "name"
opts.OrderDirection = fleet.OrderAscending
}
customHostVitals, meta, count, err = svc.ds.ListCustomHostVitals(ctx, opts)
if err != nil {
return nil, nil, 0, ctxerr.Wrap(ctx, err, "list custom host vitals")
}
return customHostVitals, meta, count, nil
}
//////////////////////////////////////////////////////////////////////////////////
// Create custom host vital
//////////////////////////////////////////////////////////////////////////////////
func createCustomHostVitalEndpoint(ctx context.Context, request any, svc fleet.Service) (fleet.Errorer, error) {
req := request.(*fleet.CreateCustomHostVitalRequest)
vital, err := svc.CreateCustomHostVital(ctx, req.Name)
if err != nil {
return fleet.CreateCustomHostVitalResponse{Err: err}, nil
}
return fleet.CreateCustomHostVitalResponse{CustomHostVital: vital}, nil
}
func (svc *Service) CreateCustomHostVital(ctx context.Context, name string) (*fleet.CustomHostVital, error) {
if err := svc.authz.Authorize(ctx, &fleet.CustomHostVital{}, fleet.ActionWrite); err != nil {
return nil, err
}
if err := fleet.ValidateCustomHostVitalName(name); err != nil {
return nil, ctxerr.Wrap(ctx, err, "validate custom host vital name")
}
vital, err := svc.ds.CreateCustomHostVital(ctx, name)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "creating custom host vital")
}
if err := svc.NewActivity(
ctx,
authz.UserFromContext(ctx),
fleet.ActivityTypeCreatedCustomHostVital{
CustomHostVitalID: vital.ID,
CustomHostVitalName: vital.Name,
},
); err != nil {
return nil, ctxerr.Wrap(ctx, err, "create activity for custom host vital creation")
}
return &vital, nil
}
//////////////////////////////////////////////////////////////////////////////////
// Update (rename) custom host vital
//////////////////////////////////////////////////////////////////////////////////
func updateCustomHostVitalEndpoint(ctx context.Context, request any, svc fleet.Service) (fleet.Errorer, error) {
req := request.(*fleet.UpdateCustomHostVitalRequest)
vital, err := svc.UpdateCustomHostVital(ctx, req.ID, req.Name)
if err != nil {
return fleet.UpdateCustomHostVitalResponse{Err: err}, nil
}
return fleet.UpdateCustomHostVitalResponse{CustomHostVital: vital}, nil
}
func (svc *Service) UpdateCustomHostVital(ctx context.Context, id uint, name string) (*fleet.CustomHostVital, error) {
if err := svc.authz.Authorize(ctx, &fleet.CustomHostVital{}, fleet.ActionWrite); err != nil {
return nil, err
}
if err := fleet.ValidateCustomHostVitalName(name); err != nil {
return nil, ctxerr.Wrap(ctx, err, "validate custom host vital name")
}
vital, err := svc.ds.UpdateCustomHostVital(ctx, id, name)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "updating custom host vital")
}
if err := svc.NewActivity(
ctx,
authz.UserFromContext(ctx),
fleet.ActivityTypeEditedCustomHostVital{
CustomHostVitalID: vital.ID,
CustomHostVitalName: vital.Name,
},
); err != nil {
return nil, ctxerr.Wrap(ctx, err, "create activity for custom host vital edit")
}
return &vital, nil
}
//////////////////////////////////////////////////////////////////////////////////
// Delete custom host vital
//////////////////////////////////////////////////////////////////////////////////
func deleteCustomHostVitalEndpoint(ctx context.Context, request any, svc fleet.Service) (fleet.Errorer, error) {
req := request.(*fleet.DeleteCustomHostVitalRequest)
err := svc.DeleteCustomHostVital(ctx, req.ID)
return fleet.DeleteCustomHostVitalResponse{Err: err}, nil
}
func (svc *Service) DeleteCustomHostVital(ctx context.Context, id uint) error {
if err := svc.authz.Authorize(ctx, &fleet.CustomHostVital{}, fleet.ActionWrite); err != nil {
return err
}
name, err := svc.ds.DeleteCustomHostVital(ctx, id)
if err != nil {
if usedErr, ok := errors.AsType[*fleet.CustomHostVitalUsedError](err); ok {
return ctxerr.Wrap(ctx, &fleet.ConflictError{
Message: fmt.Sprintf("Couldn't delete. %s", usedErr.Error()),
}, "delete custom host vital")
}
return ctxerr.Wrap(ctx, err, "delete custom host vital")
}
if err := svc.NewActivity(
ctx,
authz.UserFromContext(ctx),
fleet.ActivityTypeDeletedCustomHostVital{
CustomHostVitalID: id,
CustomHostVitalName: name,
},
); err != nil {
return ctxerr.Wrap(ctx, err, "create activity for custom host vital deletion")
}
return nil
}
//////////////////////////////////////////////////////////////////////////////////
// Set host custom host vital value
//////////////////////////////////////////////////////////////////////////////////
func setHostCustomHostVitalValueEndpoint(ctx context.Context, request any, svc fleet.Service) (fleet.Errorer, error) {
req := request.(*fleet.SetHostCustomHostVitalValueRequest)
err := svc.SetHostCustomHostVitalValue(ctx, req.HostID, req.ID, req.Value)
return fleet.SetHostCustomHostVitalValueResponse{Err: err}, nil
}
func (svc *Service) SetHostCustomHostVitalValue(ctx context.Context, hostID uint, vitalID uint, value string) error {
// Authorize against the host so team-scoped roles are enforced (host-write pattern).
if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil {
return err
}
host, err := svc.ds.HostLite(ctx, hostID)
if err != nil {
return ctxerr.Wrap(ctx, err, "find host for setting custom host vital value")
}
if err := svc.authz.Authorize(ctx, &fleet.HostCustomHostVitalValue{TeamID: host.TeamID}, fleet.ActionWrite); err != nil {
return err
}
vital, err := svc.customHostVitalByID(ctx, vitalID)
if err != nil {
return err
}
if err := svc.ds.SetHostCustomHostVitalValue(ctx, hostID, vitalID, value); err != nil {
return ctxerr.Wrap(ctx, err, "set host custom host vital value")
}
if err := svc.NewActivity(
ctx,
authz.UserFromContext(ctx),
fleet.ActivityTypeEditedCustomHostVitalValue{
HostID: hostID,
HostDisplayName: host.DisplayName(),
CustomHostVitalID: vitalID,
CustomHostVitalName: vital.Name,
},
); err != nil {
return ctxerr.Wrap(ctx, err, "create activity for custom host vital value edit")
}
return nil
}
func (svc *Service) customHostVitalByID(ctx context.Context, id uint) (*fleet.CustomHostVital, error) {
vitals, err := svc.ds.GetCustomHostVitals(ctx, []uint{id})
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "get custom host vital by id")
}
if len(vitals) == 0 {
return nil, ctxerr.Wrap(ctx, common_mysql.NotFound("CustomHostVital").WithID(id))
}
return &vitals[0], nil
}
//////////////////////////////////////////////////////////////////////////////////
// Upsert custom host vitals (spec)
//////////////////////////////////////////////////////////////////////////////////
func upsertCustomHostVitalsEndpoint(ctx context.Context, request any, svc fleet.Service) (fleet.Errorer, error) {
req := request.(*fleet.UpsertCustomHostVitalsRequest)
err := svc.UpsertCustomHostVitals(ctx, req.CustomHostVitals, req.DryRun)
return fleet.UpsertCustomHostVitalsResponse{Err: err}, nil
}
func (svc *Service) UpsertCustomHostVitals(ctx context.Context, customHostVitals []fleet.CustomHostVital, dryRun bool) error {
if err := svc.authz.Authorize(ctx, &fleet.CustomHostVital{}, fleet.ActionWrite); err != nil {
return err
}
// Names are unique in the database under the utf8mb4_unicode_ci collation
// (case-insensitive), so dedupe on that same basis rather than exact string
// equality -- otherwise two names differing only by case would pass this
// check and then fail as a raw DB duplicate-key error at insert time.
seen := make(map[string]string, len(customHostVitals)) // collation key -> original name
for _, vital := range customHostVitals {
if err := fleet.ValidateCustomHostVitalName(vital.Name); err != nil {
return ctxerr.Wrap(ctx, err, "validate custom host vital name")
}
key := norm.NFC.String(strings.ToLower(vital.Name))
if prev, ok := seen[key]; ok {
return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("custom_host_vitals",
fmt.Sprintf("duplicate custom host vital names: %q and %q must differ by more than letter case", prev, vital.Name)))
}
seen[key] = vital.Name
}
if dryRun {
return nil
}
created, deleted, err := svc.ds.UpsertCustomHostVitals(ctx, customHostVitals)
if err != nil {
if usedErr, ok := errors.AsType[*fleet.CustomHostVitalUsedError](err); ok {
return ctxerr.Wrap(ctx, &fleet.ConflictError{
Message: fmt.Sprintf("Couldn't delete. %s", usedErr.Error()),
}, "upsert custom host vitals")
}
return ctxerr.Wrap(ctx, err, "upsert custom host vitals")
}
user := authz.UserFromContext(ctx)
for _, vital := range created {
if err := svc.NewActivity(
ctx,
user,
fleet.ActivityTypeCreatedCustomHostVital{
CustomHostVitalID: vital.ID,
CustomHostVitalName: vital.Name,
},
); err != nil {
return ctxerr.Wrap(ctx, err, "create activity for custom host vital creation")
}
}
for _, vital := range deleted {
if err := svc.NewActivity(
ctx,
user,
fleet.ActivityTypeDeletedCustomHostVital{
CustomHostVitalID: vital.ID,
CustomHostVitalName: vital.Name,
},
); err != nil {
return ctxerr.Wrap(ctx, err, "create activity for custom host vital deletion")
}
}
return nil
}