Updating game code to be on device actions not buttons

This commit is contained in:
George Karr 2026-04-15 17:47:35 -05:00
parent 4ecc002f68
commit 406857973e
14 changed files with 1172 additions and 190 deletions

View file

@ -1,14 +1,10 @@
import React, { useContext, useState } from "react";
import React, { useContext, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "react-query";
import classnames from "classnames";
import deviceUserAPI from "services/entities/device_user";
import { NotificationContext } from "context/notification";
import {
HostPetAction,
HostPetMood,
IHostPet,
} from "interfaces/host_pet";
import { HostPetMood, IHostPet } from "interfaces/host_pet";
import Button from "components/buttons/Button";
import Spinner from "components/Spinner";
@ -28,13 +24,17 @@ const MOOD_EMOJI: Record<HostPetMood, string> = {
sick: "😾",
};
// Mood captions explain *why* the pet feels how it does, so users learn that
// keeping their device healthy makes the pet healthy. The pet no longer
// reacts to button clicks — the four old action buttons (feed/play/clean/
// medicine) were removed because they were trivial to game.
const MOOD_CAPTION: Record<HostPetMood, string> = {
happy: "is purring contentedly.",
happy: "is purring — your device looks great.",
content: "is chilling.",
sad: "looks glum — maybe play with them?",
hungry: "is crying for food!",
dirty: "is a grubby little goblin.",
sick: "feels awful — try some medicine.",
sad: "looks glum. Try installing something from Self-service.",
hungry: "hasn't seen your laptop in a while — wake it up?",
dirty: "is unhappy about your failing policies.",
sick: "feels awful — vulnerable software is making them sick. Update apps.",
};
interface IStatBarProps {
@ -82,6 +82,11 @@ const PetCard = ({ deviceAuthToken, className }: IPetCardProps) => {
const { renderFlash } = useContext(NotificationContext);
const queryClient = useQueryClient();
const [adoptName, setAdoptName] = useState("");
// isPetting is purely cosmetic — clicking "Pet" briefly applies an
// animation class. There's no API call: the backend's stat machinery is
// driven entirely by host hygiene now.
const [isPetting, setIsPetting] = useState(false);
const pettingTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const {
data: petData,
@ -120,15 +125,13 @@ const PetCard = ({ deviceAuthToken, className }: IPetCardProps) => {
}
);
const actionMutation = useMutation(
(action: HostPetAction) =>
deviceUserAPI.applyDevicePetAction(deviceAuthToken, action),
{
onSuccess: () => invalidatePet(),
onError: () =>
renderFlash("error", "That didn't work. Try again in a sec."),
const handlePet = () => {
setIsPetting(true);
if (pettingTimeoutRef.current) {
clearTimeout(pettingTimeoutRef.current);
}
);
pettingTimeoutRef.current = setTimeout(() => setIsPetting(false), 800);
};
if (isLoadingPet) {
return (
@ -193,11 +196,6 @@ const PetCard = ({ deviceAuthToken, className }: IPetCardProps) => {
const emoji = MOOD_EMOJI[pet.mood] ?? MOOD_EMOJI.content;
const caption = MOOD_CAPTION[pet.mood] ?? MOOD_CAPTION.content;
const handleAction = (action: HostPetAction) => {
if (actionMutation.isLoading) return;
actionMutation.mutate(action);
};
return (
<div className={classnames(baseClass, className)}>
<div className={`${baseClass}__header`}>
@ -208,12 +206,21 @@ const PetCard = ({ deviceAuthToken, className }: IPetCardProps) => {
<div
className={classnames(
`${baseClass}__stage`,
`${baseClass}__stage--${pet.mood}`
`${baseClass}__stage--${pet.mood}`,
isPetting && `${baseClass}__stage--petting`
)}
>
<div className={`${baseClass}__pet-emoji`} aria-label={pet.mood}>
{emoji}
</div>
{isPetting && (
<div
className={`${baseClass}__petting-heart`}
aria-hidden="true"
>
</div>
)}
<p className={`${baseClass}__caption`}>
{pet.name} {caption}
</p>
@ -227,39 +234,15 @@ const PetCard = ({ deviceAuthToken, className }: IPetCardProps) => {
</div>
<div className={`${baseClass}__actions`}>
<Button
variant="default"
onClick={() => handleAction("feed")}
disabled={actionMutation.isLoading}
>
Feed
</Button>
<Button
variant="default"
onClick={() => handleAction("play")}
disabled={actionMutation.isLoading}
>
Play
</Button>
<Button
variant="default"
onClick={() => handleAction("clean")}
disabled={actionMutation.isLoading}
>
Clean
</Button>
<Button
variant="default"
onClick={() => handleAction("medicine")}
disabled={actionMutation.isLoading || pet.health > 70}
>
Medicine
<Button variant="default" onClick={handlePet}>
Pet
</Button>
</div>
<p className={`${baseClass}__hint`}>
Tip: the state of your device affects your pet too. Failing policies,
disabled disk encryption, or a pet left alone will make them unwell.
Your pet reacts to your device&apos;s health: keep checking in, fix
failing policies, update vulnerable software, and use Self-service to
keep them happy. The Pet button is just for fun.
</p>
</div>
);

View file

@ -91,6 +91,26 @@
animation: pet-shake 1.2s ease-in-out infinite;
}
// Petting overrides the mood animation for ~0.8s while the user is
// showing the pet some love. Purely cosmetic no API call.
&__stage--petting .pet-card__pet-emoji {
animation: pet-purr 0.4s ease-in-out infinite !important;
}
&__stage {
position: relative;
}
&__petting-heart {
position: absolute;
top: 30%;
right: calc(50% - 80px);
color: #ff6b9d;
font-size: 32px;
pointer-events: none;
animation: pet-heart-rise 0.8s ease-out forwards;
}
&__caption {
margin: 0;
font-size: $small;
@ -192,3 +212,26 @@
transform: translateX(4px);
}
}
@keyframes pet-purr {
0%, 100% {
transform: scale(1) rotate(0);
}
50% {
transform: scale(1.06) rotate(-2deg);
}
}
@keyframes pet-heart-rise {
0% {
transform: translateY(0) scale(0.6);
opacity: 0;
}
20% {
opacity: 1;
}
100% {
transform: translateY(-60px) scale(1.2);
opacity: 0;
}
}

View file

@ -98,3 +98,121 @@ func (ds *Datastore) SaveHostPet(ctx context.Context, pet *fleet.HostPet) error
}
return nil
}
// ApplyHostPetHappinessDelta clamps and bumps the pet's happiness atomically
// in MySQL. No-op (returns nil) when the host has no pet — used by event-driven
// signals (e.g. self-service install success) that don't know whether the user
// has adopted.
func (ds *Datastore) ApplyHostPetHappinessDelta(ctx context.Context, hostID uint, delta int) error {
// LEAST/GREATEST does the clamp inline so we don't need a read-then-write
// round trip and there's no race between concurrent installs.
const stmt = `
UPDATE host_pets
SET happiness = LEAST(?, GREATEST(?, CAST(happiness AS SIGNED) + ?))
WHERE host_id = ?`
if _, err := ds.writer(ctx).ExecContext(ctx, stmt,
fleet.HostPetStatCeiling,
fleet.HostPetStatFloor,
delta,
hostID,
); err != nil {
return ctxerr.Wrap(ctx, err, "apply host pet happiness delta")
}
return nil
}
// CountOpenHostVulnsBySeverity returns the count of open critical (CVSS >= 9)
// and high (7 <= CVSS < 9) CVEs affecting software installed on the host.
// Uninstalled software is not considered. Vulns missing CVSS scores are
// ignored — they can't be bucketed.
func (ds *Datastore) CountOpenHostVulnsBySeverity(ctx context.Context, hostID uint) (critical, high uint, err error) {
const stmt = `
SELECT
COALESCE(SUM(CASE WHEN cm.cvss_score >= 9.0 THEN 1 ELSE 0 END), 0) AS critical_count,
COALESCE(SUM(CASE WHEN cm.cvss_score >= 7.0 AND cm.cvss_score < 9.0 THEN 1 ELSE 0 END), 0) AS high_count
FROM host_software hs
JOIN software_cve sc ON sc.software_id = hs.software_id
JOIN cve_meta cm ON cm.cve = sc.cve
WHERE hs.host_id = ?`
var row struct {
CriticalCount uint `db:"critical_count"`
HighCount uint `db:"high_count"`
}
if err := sqlx.GetContext(ctx, ds.reader(ctx), &row, stmt, hostID); err != nil {
// No matching rows still produces a single SUM=0 row; an actual error here is
// a real failure.
if errors.Is(err, sql.ErrNoRows) {
return 0, 0, nil
}
return 0, 0, ctxerr.Wrap(ctx, err, "count open host vulns by severity")
}
return row.CriticalCount, row.HighCount, nil
}
//----------------------------------------------------------------------------//
// Demo overrides //
//----------------------------------------------------------------------------//
// GetHostPetDemoOverrides returns the override row for the host, or nil if
// none has been set. Returning nil-and-no-error here (rather than a NotFound)
// keeps callers branch-free: the demo overlay is "if non-nil, apply".
func (ds *Datastore) GetHostPetDemoOverrides(ctx context.Context, hostID uint) (*fleet.HostPetDemoOverrides, error) {
const stmt = `
SELECT
host_id, seen_time_override, time_offset_hours,
extra_failing_policies, extra_critical_vulns, extra_high_vulns,
created_at, updated_at
FROM host_pet_demo_overrides
WHERE host_id = ?`
var o fleet.HostPetDemoOverrides
if err := sqlx.GetContext(ctx, ds.reader(ctx), &o, stmt, hostID); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, ctxerr.Wrap(ctx, err, "get host pet demo overrides")
}
return &o, nil
}
// UpsertHostPetDemoOverrides creates or updates the override row. The caller
// owns the merge: pass the values you want stored. (Demo endpoints with PATCH
// semantics should read first, mutate, then call this.)
func (ds *Datastore) UpsertHostPetDemoOverrides(ctx context.Context, o *fleet.HostPetDemoOverrides) error {
const stmt = `
INSERT INTO host_pet_demo_overrides
(host_id, seen_time_override, time_offset_hours,
extra_failing_policies, extra_critical_vulns, extra_high_vulns)
VALUES (?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
seen_time_override = VALUES(seen_time_override),
time_offset_hours = VALUES(time_offset_hours),
extra_failing_policies = VALUES(extra_failing_policies),
extra_critical_vulns = VALUES(extra_critical_vulns),
extra_high_vulns = VALUES(extra_high_vulns)`
if _, err := ds.writer(ctx).ExecContext(ctx, stmt,
o.HostID,
o.SeenTimeOverride,
o.TimeOffsetHours,
o.ExtraFailingPolicies,
o.ExtraCriticalVulns,
o.ExtraHighVulns,
); err != nil {
return ctxerr.Wrap(ctx, err, "upsert host pet demo overrides")
}
return nil
}
// DeleteHostPetDemoOverrides removes the override row for a host. No-op when
// there's nothing to delete.
func (ds *Datastore) DeleteHostPetDemoOverrides(ctx context.Context, hostID uint) error {
if _, err := ds.writer(ctx).ExecContext(ctx,
`DELETE FROM host_pet_demo_overrides WHERE host_id = ?`, hostID,
); err != nil {
return ctxerr.Wrap(ctx, err, "delete host pet demo overrides")
}
return nil
}

View file

@ -0,0 +1,38 @@
package tables
import (
"database/sql"
"fmt"
)
func init() {
MigrationClient.AddMigration(Up_20260415221415, Down_20260415221415)
}
// Up_20260415221415 creates the host_pet_demo_overrides table. Each row holds
// per-host knobs that the pet derivation function reads on top of real host
// state when the demo build tag is enabled — used to drive demos without
// poisoning the real hosts / policies / vulnerabilities tables.
func Up_20260415221415(tx *sql.Tx) error {
if _, err := tx.Exec(`
CREATE TABLE host_pet_demo_overrides (
host_id INT UNSIGNED NOT NULL,
seen_time_override TIMESTAMP(6) NULL,
time_offset_hours INT NOT NULL DEFAULT 0,
extra_failing_policies INT UNSIGNED NOT NULL DEFAULT 0,
extra_critical_vulns INT UNSIGNED NOT NULL DEFAULT 0,
extra_high_vulns INT UNSIGNED NOT NULL DEFAULT 0,
created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
updated_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
PRIMARY KEY (host_id),
CONSTRAINT fk_host_pet_demo_overrides_host_id FOREIGN KEY (host_id) REFERENCES hosts (id) ON DELETE CASCADE
)
`); err != nil {
return fmt.Errorf("creating host_pet_demo_overrides table: %w", err)
}
return nil
}
func Down_20260415221415(tx *sql.Tx) error {
return nil
}

View file

@ -0,0 +1,59 @@
package tables
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestUp_20260415221415(t *testing.T) {
db := applyUpToPrev(t)
hostRes, err := db.Exec(`
INSERT INTO hosts (osquery_host_id, node_key, hostname, uuid)
VALUES (?, ?, ?, ?)`,
"test-host-pet-demo-1", "node-key-pet-demo-1", "pet-demo-host-1", "pet-demo-uuid-1",
)
require.NoError(t, err)
hostID, err := hostRes.LastInsertId()
require.NoError(t, err)
applyNext(t, db)
// INSERT with defaults.
_, err = db.Exec(`INSERT INTO host_pet_demo_overrides (host_id) VALUES (?)`, hostID)
require.NoError(t, err)
var (
seenTimeOverride *string // NULL by default
timeOffsetHours int
extraFailingPolicies uint
extraCriticalVulns uint
extraHighVulns uint
)
err = db.QueryRow(`
SELECT seen_time_override, time_offset_hours,
extra_failing_policies, extra_critical_vulns, extra_high_vulns
FROM host_pet_demo_overrides WHERE host_id = ?`, hostID,
).Scan(&seenTimeOverride, &timeOffsetHours, &extraFailingPolicies, &extraCriticalVulns, &extraHighVulns)
require.NoError(t, err)
assert.Nil(t, seenTimeOverride)
assert.Equal(t, 0, timeOffsetHours)
assert.Equal(t, uint(0), extraFailingPolicies)
assert.Equal(t, uint(0), extraCriticalVulns)
assert.Equal(t, uint(0), extraHighVulns)
// One row per host (PK).
_, err = db.Exec(`INSERT INTO host_pet_demo_overrides (host_id) VALUES (?)`, hostID)
require.Error(t, err)
// Cascade delete with host.
_, err = db.Exec(`DELETE FROM hosts WHERE id = ?`, hostID)
require.NoError(t, err)
var count int
err = db.QueryRow(`SELECT COUNT(*) FROM host_pet_demo_overrides WHERE host_id = ?`, hostID).Scan(&count)
require.NoError(t, err)
assert.Equal(t, 0, count)
}

View file

@ -909,6 +909,25 @@ type Datastore interface {
CreateHostPet(ctx context.Context, hostID uint, name, species string) (*HostPet, error)
// SaveHostPet persists the given pet's stats and last_interacted_at.
SaveHostPet(ctx context.Context, pet *HostPet) error
// ApplyHostPetHappinessDelta atomically clamps and bumps the persisted
// happiness on the given host's pet. No-op (returns nil) if the host has
// no pet — used by the self-service install success path which doesn't
// know whether the user has adopted.
ApplyHostPetHappinessDelta(ctx context.Context, hostID uint, delta int) error
// CountOpenHostVulnsBySeverity returns the number of open critical (CVSS
// >= 9.0) and high (7.0 <= CVSS < 9.0) vulnerabilities affecting software
// installed on the given host.
CountOpenHostVulnsBySeverity(ctx context.Context, hostID uint) (critical, high uint, err error)
// GetHostPetDemoOverrides returns the demo override row for the given
// host, or nil if there isn't one. Used by the demo build to drive stat
// changes without touching real host data.
GetHostPetDemoOverrides(ctx context.Context, hostID uint) (*HostPetDemoOverrides, error)
// UpsertHostPetDemoOverrides creates or updates the demo override row for
// the given host.
UpsertHostPetDemoOverrides(ctx context.Context, overrides *HostPetDemoOverrides) error
// DeleteHostPetDemoOverrides removes the demo override row for the given
// host. No-op if no row exists.
DeleteHostPetDemoOverrides(ctx context.Context, hostID uint) error
// Methods used for async processing of host policy query results.
AsyncBatchInsertPolicyMembership(ctx context.Context, batch []PolicyMembershipResult) error

View file

@ -87,3 +87,101 @@ type HostPetAdoption struct {
Name string `json:"name"`
Species string `json:"species"`
}
// HostPetMetrics is the snapshot of host hygiene signals the pet derivation
// reads to compute its stats. Built once per request from real host state and
// (when the demo build tag is enabled) overlaid with a HostPetDemoOverrides
// row.
type HostPetMetrics struct {
// SeenTime is when Fleet last heard from the host. Drives hunger.
SeenTime time.Time
// FailingPolicyCount is how many policies are failing on this host right
// now. Drives cleanliness.
FailingPolicyCount uint
// CriticalVulnCount + HighVulnCount are open CVEs on installed software,
// bucketed by CVSS severity. Drive health.
CriticalVulnCount uint
HighVulnCount uint
// DiskEncryptionEnabled is the host's current FileVault / BitLocker /
// LUKS state. Tri-state because some hosts haven't reported it yet.
DiskEncryptionEnabled *bool
// MDMUnenrolled is true when the host's MDM enrollment_status is "Off".
MDMUnenrolled bool
}
// HostPetDemoOverrides are per-host knobs the demo build can layer on top of
// real host state to drive stat changes without touching the underlying
// hosts/policies/vulnerabilities tables. Reset by deleting the row.
type HostPetDemoOverrides struct {
HostID uint `json:"host_id" db:"host_id"`
SeenTimeOverride *time.Time `json:"seen_time_override,omitempty" db:"seen_time_override"`
TimeOffsetHours int `json:"time_offset_hours" db:"time_offset_hours"`
ExtraFailingPolicies uint `json:"extra_failing_policies" db:"extra_failing_policies"`
ExtraCriticalVulns uint `json:"extra_critical_vulns" db:"extra_critical_vulns"`
ExtraHighVulns uint `json:"extra_high_vulns" db:"extra_high_vulns"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// Apply overlays the override values onto a real metrics snapshot. Counts
// are added; SeenTimeOverride wholesale replaces SeenTime when set.
// TimeOffsetHours is consumed by the caller (it shifts "now"), not here.
func (o *HostPetDemoOverrides) Apply(m *HostPetMetrics) {
if o == nil || m == nil {
return
}
if o.SeenTimeOverride != nil {
m.SeenTime = *o.SeenTimeOverride
}
m.FailingPolicyCount += o.ExtraFailingPolicies
m.CriticalVulnCount += o.ExtraCriticalVulns
m.HighVulnCount += o.ExtraHighVulns
}
//----------------------------------------------------------------------------//
// Pet stat derivation tuning constants //
//----------------------------------------------------------------------------//
// Targets are the per-stat values that host signals push the pet toward.
// On every read, each stat slides toward its target by HostPetTickStep so that
// transient signals don't snap the pet's display from one extreme to another.
const (
// Default ("nothing wrong") targets when there are no negative signals.
HostPetTargetHealthBaseline uint8 = 90
HostPetTargetCleanlinessBaseline uint8 = 90
HostPetTargetHungerBaseline uint8 = 20 // low hunger = full
HostPetTargetHappinessBaseline uint8 = 70
// HostPetTickStep is the maximum amount any single stat can move per read
// when sliding toward its target. Keeps the UI from flipping wildly.
HostPetTickStep uint8 = 8
// Hunger thresholds keyed off hours since last check-in.
HostPetHungerHoursFresh = 1.0 // <1h since check-in: pet is fed
HostPetHungerHoursStale = 6.0 // 1-6h: trending toward "peckish"
HostPetHungerHoursVeryStale = 24.0 // >24h: starving
// Hunger targets for each band above.
HostPetTargetHungerFresh uint8 = 15
HostPetTargetHungerStale uint8 = 50
HostPetTargetHungerVeryStale uint8 = 80
HostPetTargetHungerStarving uint8 = 95
// Cleanliness drag per failing policy (1 failing policy = -15 cleanliness).
HostPetCleanlinessPerFailingPolicy uint8 = 15
// Health drag per vulnerability bucket.
HostPetHealthPerCriticalVuln uint8 = 10
HostPetHealthPerHighVuln uint8 = 3
// MDM/disk-encryption signals (applied as direct stat overlays, not
// targets, since they're binary).
HostPetHealthMDMUnenrolledPenalty uint8 = 10
HostPetHappinessDiskEncOnBonus uint8 = 5
HostPetHappinessDiskEncOffPenalty uint8 = 10
// Self-service event happiness bump (applied event-driven by the install
// success path, persisted on the pet row, then decayed naturally over
// time toward the happiness baseline).
HostPetHappinessSelfServiceBump uint8 = 12
)

View file

@ -671,6 +671,16 @@ type CreateHostPetFunc func(ctx context.Context, hostID uint, name string, speci
type SaveHostPetFunc func(ctx context.Context, pet *fleet.HostPet) error
type ApplyHostPetHappinessDeltaFunc func(ctx context.Context, hostID uint, delta int) error
type CountOpenHostVulnsBySeverityFunc func(ctx context.Context, hostID uint) (critical uint, high uint, err error)
type GetHostPetDemoOverridesFunc func(ctx context.Context, hostID uint) (*fleet.HostPetDemoOverrides, error)
type UpsertHostPetDemoOverridesFunc func(ctx context.Context, overrides *fleet.HostPetDemoOverrides) error
type DeleteHostPetDemoOverridesFunc func(ctx context.Context, hostID uint) error
type AsyncBatchInsertPolicyMembershipFunc func(ctx context.Context, batch []fleet.PolicyMembershipResult) error
type AsyncBatchUpdatePolicyTimestampFunc func(ctx context.Context, ids []uint, ts time.Time) error
@ -2842,6 +2852,21 @@ type DataStore struct {
SaveHostPetFunc SaveHostPetFunc
SaveHostPetFuncInvoked bool
ApplyHostPetHappinessDeltaFunc ApplyHostPetHappinessDeltaFunc
ApplyHostPetHappinessDeltaFuncInvoked bool
CountOpenHostVulnsBySeverityFunc CountOpenHostVulnsBySeverityFunc
CountOpenHostVulnsBySeverityFuncInvoked bool
GetHostPetDemoOverridesFunc GetHostPetDemoOverridesFunc
GetHostPetDemoOverridesFuncInvoked bool
UpsertHostPetDemoOverridesFunc UpsertHostPetDemoOverridesFunc
UpsertHostPetDemoOverridesFuncInvoked bool
DeleteHostPetDemoOverridesFunc DeleteHostPetDemoOverridesFunc
DeleteHostPetDemoOverridesFuncInvoked bool
AsyncBatchInsertPolicyMembershipFunc AsyncBatchInsertPolicyMembershipFunc
AsyncBatchInsertPolicyMembershipFuncInvoked bool
@ -6910,6 +6935,41 @@ func (s *DataStore) SaveHostPet(ctx context.Context, pet *fleet.HostPet) error {
return s.SaveHostPetFunc(ctx, pet)
}
func (s *DataStore) ApplyHostPetHappinessDelta(ctx context.Context, hostID uint, delta int) error {
s.mu.Lock()
s.ApplyHostPetHappinessDeltaFuncInvoked = true
s.mu.Unlock()
return s.ApplyHostPetHappinessDeltaFunc(ctx, hostID, delta)
}
func (s *DataStore) CountOpenHostVulnsBySeverity(ctx context.Context, hostID uint) (uint, uint, error) {
s.mu.Lock()
s.CountOpenHostVulnsBySeverityFuncInvoked = true
s.mu.Unlock()
return s.CountOpenHostVulnsBySeverityFunc(ctx, hostID)
}
func (s *DataStore) GetHostPetDemoOverrides(ctx context.Context, hostID uint) (*fleet.HostPetDemoOverrides, error) {
s.mu.Lock()
s.GetHostPetDemoOverridesFuncInvoked = true
s.mu.Unlock()
return s.GetHostPetDemoOverridesFunc(ctx, hostID)
}
func (s *DataStore) UpsertHostPetDemoOverrides(ctx context.Context, overrides *fleet.HostPetDemoOverrides) error {
s.mu.Lock()
s.UpsertHostPetDemoOverridesFuncInvoked = true
s.mu.Unlock()
return s.UpsertHostPetDemoOverridesFunc(ctx, overrides)
}
func (s *DataStore) DeleteHostPetDemoOverrides(ctx context.Context, hostID uint) error {
s.mu.Lock()
s.DeleteHostPetDemoOverridesFuncInvoked = true
s.mu.Unlock()
return s.DeleteHostPetDemoOverridesFunc(ctx, hostID)
}
func (s *DataStore) AsyncBatchInsertPolicyMembership(ctx context.Context, batch []fleet.PolicyMembershipResult) error {
s.mu.Lock()
s.AsyncBatchInsertPolicyMembershipFuncInvoked = true

View file

@ -488,6 +488,10 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC
ue.GET("/api/_version_/fleet/hosts/{id:[0-9]+}/device_url", getHostDeviceURLEndpoint, getHostDeviceURLRequest{})
// Pet demo endpoints — registered conditionally based on the `pet_demo`
// build tag (the default build's stub is a no-op). See host_pets_demo.go.
registerPetDemoEndpoints(ue)
ue.POST("/api/_version_/fleet/labels", createLabelEndpoint, fleet.CreateLabelRequest{})
ue.PATCH("/api/_version_/fleet/labels/{id:[0-9]+}", modifyLabelEndpoint, fleet.ModifyLabelRequest{})
ue.GET("/api/_version_/fleet/labels/{id:[0-9]+}", getLabelEndpoint, fleet.GetLabelRequest{})

View file

@ -4,6 +4,7 @@ import (
"context"
"errors"
"strings"
"time"
"unicode/utf8"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
@ -11,36 +12,21 @@ import (
"github.com/fleetdm/fleet/v4/server/fleet"
)
//----------------------------------------------------------------------------//
// Pet stat decay / signal tuning constants //
//----------------------------------------------------------------------------//
const (
// Passive decay per hour of neglect (applied on every read).
decayHungerPerHour = 3 // hunger rises (higher = hungrier)
decayCleanlinessPerHour = 2 // cleanliness drops
decayHappinessPerHour = 1 // happiness drops slowly
// Health does not decay on its own; it drains from sustained bad stats.
// Action effects (applied on POST /pet/action).
actionFeedHungerDelta = -30 // Feed: lower hunger
actionFeedHappinessDelta = 5
actionPlayHappinessDelta = 30
actionPlayHungerDelta = 10 // playing makes you hungry
actionCleanCleanlinessDelta = 40
actionCleanHappinessDelta = 5
actionMedicineHealthDelta = 30
// Device-hygiene signal effects (applied on every read, once per read).
signalFailingPolicyHealthPerPolicy = -5
signalDiskEncryptionOffHappinessDelta = -10
signalMdmUnenrolledHealthDelta = -10
signalAllPoliciesPassingHealthDelta = 2
signalDiskEncryptionOnHappinessDelta = 2
// Name validation.
maxPetNameLen = 32
minPetNameLen = 1
// Happiness decays toward its target by this many points per hour of
// elapsed time since the pet was last persisted (last_interacted_at).
// At 2/h, a +12 self-service bump above target dissipates in ~6 hours.
happinessDecayPerHour = 2
// Cap simulated elapsed time when computing happiness decay so a pet
// that hasn't been read in a year doesn't snap from a "fresh bump"
// straight to the target on the next read — caller still sees a
// gradual trend if they refresh frequently after a long gap.
maxHappinessDecayWindow = 7 * 24 * time.Hour
)
//----------------------------------------------------------------------------//
@ -160,7 +146,8 @@ func (svc *Service) GetDevicePet(ctx context.Context, host *fleet.Host) (*fleet.
return nil, ctxerr.Wrap(ctx, err, "get host pet")
}
svc.applyDecayAndSignals(ctx, pet, host)
metrics, now := svc.gatherHostMetrics(ctx, host)
applyHostMetricsToPet(pet, metrics, now)
return pet, nil
}
@ -197,140 +184,180 @@ func (svc *Service) AdoptDevicePet(ctx context.Context, host *fleet.Host, name,
return nil, ctxerr.Wrap(ctx, err, "create host pet")
}
svc.applyDecayAndSignals(ctx, pet, host)
metrics, now := svc.gatherHostMetrics(ctx, host)
applyHostMetricsToPet(pet, metrics, now)
return pet, nil
}
// ApplyDevicePetAction applies a single action to the host's pet (feed, play,
// clean, medicine) and returns the updated pet. Passive decay and device-hygiene
// signals are applied first.
func (svc *Service) ApplyDevicePetAction(ctx context.Context, host *fleet.Host, action fleet.HostPetAction) (*fleet.HostPet, error) {
// ApplyDevicePetAction is deprecated. Pet stats are now driven entirely by
// host hygiene signals (check-ins, failing policies, vulnerabilities, MDM
// posture, disk encryption, and self-service install events) — there's no
// longer any user-driven action that mutates them. The endpoint stays
// registered so older Fleet Desktop clients don't 404, but it always
// returns 410 Gone.
func (svc *Service) ApplyDevicePetAction(ctx context.Context, _ *fleet.Host, _ fleet.HostPetAction) (*fleet.HostPet, error) {
svc.authz.SkipAuthorization(ctx)
if !fleet.IsValidHostPetAction(string(action)) {
invalid := fleet.NewInvalidArgumentError("action", "must be one of feed, play, clean, medicine")
return nil, ctxerr.Wrap(ctx, invalid)
}
pet, err := svc.ds.GetHostPet(ctx, host.ID)
if err != nil {
if fleet.IsNotFound(err) {
return nil, ctxerr.Wrap(ctx, &fleet.BadRequestError{Message: "no pet found for this host - adopt one first"})
}
return nil, ctxerr.Wrap(ctx, err, "get host pet")
}
// Apply decay and device signals before the action so each action is
// measured against the current state of the pet, not a stale snapshot.
svc.applyDecayAndSignals(ctx, pet, host)
// Apply the action's deltas.
switch action {
case fleet.HostPetActionFeed:
// Over-feeding: if hunger is already low, the pet gets stuffed and sad.
if pet.Hunger < 20 {
pet.Happiness = adjustStat(pet.Happiness, -10)
pet.Health = adjustStat(pet.Health, -2)
} else {
pet.Happiness = adjustStat(pet.Happiness, actionFeedHappinessDelta)
}
pet.Hunger = adjustStat(pet.Hunger, actionFeedHungerDelta)
case fleet.HostPetActionPlay:
pet.Happiness = adjustStat(pet.Happiness, actionPlayHappinessDelta)
pet.Hunger = adjustStat(pet.Hunger, actionPlayHungerDelta)
case fleet.HostPetActionClean:
pet.Cleanliness = adjustStat(pet.Cleanliness, actionCleanCleanlinessDelta)
pet.Happiness = adjustStat(pet.Happiness, actionCleanHappinessDelta)
case fleet.HostPetActionMedicine:
pet.Health = adjustStat(pet.Health, actionMedicineHealthDelta)
}
// An action counts as an interaction — reset the decay clock.
pet.LastInteractedAt = svc.clock.Now()
if err := svc.ds.SaveHostPet(ctx, pet); err != nil {
return nil, ctxerr.Wrap(ctx, err, "save host pet")
}
// Recompute derived mood for the response.
pet.Mood = computeMood(pet)
return pet, nil
return nil, ctxerr.Wrap(ctx, &fleet.BadRequestError{
Message: "pet actions have been removed; the pet now reacts to your device's hygiene automatically",
InternalErr: errors.New("ApplyDevicePetAction is deprecated"),
})
}
//----------------------------------------------------------------------------//
// Internal: decay + signal application //
// Internal: host-metrics derivation //
//----------------------------------------------------------------------------//
// applyDecayAndSignals mutates the pet in-place by applying:
// 1. Passive decay based on time elapsed since last_interacted_at.
// 2. Device-hygiene signals (failing policies, disk encryption, MDM).
// 3. Cross-stat feedback: if hunger/cleanliness are maxed, health drains.
//
// This intentionally does *not* persist the pet — we only write to the DB on
// explicit actions. This keeps GETs cheap and avoids the user's browser silently
// grinding down their pet by refreshing.
func (svc *Service) applyDecayAndSignals(ctx context.Context, pet *fleet.HostPet, host *fleet.Host) {
// gatherHostMetrics builds the HostPetMetrics snapshot the derivation reads,
// pulling failing policies + open vulns from the datastore and overlaying any
// demo overrides. Returns the metrics and the "now" timestamp the derivation
// should use (real now + any demo time offset).
func (svc *Service) gatherHostMetrics(ctx context.Context, host *fleet.Host) (fleet.HostPetMetrics, time.Time) {
now := svc.clock.Now()
hours := now.Sub(pet.LastInteractedAt).Hours()
if hours < 0 {
hours = 0
m := fleet.HostPetMetrics{
SeenTime: host.SeenTime,
DiskEncryptionEnabled: host.DiskEncryptionEnabled,
MDMUnenrolled: host.MDM.EnrollmentStatus != nil && *host.MDM.EnrollmentStatus == "Off",
}
// 1. Passive decay (fractional-friendly via int truncation — fine for a pet).
pet.Hunger = adjustStat(pet.Hunger, int(hours*float64(decayHungerPerHour)))
pet.Cleanliness = adjustStat(pet.Cleanliness, -int(hours*float64(decayCleanlinessPerHour)))
pet.Happiness = adjustStat(pet.Happiness, -int(hours*float64(decayHappinessPerHour)))
// 2. Device-hygiene signals. These are "what the pet sees right now", not a
// running accumulator — they're applied every read but the result is
// bounded by the stat clamps, so they don't compound unbounded.
policies, err := svc.ds.ListPoliciesForHost(ctx, host)
if err == nil && len(policies) > 0 {
failing := 0
// Failing policies. ListPoliciesForHost may return an error on hosts with
// no membership rows; we treat that as zero failing policies rather than
// failing the whole request, matching the previous behaviour.
if policies, err := svc.ds.ListPoliciesForHost(ctx, host); err == nil {
for _, p := range policies {
if p.Response == "fail" {
failing++
m.FailingPolicyCount++
}
}
if failing == 0 {
pet.Health = adjustStat(pet.Health, signalAllPoliciesPassingHealthDelta)
} else {
pet.Health = adjustStat(pet.Health, failing*signalFailingPolicyHealthPerPolicy)
}
// Open vulns by severity. Same forgiving treatment — a transient vuln
// query failure shouldn't blank the pet.
if crit, high, err := svc.ds.CountOpenHostVulnsBySeverity(ctx, host.ID); err == nil {
m.CriticalVulnCount = crit
m.HighVulnCount = high
}
// Demo overlay. GetHostPetDemoOverrides returns (nil, nil) when no row
// exists, so this is a no-op on real deployments.
if overrides, err := svc.ds.GetHostPetDemoOverrides(ctx, host.ID); err == nil && overrides != nil {
overrides.Apply(&m)
if overrides.TimeOffsetHours != 0 {
now = now.Add(time.Duration(overrides.TimeOffsetHours) * time.Hour)
}
}
if host.DiskEncryptionEnabled != nil {
if *host.DiskEncryptionEnabled {
pet.Happiness = adjustStat(pet.Happiness, signalDiskEncryptionOnHappinessDelta)
} else {
pet.Happiness = adjustStat(pet.Happiness, signalDiskEncryptionOffHappinessDelta)
}
}
return m, now
}
if host.MDM.EnrollmentStatus != nil && *host.MDM.EnrollmentStatus == "Off" {
pet.Health = adjustStat(pet.Health, signalMdmUnenrolledHealthDelta)
}
// applyHostMetricsToPet derives the pet's display stats from a host metrics
// snapshot. Pure function over (pet, metrics, now) so it's table-testable
// without any mocks. Mutates pet in place; does NOT persist.
//
// Hunger / cleanliness / health snap to whatever the host metrics say —
// these stats are the pet's *display* of the host's posture, not an
// accumulator the user can grind. Happiness is the one persisted stat: it
// decays over time toward a target derived from disk encryption posture, and
// is bumped above target by event-driven signals (currently: successful
// self-service installs / uninstalls).
func applyHostMetricsToPet(pet *fleet.HostPet, m fleet.HostPetMetrics, now time.Time) {
pet.Hunger = hungerFromMetrics(m, now)
pet.Cleanliness = cleanlinessFromMetrics(m)
pet.Health = healthFromMetrics(m)
// 3. Cross-stat feedback: a starving, filthy pet loses health.
if pet.Hunger >= 90 {
pet.Health = adjustStat(pet.Health, -5)
}
if pet.Cleanliness <= 10 {
pet.Health = adjustStat(pet.Health, -5)
}
target := happinessTargetFromMetrics(m)
pet.Happiness = decayedHappiness(pet.Happiness, target, now.Sub(pet.LastInteractedAt))
// 4. Derived mood.
pet.Mood = computeMood(pet)
}
// adjustStat clamps stat ± delta to [HostPetStatFloor, HostPetStatCeiling].
// Using int for delta keeps callers ergonomic (negative deltas, int8 overflow
// sidestepped) while the stored value stays uint8.
func adjustStat(stat uint8, delta int) uint8 {
v := int(stat) + delta
// hungerFromMetrics: time-since-check-in → hunger band. Higher hunger means
// the pet is hungrier (i.e. the host hasn't checked in recently).
func hungerFromMetrics(m fleet.HostPetMetrics, now time.Time) uint8 {
if m.SeenTime.IsZero() {
// Brand-new host that's never checked in. Don't punish — return
// the baseline so the pet looks normal until the first check-in.
return fleet.HostPetTargetHungerBaseline
}
hours := now.Sub(m.SeenTime).Hours()
switch {
case hours < fleet.HostPetHungerHoursFresh:
return fleet.HostPetTargetHungerFresh
case hours < fleet.HostPetHungerHoursStale:
return fleet.HostPetTargetHungerStale
case hours < fleet.HostPetHungerHoursVeryStale:
return fleet.HostPetTargetHungerVeryStale
default:
return fleet.HostPetTargetHungerStarving
}
}
// cleanlinessFromMetrics: each failing policy drags cleanliness down from the
// baseline. All-passing → baseline.
func cleanlinessFromMetrics(m fleet.HostPetMetrics) uint8 {
drag := int(m.FailingPolicyCount) * int(fleet.HostPetCleanlinessPerFailingPolicy)
return clampStat(int(fleet.HostPetTargetCleanlinessBaseline) - drag)
}
// healthFromMetrics: critical/high vulns drain health hardest. MDM unenrolled
// adds a small additional penalty.
func healthFromMetrics(m fleet.HostPetMetrics) uint8 {
v := int(fleet.HostPetTargetHealthBaseline)
v -= int(m.CriticalVulnCount) * int(fleet.HostPetHealthPerCriticalVuln)
v -= int(m.HighVulnCount) * int(fleet.HostPetHealthPerHighVuln)
if m.MDMUnenrolled {
v -= int(fleet.HostPetHealthMDMUnenrolledPenalty)
}
return clampStat(v)
}
// happinessTargetFromMetrics: the floor that persisted happiness decays
// toward. Disk encryption is the only host-state signal that moves the
// target right now — self-service events bump happiness above it.
func happinessTargetFromMetrics(m fleet.HostPetMetrics) uint8 {
v := int(fleet.HostPetTargetHappinessBaseline)
if m.DiskEncryptionEnabled != nil {
if *m.DiskEncryptionEnabled {
v += int(fleet.HostPetHappinessDiskEncOnBonus)
} else {
v -= int(fleet.HostPetHappinessDiskEncOffPenalty)
}
}
return clampStat(v)
}
// decayedHappiness slides current toward target by happinessDecayPerHour
// per hour of elapsed time, capped at maxHappinessDecayWindow so a long
// idle stretch doesn't snap straight to target.
func decayedHappiness(current, target uint8, elapsed time.Duration) uint8 {
if elapsed < 0 {
elapsed = 0
}
if elapsed > maxHappinessDecayWindow {
elapsed = maxHappinessDecayWindow
}
step := int(elapsed.Hours() * float64(happinessDecayPerHour))
if step <= 0 {
return current
}
if current < target {
next := int(current) + step
if next > int(target) {
next = int(target)
}
return clampStat(next)
}
if current > target {
next := int(current) - step
if next < int(target) {
next = int(target)
}
return clampStat(next)
}
return current
}
// clampStat constrains v to [HostPetStatFloor, HostPetStatCeiling].
func clampStat(v int) uint8 {
if v < int(fleet.HostPetStatFloor) {
v = int(fleet.HostPetStatFloor)
}

View file

@ -0,0 +1,248 @@
//go:build pet_demo
// Package service: pet demo endpoints.
//
// These endpoints exist purely to drive the host_pets feature for live
// demos — they let an admin override what the pet derivation function
// "sees" (failing policies, vulnerability counts, time-since-last-checkin)
// without actually mutating policies, hosts, or vulnerabilities tables.
//
// They are gated by *both* a compile-time build tag and a runtime env
// var so they cannot accidentally reach production:
//
// 1. Compiled in only when the binary is built with `-tags pet_demo`.
// 2. Even then, every handler 404s unless FLEET_ENABLE_PET_DEMO=1 is
// set on the running server.
// 3. The handlers also require global-admin auth.
//
// Documentation for usage lives in `43625-pet-host-metrics-plan.md`.
package service
import (
"context"
"errors"
"net/http"
"os"
"time"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/contexts/viewer"
"github.com/fleetdm/fleet/v4/server/fleet"
eu "github.com/fleetdm/fleet/v4/server/platform/endpointer"
)
// petDemoEnvVar is the runtime gate. Set FLEET_ENABLE_PET_DEMO=1 on a
// pet_demo-tagged binary to actually serve these endpoints.
const petDemoEnvVar = "FLEET_ENABLE_PET_DEMO"
func petDemoEnabled() bool { return os.Getenv(petDemoEnvVar) == "1" }
// registerPetDemoEndpoints wires the demo routes onto the user-authenticated
// endpointer. Called from handler.go unconditionally; the stub version (in
// host_pets_demo_stub.go, default build) does nothing.
func registerPetDemoEndpoints(ue *eu.CommonEndpointer[handlerFunc]) {
ue.GET("/api/_version_/fleet/hosts/{id:[0-9]+}/pet/demo/overrides",
getHostPetDemoOverridesEndpoint, getHostPetDemoOverridesRequest{})
ue.POST("/api/_version_/fleet/hosts/{id:[0-9]+}/pet/demo/overrides",
upsertHostPetDemoOverridesEndpoint, upsertHostPetDemoOverridesRequest{})
ue.DELETE("/api/_version_/fleet/hosts/{id:[0-9]+}/pet/demo/overrides",
deleteHostPetDemoOverridesEndpoint, deleteHostPetDemoOverridesRequest{})
ue.POST("/api/_version_/fleet/hosts/{id:[0-9]+}/pet/demo/simulate_self_service",
simulateSelfServiceEndpoint, simulateSelfServiceRequest{})
}
// requirePetDemoAdmin enforces the runtime env-var gate AND a global-admin
// check. Returns nil on success, an error on rejection.
func requirePetDemoAdmin(ctx context.Context, svc *Service) error {
if !petDemoEnabled() {
// 404 rather than 403 — when the gate is off the endpoint shouldn't
// even appear to exist.
return ctxerr.Wrap(ctx, &notFoundError{})
}
// Satisfy the authz middleware with a baseline host check.
if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil {
return err
}
vc, ok := viewer.FromContext(ctx)
if !ok {
return fleet.ErrNoContext
}
if vc.User == nil || vc.User.GlobalRole == nil || *vc.User.GlobalRole != fleet.RoleAdmin {
return fleet.NewPermissionError("pet demo endpoints require global admin")
}
return nil
}
//----------------------------------------------------------------------------//
// GET /pet/demo/overrides //
//----------------------------------------------------------------------------//
type getHostPetDemoOverridesRequest struct {
ID uint `url:"id"`
}
type getHostPetDemoOverridesResponse struct {
HostID uint `json:"host_id"`
Overrides *fleet.HostPetDemoOverrides `json:"overrides"`
Err error `json:"error,omitempty"`
}
func (r getHostPetDemoOverridesResponse) Error() error { return r.Err }
func getHostPetDemoOverridesEndpoint(ctx context.Context, request any, sIface fleet.Service) (fleet.Errorer, error) {
req := request.(*getHostPetDemoOverridesRequest)
svc, ok := sIface.(*Service)
if !ok {
return getHostPetDemoOverridesResponse{Err: errors.New("internal: service is not *Service")}, nil
}
if err := requirePetDemoAdmin(ctx, svc); err != nil {
return getHostPetDemoOverridesResponse{Err: err}, nil
}
o, err := svc.ds.GetHostPetDemoOverrides(ctx, req.ID)
if err != nil {
return getHostPetDemoOverridesResponse{Err: ctxerr.Wrap(ctx, err, "get host pet demo overrides")}, nil
}
return getHostPetDemoOverridesResponse{HostID: req.ID, Overrides: o}, nil
}
//----------------------------------------------------------------------------//
// POST /pet/demo/overrides (upsert; merges into existing row) //
//----------------------------------------------------------------------------//
type upsertHostPetDemoOverridesRequest struct {
ID uint `url:"id"`
// All fields optional. Pass only what you want to change. Unset fields
// leave the existing value alone (PATCH-like semantics).
SeenTimeOverride *time.Time `json:"seen_time_override,omitempty"`
TimeOffsetHours *int `json:"time_offset_hours,omitempty"`
ExtraFailingPolicies *uint `json:"extra_failing_policies,omitempty"`
ExtraCriticalVulns *uint `json:"extra_critical_vulns,omitempty"`
ExtraHighVulns *uint `json:"extra_high_vulns,omitempty"`
// ClearSeenTimeOverride lets the caller explicitly null out
// seen_time_override (otherwise sending no field leaves it alone).
ClearSeenTimeOverride bool `json:"clear_seen_time_override,omitempty"`
}
type upsertHostPetDemoOverridesResponse struct {
HostID uint `json:"host_id"`
Overrides *fleet.HostPetDemoOverrides `json:"overrides"`
Err error `json:"error,omitempty"`
}
func (r upsertHostPetDemoOverridesResponse) Error() error { return r.Err }
func upsertHostPetDemoOverridesEndpoint(ctx context.Context, request any, sIface fleet.Service) (fleet.Errorer, error) {
req := request.(*upsertHostPetDemoOverridesRequest)
svc, ok := sIface.(*Service)
if !ok {
return upsertHostPetDemoOverridesResponse{Err: errors.New("internal: service is not *Service")}, nil
}
if err := requirePetDemoAdmin(ctx, svc); err != nil {
return upsertHostPetDemoOverridesResponse{Err: err}, nil
}
existing, err := svc.ds.GetHostPetDemoOverrides(ctx, req.ID)
if err != nil {
return upsertHostPetDemoOverridesResponse{Err: ctxerr.Wrap(ctx, err, "load existing overrides for merge")}, nil
}
merged := &fleet.HostPetDemoOverrides{HostID: req.ID}
if existing != nil {
*merged = *existing
merged.HostID = req.ID
}
if req.ClearSeenTimeOverride {
merged.SeenTimeOverride = nil
} else if req.SeenTimeOverride != nil {
merged.SeenTimeOverride = req.SeenTimeOverride
}
if req.TimeOffsetHours != nil {
merged.TimeOffsetHours = *req.TimeOffsetHours
}
if req.ExtraFailingPolicies != nil {
merged.ExtraFailingPolicies = *req.ExtraFailingPolicies
}
if req.ExtraCriticalVulns != nil {
merged.ExtraCriticalVulns = *req.ExtraCriticalVulns
}
if req.ExtraHighVulns != nil {
merged.ExtraHighVulns = *req.ExtraHighVulns
}
if err := svc.ds.UpsertHostPetDemoOverrides(ctx, merged); err != nil {
return upsertHostPetDemoOverridesResponse{Err: ctxerr.Wrap(ctx, err, "upsert host pet demo overrides")}, nil
}
// Re-read so timestamps are populated.
o, err := svc.ds.GetHostPetDemoOverrides(ctx, req.ID)
if err != nil {
return upsertHostPetDemoOverridesResponse{Err: ctxerr.Wrap(ctx, err, "reload after upsert")}, nil
}
return upsertHostPetDemoOverridesResponse{HostID: req.ID, Overrides: o}, nil
}
//----------------------------------------------------------------------------//
// DELETE /pet/demo/overrides (reset to no overrides) //
//----------------------------------------------------------------------------//
type deleteHostPetDemoOverridesRequest struct {
ID uint `url:"id"`
}
type deleteHostPetDemoOverridesResponse struct {
Err error `json:"error,omitempty"`
}
func (r deleteHostPetDemoOverridesResponse) Error() error { return r.Err }
func (r deleteHostPetDemoOverridesResponse) Status() int { return http.StatusNoContent }
func deleteHostPetDemoOverridesEndpoint(ctx context.Context, request any, sIface fleet.Service) (fleet.Errorer, error) {
req := request.(*deleteHostPetDemoOverridesRequest)
svc, ok := sIface.(*Service)
if !ok {
return deleteHostPetDemoOverridesResponse{Err: errors.New("internal: service is not *Service")}, nil
}
if err := requirePetDemoAdmin(ctx, svc); err != nil {
return deleteHostPetDemoOverridesResponse{Err: err}, nil
}
if err := svc.ds.DeleteHostPetDemoOverrides(ctx, req.ID); err != nil {
return deleteHostPetDemoOverridesResponse{Err: ctxerr.Wrap(ctx, err, "delete host pet demo overrides")}, nil
}
return deleteHostPetDemoOverridesResponse{}, nil
}
//----------------------------------------------------------------------------//
// POST /pet/demo/simulate_self_service (one-shot happiness bump) //
//----------------------------------------------------------------------------//
type simulateSelfServiceRequest struct {
ID uint `url:"id"`
Delta int `json:"delta"`
}
type simulateSelfServiceResponse struct {
Err error `json:"error,omitempty"`
}
func (r simulateSelfServiceResponse) Error() error { return r.Err }
func (r simulateSelfServiceResponse) Status() int { return http.StatusNoContent }
func simulateSelfServiceEndpoint(ctx context.Context, request any, sIface fleet.Service) (fleet.Errorer, error) {
req := request.(*simulateSelfServiceRequest)
svc, ok := sIface.(*Service)
if !ok {
return simulateSelfServiceResponse{Err: errors.New("internal: service is not *Service")}, nil
}
if err := requirePetDemoAdmin(ctx, svc); err != nil {
return simulateSelfServiceResponse{Err: err}, nil
}
delta := req.Delta
if delta == 0 {
// Default to the same bump a real install applies. Lets curl-only
// demos call POST .../simulate_self_service with no body.
delta = int(fleet.HostPetHappinessSelfServiceBump)
}
if err := svc.ds.ApplyHostPetHappinessDelta(ctx, req.ID, delta); err != nil {
return simulateSelfServiceResponse{Err: ctxerr.Wrap(ctx, err, "apply host pet happiness delta")}, nil
}
return simulateSelfServiceResponse{}, nil
}

View file

@ -0,0 +1,13 @@
//go:build !pet_demo
package service
import (
eu "github.com/fleetdm/fleet/v4/server/platform/endpointer"
)
// registerPetDemoEndpoints is a no-op in production builds. The full
// implementation lives in host_pets_demo.go and is only compiled when the
// `pet_demo` build tag is set, AND requires the FLEET_ENABLE_PET_DEMO=1 env
// var to actually serve. See `43625-pet-host-metrics-plan.md`.
func registerPetDemoEndpoints(_ *eu.CommonEndpointer[handlerFunc]) {}

View file

@ -0,0 +1,243 @@
package service
import (
"testing"
"time"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/stretchr/testify/assert"
)
func TestHungerFromMetrics(t *testing.T) {
now := time.Date(2026, 4, 15, 12, 0, 0, 0, time.UTC)
cases := []struct {
name string
seenAgo time.Duration
zeroSeen bool
want uint8
}{
{name: "never seen", zeroSeen: true, want: fleet.HostPetTargetHungerBaseline},
{name: "fresh check-in (30 min)", seenAgo: 30 * time.Minute, want: fleet.HostPetTargetHungerFresh},
{name: "boundary just under fresh", seenAgo: 59 * time.Minute, want: fleet.HostPetTargetHungerFresh},
{name: "stale (3h)", seenAgo: 3 * time.Hour, want: fleet.HostPetTargetHungerStale},
{name: "very stale (12h)", seenAgo: 12 * time.Hour, want: fleet.HostPetTargetHungerVeryStale},
{name: "starving (3 days)", seenAgo: 72 * time.Hour, want: fleet.HostPetTargetHungerStarving},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
m := fleet.HostPetMetrics{}
if !tc.zeroSeen {
m.SeenTime = now.Add(-tc.seenAgo)
}
got := hungerFromMetrics(m, now)
assert.Equal(t, tc.want, got)
})
}
}
func TestCleanlinessFromMetrics(t *testing.T) {
cases := []struct {
name string
failingPolicy uint
wantAtLeastMin bool // for floor cases
wantExact uint8
}{
{name: "no failing policies", failingPolicy: 0, wantExact: fleet.HostPetTargetCleanlinessBaseline},
{name: "1 failing policy", failingPolicy: 1, wantExact: fleet.HostPetTargetCleanlinessBaseline - fleet.HostPetCleanlinessPerFailingPolicy},
{name: "3 failing policies", failingPolicy: 3, wantExact: fleet.HostPetTargetCleanlinessBaseline - 3*fleet.HostPetCleanlinessPerFailingPolicy},
{name: "many failing policies clamps to floor", failingPolicy: 50, wantAtLeastMin: true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := cleanlinessFromMetrics(fleet.HostPetMetrics{FailingPolicyCount: tc.failingPolicy})
if tc.wantAtLeastMin {
assert.Equal(t, fleet.HostPetStatFloor, got)
} else {
assert.Equal(t, tc.wantExact, got)
}
})
}
}
func TestHealthFromMetrics(t *testing.T) {
cases := []struct {
name string
critical uint
high uint
mdmUnenrolled bool
want uint8
}{
{
name: "clean host",
want: fleet.HostPetTargetHealthBaseline,
},
{
name: "1 critical vuln",
critical: 1,
want: fleet.HostPetTargetHealthBaseline - fleet.HostPetHealthPerCriticalVuln,
},
{
name: "5 high vulns",
high: 5,
want: fleet.HostPetTargetHealthBaseline - 5*fleet.HostPetHealthPerHighVuln,
},
{
name: "mdm unenrolled penalty",
mdmUnenrolled: true,
want: fleet.HostPetTargetHealthBaseline - fleet.HostPetHealthMDMUnenrolledPenalty,
},
{
name: "stack of penalties clamps to floor",
critical: 20,
high: 20,
mdmUnenrolled: true,
want: fleet.HostPetStatFloor,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := healthFromMetrics(fleet.HostPetMetrics{
CriticalVulnCount: tc.critical,
HighVulnCount: tc.high,
MDMUnenrolled: tc.mdmUnenrolled,
})
assert.Equal(t, tc.want, got)
})
}
}
func TestHappinessTargetFromMetrics(t *testing.T) {
tr := true
fa := false
cases := []struct {
name string
disk *bool
want uint8
}{
{name: "unknown disk encryption", disk: nil, want: fleet.HostPetTargetHappinessBaseline},
{name: "disk encryption on", disk: &tr, want: fleet.HostPetTargetHappinessBaseline + fleet.HostPetHappinessDiskEncOnBonus},
{name: "disk encryption off", disk: &fa, want: fleet.HostPetTargetHappinessBaseline - fleet.HostPetHappinessDiskEncOffPenalty},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := happinessTargetFromMetrics(fleet.HostPetMetrics{DiskEncryptionEnabled: tc.disk})
assert.Equal(t, tc.want, got)
})
}
}
func TestDecayedHappiness(t *testing.T) {
cases := []struct {
name string
current uint8
target uint8
elapsed time.Duration
want uint8
}{
{name: "current at target stays put", current: 70, target: 70, elapsed: 24 * time.Hour, want: 70},
{name: "no elapsed time means no movement", current: 90, target: 70, elapsed: 0, want: 90},
{name: "negative elapsed clamped to zero", current: 90, target: 70, elapsed: -time.Hour, want: 90},
{name: "1h decays by happinessDecayPerHour above target", current: 90, target: 70, elapsed: time.Hour, want: 90 - happinessDecayPerHour},
{name: "5h overshoots target then clamps", current: 75, target: 70, elapsed: 5 * time.Hour, want: 70},
{name: "5h slides up toward target", current: 50, target: 70, elapsed: 5 * time.Hour, want: 50 + 5*happinessDecayPerHour},
{name: "month gap caps at decay window", current: 100, target: 50, elapsed: 30 * 24 * time.Hour, want: 50},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := decayedHappiness(tc.current, tc.target, tc.elapsed)
assert.Equal(t, tc.want, got)
})
}
}
func TestApplyHostMetricsToPet_FullPipeline(t *testing.T) {
now := time.Date(2026, 4, 15, 12, 0, 0, 0, time.UTC)
tr := true
pet := &fleet.HostPet{
Health: 50,
Happiness: 80,
Hunger: 50,
Cleanliness: 50,
LastInteractedAt: now.Add(-2 * time.Hour),
}
m := fleet.HostPetMetrics{
SeenTime: now.Add(-30 * time.Minute), // fresh
FailingPolicyCount: 2,
CriticalVulnCount: 0,
HighVulnCount: 0,
DiskEncryptionEnabled: &tr,
MDMUnenrolled: false,
}
applyHostMetricsToPet(pet, m, now)
// Hunger should snap to "fresh" band.
assert.Equal(t, fleet.HostPetTargetHungerFresh, pet.Hunger)
// Cleanliness: baseline - 2*15 = 60.
assert.Equal(t, uint8(fleet.HostPetTargetCleanlinessBaseline-2*fleet.HostPetCleanlinessPerFailingPolicy), pet.Cleanliness)
// Health: full baseline (no vulns, mdm enrolled).
assert.Equal(t, fleet.HostPetTargetHealthBaseline, pet.Health)
// Happiness target: baseline + disk-on bonus = 75. Current 80, 2h elapsed,
// decay 2/h → 76.
wantHappiness := uint8(80 - 2*happinessDecayPerHour)
assert.Equal(t, wantHappiness, pet.Happiness)
// Mood is computed.
assert.NotEmpty(t, pet.Mood)
}
func TestHostPetDemoOverrides_Apply(t *testing.T) {
now := time.Date(2026, 4, 15, 12, 0, 0, 0, time.UTC)
overrideSeen := now.Add(-48 * time.Hour)
m := fleet.HostPetMetrics{
SeenTime: now.Add(-30 * time.Minute),
FailingPolicyCount: 1,
CriticalVulnCount: 0,
HighVulnCount: 0,
}
o := &fleet.HostPetDemoOverrides{
SeenTimeOverride: &overrideSeen,
ExtraFailingPolicies: 4,
ExtraCriticalVulns: 2,
ExtraHighVulns: 3,
}
o.Apply(&m)
assert.Equal(t, overrideSeen, m.SeenTime, "seen time override should replace, not stack")
assert.Equal(t, uint(5), m.FailingPolicyCount, "extras should add to the real count")
assert.Equal(t, uint(2), m.CriticalVulnCount)
assert.Equal(t, uint(3), m.HighVulnCount)
// Nil overrides is a safe no-op.
var nilOverride *fleet.HostPetDemoOverrides
nilOverride.Apply(&m) // must not panic
}
func TestComputeMood(t *testing.T) {
cases := []struct {
name string
pet fleet.HostPet
want fleet.HostPetMood
}{
{name: "low health -> sick", pet: fleet.HostPet{Health: 20, Happiness: 80, Hunger: 30, Cleanliness: 80}, want: fleet.HostPetMoodSick},
{name: "high hunger -> hungry", pet: fleet.HostPet{Health: 80, Happiness: 80, Hunger: 90, Cleanliness: 80}, want: fleet.HostPetMoodHungry},
{name: "low cleanliness -> dirty", pet: fleet.HostPet{Health: 80, Happiness: 80, Hunger: 30, Cleanliness: 10}, want: fleet.HostPetMoodDirty},
{name: "low happiness -> sad", pet: fleet.HostPet{Health: 80, Happiness: 20, Hunger: 30, Cleanliness: 80}, want: fleet.HostPetMoodSad},
{name: "all good -> happy", pet: fleet.HostPet{Health: 90, Happiness: 90, Hunger: 10, Cleanliness: 90}, want: fleet.HostPetMoodHappy},
{name: "middle of the road -> content", pet: fleet.HostPet{Health: 70, Happiness: 60, Hunger: 40, Cleanliness: 50}, want: fleet.HostPetMoodContent},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
pet := tc.pet
got := computeMood(&pet)
assert.Equal(t, tc.want, got)
})
}
}

View file

@ -1021,6 +1021,20 @@ func (svc *Service) SaveHostScriptResult(ctx context.Context, result *fleet.Host
if err := svc.ds.UpdateHostRefetchRequested(ctx, host.ID, true); err != nil {
return ctxerr.Wrap(ctx, err, "queue host vitals refetch")
}
// Self-service uninstalls nudge the host's virtual pet's
// happiness — same pattern as the install hook above. Errors
// are logged and swallowed.
if selfService {
if err := svc.ds.ApplyHostPetHappinessDelta(ctx, host.ID, int(fleet.HostPetHappinessSelfServiceBump)); err != nil {
svc.logger.WarnContext(ctx,
"apply host pet happiness delta after self-service uninstall",
"host_id", host.ID,
"execution_id", hsr.ExecutionID,
"err", err,
)
}
}
}
default:
// TODO(sarah): We may need to special case lock/unlock script results here?
@ -1547,6 +1561,21 @@ func (svc *Service) SaveHostSoftwareInstallResult(ctx context.Context, result *f
return ctxerr.Wrap(ctx, err, "queue host vitals refetch")
}
}
// Self-service installs nudge the host's virtual pet's happiness — see
// `server/fleet/host_pet.go`. Errors here are logged and swallowed:
// the pet feature must never block a software install from completing.
// No-op for hosts without an adopted pet.
if hsi.SelfService && status == fleet.SoftwareInstalled {
if err := svc.ds.ApplyHostPetHappinessDelta(ctx, host.ID, int(fleet.HostPetHappinessSelfServiceBump)); err != nil {
svc.logger.WarnContext(ctx,
"apply host pet happiness delta after self-service install",
"host_id", host.ID,
"install_uuid", result.InstallUUID,
"err", err,
)
}
}
}
return nil
}