mirror of
https://github.com/fleetdm/fleet
synced 2026-04-21 13:37:30 +00:00
Updating game code to be on device actions not buttons
This commit is contained in:
parent
4ecc002f68
commit
406857973e
14 changed files with 1172 additions and 190 deletions
|
|
@ -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'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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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{})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
248
server/service/host_pets_demo.go
Normal file
248
server/service/host_pets_demo.go
Normal 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, ¬FoundError{})
|
||||
}
|
||||
// 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
|
||||
}
|
||||
13
server/service/host_pets_demo_stub.go
Normal file
13
server/service/host_pets_demo_stub.go
Normal 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]) {}
|
||||
243
server/service/host_pets_test.go
Normal file
243
server/service/host_pets_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue