Add support for host vitals labels (#30278)

# Details

This PR adds support for a new label membership type, `host_vitals`.
Membership for these labels is based on a database query created from
user-supplied criteria. In this first iteration, the allowed criteria
are very simple: a label can specify either an IdP group or IdP
department, and hosts with linked users with a matching group or
department.

Groundwork is laid here for more complex host vitals queries, including
`and` and `or` logic, different data types and different kinds of vitals
(rather than just the "foreign" vitals of which IdP is an example).

Note that this PR does _not_ include the cron job that will trigger
membership updating, and it doesn't include ; for sake of simplicity in
review that will be done in a follow-on PR.

## Basic flow

### Creating a host vitals label

1. A new label is created via the API / GitOps with membership type
`host_vitals` and a `criteria` property that's a JSON blob. Currently
the JSON can only contain `vital` and `value` keys (and must contain
those keys)
2. The server validates that the specified `vital` exists in our [set of
known host
vitals](https://github.com/fleetdm/fleet/pull/30278/files#diff-b6d4c48f2624b82c2567b2b88db1de51c6b152eeb261d40acfd5b63a890839b7R418-R436).
3. The server validates that the [criteria can be parsed into a
query](https://github.com/fleetdm/fleet/pull/30278/files?diff=unified&w=1#diff-4ac4cfba8bed490e8ef125a0556f5417156f805017bfe93c6e2c61aa94ba8a8cR81-R86).
This also happens during GitOps dry run.
4. The label is saved (criteria is saved as JSON in the db)

### Updating membership for a host vitals label

1. The label's criteria is used to generate a query to run on the
_Fleet_ db.
1. For each vital criteria, check the vital type. Currently only foreign
vitals are supported.
   2. For foreign vitals, add its group to a set we keep track of.
3. Add a `WHERE` clause section for the vital and value, e.g.
`end_user_idp_groups = ?`
4. Once we have all the `WHERE` clauses, create the query as `SELECT %s
FROM %s` + any joins contributed by foreign vitals groups + `WHERE ` +
all the `WHERE` clauses we just calculated. The `%s` provide some
flexibility if we want to use these queries in other contexts.
2. Delete all existing label members
3. Do an `INSERT...SELECT` using the query we calculated from the label
criteria. The query will be `SELECT <label id> as label_id, hosts.id
FROM hosts JOIN ...`

## Future work

### Domestic vitals

These can be anything that we already store in the `hosts` table.
Domestic vitals won't add any `JOIN`s to the calculated label query, and
will simply be e.g. `hosts.hostname = ?`

### Custom vitals

We currently support an `additional_queries` config that will cause
other queries to run on hosts. The data returned from these queries is
stored in a `hosts_additional` table as a JSON blob. We can use MySQL
JSON functions to match values in this data, e.g.
`JSON_EXTRACT(host_additional, `$.some_custom_vital`) = ?`

# Checklist for submitter

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

<!-- Note that API documentation changes are now addressed by the
product design team. -->

- [ ] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
> I'll add the changelog item when I add the cron job PR
- [X] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented (using placeholders for values in statements)
- [X] If database migrations are included, checked table schema to
confirm autoupdate
- For new Fleet configuration settings
- [X] Verified that the setting can be managed via GitOps, or confirmed
that the setting is explicitly being excluded from GitOps. If managing
via Gitops:
- [X] Verified that the setting is exported via `fleetctl
generate-gitops`
- [X] Verified that the setting is cleared on the server if it is not
supplied in a YAML file (or that it is documented as being optional)
- For database migrations:
- [X] Checked schema for all modified table for columns that will
auto-update timestamps during migration.
- [X] Confirmed that updating the timestamps is acceptable, and will not
cause unwanted side effects.
- [X] Ensured the correct collation is explicitly set for character
columns (`COLLATE utf8mb4_unicode_ci`).
- [X] Added/updated automated tests
- [X] Manual QA for all new/changed functionality
This commit is contained in:
Scott Gress
2025-06-30 09:58:58 -05:00
committed by GitHub
parent 639cbb510b
commit af2de5bc42
15 changed files with 936 additions and 350 deletions
+6 -3
View File
@@ -1279,10 +1279,13 @@ func (cmd *GenerateGitopsCommand) generateLabels() ([]map[string]interface{}, er
if label.Platform != "" {
labelSpec[jsonFieldName(t, "Platform")] = label.Platform
}
if label.LabelMembershipType == fleet.LabelMembershipTypeDynamic {
labelSpec[jsonFieldName(t, "Query")] = label.Query
} else {
switch label.LabelMembershipType {
case fleet.LabelMembershipTypeManual:
labelSpec[jsonFieldName(t, "Hosts")] = label.Hosts
case fleet.LabelMembershipTypeDynamic:
labelSpec[jsonFieldName(t, "Query")] = label.Query
case fleet.LabelMembershipTypeHostVitals:
labelSpec[jsonFieldName(t, "HostVitalsCriteria")] = label.HostVitalsCriteria
}
result = append(result, labelSpec)
@@ -361,6 +361,11 @@ func (MockClient) GetLabels() ([]*fleet.LabelSpec, error) {
Description: "Label B description",
LabelMembershipType: fleet.LabelMembershipTypeManual,
Hosts: []string{"host1", "host2"},
}, {
Name: "Label C",
Description: "Label C description",
LabelMembershipType: fleet.LabelMembershipTypeHostVitals,
HostVitalsCriteria: ptr.RawMessage(json.RawMessage(`{"vital": "end_user_idp_group", "value": "some-group"}`)),
}}, nil
}
@@ -9,3 +9,9 @@
hosts:
- host1
- host2
- name: Label C
description: Label C description
label_membership_type: host_vitals
criteria:
vital: end_user_idp_group
value: some-group
@@ -39,6 +39,12 @@ labels:
- host2
label_membership_type: manual
name: Label B
- criteria:
value: some-group
vital: end_user_idp_group
description: Label C description
label_membership_type: host_vitals
name: Label C
org_settings:
features:
additional_queries:
@@ -24,6 +24,12 @@ labels:
- host2
label_membership_type: manual
name: Label B
- criteria:
value: some-group
vital: end_user_idp_group
description: Label C description
label_membership_type: host_vitals
name: Label C
org_settings:
features:
additional_queries:
+17 -2
View File
@@ -675,13 +675,28 @@ func parseLabels(top map[string]json.RawMessage, result *GitOps, baseDir string,
if l.Name == "" {
multiError = multierror.Append(multiError, errors.New("name is required for each label"))
}
if l.Query == "" && len(l.Hosts) == 0 {
multiError = multierror.Append(multiError, errors.New("a SQL query or hosts list is required for each label"))
if l.Query == "" && len(l.Hosts) == 0 && l.HostVitalsCriteria == nil {
multiError = multierror.Append(multiError, errors.New("a SQL query, hosts list or host vitals criteria is required for each label"))
}
// Don't use non-ASCII
if !isASCII(l.Name) {
multiError = multierror.Append(multiError, fmt.Errorf("label name must be in ASCII: %s", l.Name))
}
// Check that host vitals criteria is valid
if l.HostVitalsCriteria != nil {
criteriaJson, err := json.Marshal(l.HostVitalsCriteria)
if err != nil {
multiError = multierror.Append(multiError, fmt.Errorf("failed to marshal host vitals criteria for label %s: %v", l.Name, err))
continue
}
label := fleet.Label{
Name: l.Name,
HostVitalsCriteria: ptr.RawMessage(criteriaJson),
}
if _, _, err := label.CalculateHostVitalsQuery(); err != nil {
multiError = multierror.Append(multiError, fmt.Errorf("invalid host vitals criteria for label %s: %v", l.Name, err))
}
}
}
duplicates := getDuplicateNames(
result.Labels, func(l *fleet.LabelSpec) string {
+61 -5
View File
@@ -33,15 +33,17 @@ func (ds *Datastore) ApplyLabelSpecsWithAuthor(ctx context.Context, specs []*fle
platform,
label_type,
label_membership_type,
criteria,
author_id
) VALUES ( ?, ?, ?, ?, ?, ?, ? )
) VALUES ( ?, ?, ?, ?, ?, ?, ?, ? )
ON DUPLICATE KEY UPDATE
name = VALUES(name),
description = VALUES(description),
query = VALUES(query),
platform = VALUES(platform),
label_type = VALUES(label_type),
label_membership_type = VALUES(label_membership_type)
label_membership_type = VALUES(label_membership_type),
criteria = VALUES(criteria)
`
prepTx, ok := tx.(sqlx.PreparerContext)
@@ -58,7 +60,7 @@ func (ds *Datastore) ApplyLabelSpecsWithAuthor(ctx context.Context, specs []*fle
if s.Name == "" {
return ctxerr.New(ctx, "label name must not be empty")
}
_, err := stmt.ExecContext(ctx, s.Name, s.Description, s.Query, s.Platform, s.LabelType, s.LabelMembershipType, authorID)
_, err := stmt.ExecContext(ctx, s.Name, s.Description, s.Query, s.Platform, s.LabelType, s.LabelMembershipType, s.HostVitalsCriteria, authorID)
if err != nil {
return ctxerr.Wrap(ctx, err, "exec ApplyLabelSpecs insert")
}
@@ -182,6 +184,58 @@ VALUES ` + strings.Join(placeholders, ", ")
return ds.labelDB(ctx, labelID, teamFilter, ds.writer(ctx))
}
// Update label membership for a host vitals label.
func (ds *Datastore) UpdateLabelMembershipByHostCriteria(ctx context.Context, hvl fleet.HostVitalsLabel) (*fleet.Label, error) {
// Get the label data.
label := hvl.GetLabel()
// If the label isn't a host vitals label, bail out.
if label.LabelMembershipType != fleet.LabelMembershipTypeHostVitals {
return nil, ctxerr.New(ctx, "label is not a host vitals label")
}
// Get the query and value params for the host vitals label.
query, queryVals, err := hvl.CalculateHostVitalsQuery()
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "calculating host vitals query")
}
if query == "" {
return nil, ctxerr.New(ctx, "label query is empty after calculating host vitals query")
}
err = ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
labelSelect := fmt.Sprintf("%d as label_id, hosts.id as host_id", label.ID)
labelQuery := fmt.Sprintf(query, labelSelect, "hosts")
// Insert new label membership based on the label query.
sql := fmt.Sprintf(`INSERT INTO label_membership (label_id, host_id) SELECT candidate.label_id, candidate.host_id FROM (%s) as candidate ON DUPLICATE KEY UPDATE host_id = label_membership.host_id`, labelQuery)
_, err := tx.ExecContext(ctx, sql, queryVals...)
if err != nil {
return ctxerr.Wrap(ctx, err, "execute membership INSERT")
}
// Remove any existing label membership for the label that is not in the new query.
sql = fmt.Sprintf(`DELETE FROM label_membership WHERE label_id = %d AND NOT EXISTS (SELECT 1 FROM (%s) as candidate WHERE candidate.host_id = label_membership.host_id)`, label.ID, labelQuery)
_, err = tx.ExecContext(ctx, sql, queryVals...)
if err != nil {
return ctxerr.Wrap(ctx, err, "execute membership DELETE")
}
// Get the new number of members.
sql = `SELECT COUNT(*) FROM label_membership WHERE label_id = ?`
var count int
if err := sqlx.GetContext(ctx, tx, &count, sql, label.ID); err != nil {
return ctxerr.Wrap(ctx, err, "get label membership count")
}
label.HostCount = count
return nil
})
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "UpdateLabelMembershipByHostCriteria transaction")
}
return label, err
}
func batchHostIds(hostIds []uint) [][]uint {
// same functionality as `batchHostnames`, but for host IDs
const batchSize = 50000 // Large, but well under the undocumented limit
@@ -197,7 +251,7 @@ func batchHostIds(hostIds []uint) [][]uint {
func (ds *Datastore) GetLabelSpecs(ctx context.Context) ([]*fleet.LabelSpec, error) {
var specs []*fleet.LabelSpec
// Get basic specs
query := "SELECT id, name, description, query, platform, label_type, label_membership_type FROM labels"
query := "SELECT id, name, description, query, platform, label_type, label_membership_type, criteria FROM labels"
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &specs, query); err != nil {
return nil, ctxerr.Wrap(ctx, err, "get labels")
}
@@ -268,11 +322,12 @@ func (ds *Datastore) NewLabel(ctx context.Context, label *fleet.Label, opts ...f
name,
description,
query,
criteria,
platform,
label_type,
label_membership_type,
author_id
) VALUES ( ?, ?, ?, ?, ?, ?, ?)
) VALUES ( ?, ?, ?, ?, ?, ?, ?, ? )
`
result, err := ds.writer(ctx).ExecContext(
ctx,
@@ -280,6 +335,7 @@ func (ds *Datastore) NewLabel(ctx context.Context, label *fleet.Label, opts ...f
label.Name,
label.Description,
label.Query,
label.HostVitalsCriteria,
label.Platform,
label.LabelType,
label.LabelMembershipType,
+120
View File
@@ -2,6 +2,7 @@ package mysql
import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
@@ -96,6 +97,7 @@ func TestLabels(t *testing.T) {
{"ListHostsInLabelOSSettings", testLabelsListHostsInLabelOSSettings},
{"AddDeleteLabelsToFromHost", testAddDeleteLabelsToFromHost},
{"ApplyLabelSpecSerialUUID", testApplyLabelSpecsForSerialUUID},
{"UpdateLabelMembershipByHostCriteria", testUpdateLabelMembershipByHostCriteria},
}
// call TruncateTables first to remove migration-created labels
TruncateTables(t, ds)
@@ -2009,3 +2011,121 @@ func testApplyLabelSpecsForSerialUUID(t *testing.T, ds *Datastore) {
require.Equal(t, host2.ID, hosts[1].ID)
require.Equal(t, host3.ID, hosts[2].ID)
}
type TestHostVitalsLabel struct {
fleet.Label
}
func (t *TestHostVitalsLabel) CalculateHostVitalsQuery() (string, []interface{}, error) {
return "SELECT %s FROM %s JOIN host_users ON (host_users.host_id = hosts.id) WHERE host_users.username = ?", []interface{}{"user1"}, nil
}
func (t *TestHostVitalsLabel) GetLabel() *fleet.Label {
return &t.Label
}
func testUpdateLabelMembershipByHostCriteria(t *testing.T, ds *Datastore) {
ctx := context.Background()
hosts := make([]*fleet.Host, 4)
for i := 1; i <= 4; i++ {
host, err := ds.NewHost(ctx, &fleet.Host{
OsqueryHostID: ptr.String(fmt.Sprintf("%d", i)),
NodeKey: ptr.String(fmt.Sprintf("%d", i)),
UUID: fmt.Sprintf("uuid%d", i),
Hostname: fmt.Sprintf("host%d.local", i),
HardwareSerial: fmt.Sprintf("hwd%d", i),
Platform: "darwin",
})
require.NoError(t, err)
hosts[i-1] = host
}
// Add users to the hosts
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx, `
INSERT INTO host_users (host_id, uid, username) VALUES
(?, ?, ?),
(?, ?, ?),
(?, ?, ?),
(?, ?, ?),
(?, ?, ?)`,
hosts[0].ID, 1, "user1",
hosts[1].ID, 2, "user2",
hosts[2].ID, 1, "user1",
hosts[2].ID, 3, "user3",
hosts[3].ID, 3, "user3")
return err
})
criteria, err := json.Marshal(&fleet.HostVitalCriteria{
Vital: ptr.String("username"),
Value: ptr.String("user1"),
})
require.NoError(t, err)
var id uint
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
result, err := q.ExecContext(context.Background(),
"INSERT INTO labels (name, description, platform, label_type, label_membership_type) VALUES (?, ?, ?, ?, ?)",
"test host vitals label", "test", "", fleet.LabelTypeRegular, fleet.LabelMembershipTypeHostVitals)
if err != nil {
return err
}
id64, err := result.LastInsertId()
if err != nil {
return err
}
id = uint(id64) // nolint:gosec
return nil
})
label := &TestHostVitalsLabel{
Label: fleet.Label{
ID: id,
Name: "Test Host Vitals Label",
LabelType: fleet.LabelTypeRegular,
LabelMembershipType: fleet.LabelMembershipTypeHostVitals,
HostVitalsCriteria: ptr.RawMessage(criteria),
},
}
filter := fleet.TeamFilter{User: test.UserAdmin}
updatedLabel, err := ds.UpdateLabelMembershipByHostCriteria(ctx, label)
require.NoError(t, err)
require.Equal(t, 2, updatedLabel.HostCount)
// Check that the label has the correct hosts
hostsInLabel, err := ds.ListHostsInLabel(ctx, filter, label.ID, fleet.HostListOptions{})
require.NoError(t, err)
require.Len(t, hostsInLabel, 2) // Only hosts 1 and 3 should match the criteria (user1)
require.ElementsMatch(t, []uint{hosts[0].ID, hosts[2].ID}, []uint{hostsInLabel[0].ID, hostsInLabel[1].ID})
// Update host users.
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx, `
INSERT INTO host_users (host_id, uid, username) VALUES
(?, ?, ?),
(?, ?, ?),
(?, ?, ?) ON DUPLICATE KEY UPDATE username = VALUES(username), uid = VALUES(uid)`,
hosts[0].ID, 2, "user2",
hosts[1].ID, 1, "user1",
hosts[3].ID, 1, "user1")
return err
})
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx, `
DELETE FROM host_users WHERE host_id = ? AND uid = ?`,
hosts[0].ID, 1) // Remove user1 from host 1
return err
})
updatedLabel, err = ds.UpdateLabelMembershipByHostCriteria(ctx, label)
require.NoError(t, err)
require.Equal(t, 3, updatedLabel.HostCount)
// Check that the label has the correct hosts
hostsInLabel, err = ds.ListHostsInLabel(ctx, filter, label.ID, fleet.HostListOptions{})
require.NoError(t, err)
require.Len(t, hostsInLabel, 3) // Only hosts 2, 3 and 4 should match the criteria (user1)
require.ElementsMatch(t, []uint{hosts[1].ID, hosts[2].ID, hosts[3].ID}, []uint{hostsInLabel[0].ID, hostsInLabel[1].ID, hostsInLabel[2].ID})
}
@@ -0,0 +1,25 @@
package tables
import (
"database/sql"
"fmt"
)
func init() {
MigrationClient.AddMigration(Up_20250629131032, Down_20250629131032)
}
func Up_20250629131032(tx *sql.Tx) error {
_, err := tx.Exec(
"ALTER TABLE `labels` " +
"ADD COLUMN `criteria` json DEFAULT NULL; ",
)
if err != nil {
return fmt.Errorf("failed to add criteria column to labels table: %w", err)
}
return nil
}
func Down_20250629131032(tx *sql.Tx) error {
return nil
}
File diff suppressed because one or more lines are too long
+48
View File
@@ -387,6 +387,54 @@ type Host struct {
Policies *[]*HostPolicy `json:"policies,omitempty" csv:"-"`
}
type HostForeignVitalGroup struct {
Name string
Query string
}
type HostVitalType int
const (
HostVitalTypeDomestic HostVitalType = iota // Domestic vitals are those that are stored in the host table
HostVitalTypeForeign // Foreign vitals are those that are stored in a separate table and joined to the host table
HostVitalTypeAdditional // Additional vitals are those that are stored in the host_additional table as a JSON blob
)
type HostVital struct {
Name string // Display name of the vital
VitalType HostVitalType
DataType string // Data type of the vital, e.g. "string", "int", "bool"
ForeignVitalGroup *string // For foreign vitals, the group they belong to
Path string // Path to the vital in the SQL query, for use in generating the WHERE clause
}
var hostForeignVitalGroups = map[string]HostForeignVitalGroup{
"idp": {
Name: "Identity Provider",
Query: `RIGHT JOIN host_scim_user ON (hosts.id = host_scim_user.host_id) JOIN scim_users ON (host_scim_user.scim_user_id = scim_users.id) JOIN scim_user_group ON (host_scim_user.scim_user_id = scim_user_group.scim_user_id) JOIN scim_groups ON (scim_user_group.group_id = scim_groups.id)`,
},
}
var hostVitals = map[string]HostVital{
"end_user_idp_group": {
Name: "IDP Group",
VitalType: HostVitalTypeForeign,
// A user can be in multiple groups, but we use a join table to specify them,
// so we can represent "group" as a string rather than an array and use AND/OR
// criteria to filter hosts by group membership.
DataType: "string",
ForeignVitalGroup: ptr.String("idp"),
Path: "scim_groups.display_name",
},
"end_user_idp_department": {
Name: "IDP Department",
VitalType: HostVitalTypeForeign,
DataType: "string",
ForeignVitalGroup: ptr.String("idp"),
Path: "scim_users.department",
},
}
type AndroidHost struct {
*Host
*android.Device
+120
View File
@@ -1,6 +1,8 @@
package fleet
import (
"encoding/json"
"errors"
"fmt"
"strings"
"time"
@@ -18,6 +20,24 @@ type ModifyLabelPayload struct {
HostIDs []uint `json:"host_ids"`
}
type HostVitalOperator string
const (
HostVitalOperatorEqual HostVitalOperator = "="
HostVitalOperatorNotEqual HostVitalOperator = "!="
HostVitalOperatorGreater HostVitalOperator = ">"
HostVitalOperatorLess HostVitalOperator = "<"
HostVitalOperatorLike HostVitalOperator = "LIKE"
)
type HostVitalCriteria struct {
Vital *string `json:"vital,omitempty"`
Value *string `json:"value,omitempty"`
Operator *HostVitalOperator `json:"operator,omitempty"`
And []HostVitalCriteria `json:"and,omitempty"`
Or []HostVitalCriteria `json:"or,omitempty"`
}
type LabelPayload struct {
Name string `json:"name"`
// Query is the SQL query that defines the label. This defines a dynamic
@@ -33,6 +53,8 @@ type LabelPayload struct {
// host. Must be empty for a dynamic label.
Hosts []string `json:"hosts"`
HostIDs []uint `json:"host_ids"`
// Criteria is the set of criteria that defines a host vitals label.
Criteria *HostVitalCriteria `json:"criteria,omitempty"`
}
// LabelType is used to catagorize the kind of label
@@ -78,6 +100,9 @@ const (
LabelMembershipTypeDynamic LabelMembershipType = iota
// LabelTypeManual indicates that the label is populated manually.
LabelMembershipTypeManual
// LabelMembershipTypeHostVitals indicates that the label is populated
// dynamically based on host vitals data.
LabelMembershipTypeHostVitals
)
func (t LabelMembershipType) MarshalJSON() ([]byte, error) {
@@ -86,6 +111,8 @@ func (t LabelMembershipType) MarshalJSON() ([]byte, error) {
return []byte(`"dynamic"`), nil
case LabelMembershipTypeManual:
return []byte(`"manual"`), nil
case LabelMembershipTypeHostVitals:
return []byte(`"host_vitals"`), nil
default:
return nil, fmt.Errorf("invalid LabelMembershipType: %d", t)
}
@@ -97,12 +124,21 @@ func (t *LabelMembershipType) UnmarshalJSON(b []byte) error {
*t = LabelMembershipTypeDynamic
case `"manual"`:
*t = LabelMembershipTypeManual
case `"host_vitals"`:
*t = LabelMembershipTypeHostVitals
default:
return fmt.Errorf("invalid LabelMembershipType: %s", string(b))
}
return nil
}
// Create a separate interface for host vitals labels to allow for
// different query generation logic in tests.
type HostVitalsLabel interface {
CalculateHostVitalsQuery() (query string, values []any, err error)
GetLabel() *Label
}
type Label struct {
UpdateCreateTimestamps
ID uint `json:"id"`
@@ -110,12 +146,18 @@ type Label struct {
Name string `json:"name"`
Description string `json:"description"`
Query string `json:"query"`
HostVitalsCriteria *json.RawMessage `json:"criteria,omitempty" db:"criteria"`
Platform string `json:"platform"`
LabelType LabelType `json:"label_type" db:"label_type"`
LabelMembershipType LabelMembershipType `json:"label_membership_type" db:"label_membership_type"`
HostCount int `json:"host_count,omitempty" db:"host_count"`
}
// Implement the HostVitalsLabel interface.
func (l *Label) GetLabel() *Label {
return l
}
type LabelSummary struct {
ID uint `json:"id"`
Name string `json:"name"`
@@ -148,6 +190,7 @@ type LabelSpec struct {
LabelType LabelType `json:"label_type,omitempty" db:"label_type"`
LabelMembershipType LabelMembershipType `json:"label_membership_type" db:"label_membership_type"`
Hosts []string `json:"hosts"`
HostVitalsCriteria *json.RawMessage `json:"criteria,omitempty" db:"criteria"`
}
const (
@@ -258,3 +301,80 @@ func (l *LabelIdentsWithScope) Equal(other *LabelIdentsWithScope) bool {
return true
}
// Translate label host vitals crteria into a query.
// TODO -- add caching support for this query?
func (l *Label) CalculateHostVitalsQuery() (query string, values []any, err error) {
var criteria *HostVitalCriteria
if l.HostVitalsCriteria == nil {
return "", nil, errors.New("label has no host vitals criteria")
}
// Unmarshal the criteria from JSON.
if err := json.Unmarshal(*l.HostVitalsCriteria, &criteria); err != nil {
return "", nil, fmt.Errorf("unmarshalling host vitals criteria: %w", err)
}
// We'll use a set to gather the foreign vitals groups we need to join on,
// so that we can avoid duplicates.
foreignVitalsGroups := make(map[*HostForeignVitalGroup]struct{})
// Hold values to be substituted in the paramerized query.
values = make([]any, 0)
// Recursively parse the criteria to build the WHERE clause.
whereClause, err := parseHostVitalCriteria(criteria, foreignVitalsGroups, &values)
if err != nil {
return "", nil, fmt.Errorf("parsing host vitals criteria: %w", err)
}
// If there are foreign vitals groups, concatenate all their joins.
joins := make([]string, 0, len(foreignVitalsGroups))
if len(foreignVitalsGroups) > 0 {
for group := range foreignVitalsGroups {
joins = append(joins, group.Query)
}
}
// Leave SELECT and FROM to be filled in later for flexibility.
query = "SELECT %s FROM %s " + strings.Join(joins, " ") + " WHERE " + whereClause + " GROUP BY hosts.id"
return
}
// Translates a HostVitalCriteria into part of a SQL WHERE clause
// TODO: add support for And/Or criteria
func parseHostVitalCriteria(criteria *HostVitalCriteria, foreignVitalsGroups map[*HostForeignVitalGroup]struct{}, values *[]any) (string, error) {
// We don't support anything other than vital/value right now.
if criteria.And != nil || criteria.Or != nil {
return "", errors.New("And/Or criteria not supported in host vitals labels yet")
}
if criteria.Vital == nil {
return "", errors.New("vital criteria must have a vital")
}
if criteria.Value == nil {
return "", fmt.Errorf("vital %s must have a value", *criteria.Vital)
}
// Look up the vital in the map.
vital, ok := hostVitals[*criteria.Vital]
if !ok {
return "", fmt.Errorf("unknown vital %s", *criteria.Vital)
}
// If the vital is a foreign vitals group, add it to the list of foreign vitals groups.
if vital.VitalType == HostVitalTypeForeign {
foreignVitalsGroup, ok := hostForeignVitalGroups[*vital.ForeignVitalGroup]
if !ok {
return "", fmt.Errorf("unknown foreign vital group %s", *vital.ForeignVitalGroup)
}
foreignVitalsGroups[&foreignVitalsGroup] = struct{}{}
}
*values = append(*values, *criteria.Value)
operator := criteria.Operator
if operator == nil {
// Default to equality if no operator is specified.
op := HostVitalOperatorEqual
operator = &op
}
// TODO - handle different vital data types and operator types.
// For now, we only support equality checks.
if *operator != HostVitalOperatorEqual {
return "", fmt.Errorf("operator %s not supported for vital %s", *operator, *criteria.Vital)
}
return fmt.Sprintf("%s = ?", vital.Path), nil
}
+450 -331
View File
@@ -4249,373 +4249,492 @@ func (s *integrationTestSuite) TestLabels() {
manualHosts := hosts[:3]
lbl2Hosts := hosts[3:]
// list labels, has the built-in ones
builtinsMap := fleet.ReservedLabelNames()
var listResp listLabelsResponse
s.DoJSON("GET", "/api/latest/fleet/labels", nil, http.StatusOK, &listResp)
assert.True(t, len(listResp.Labels) > 0)
var builtinLbl fleet.Label
for _, lbl := range listResp.Labels {
_, ok := builtinsMap[lbl.Name]
assert.True(t, ok)
assert.Equal(t, fleet.LabelTypeBuiltIn, lbl.LabelType)
builtinLbl = lbl.Label
}
builtInsCount := len(listResp.Labels)
require.Equal(t, builtInsCount, len(builtinsMap))
t.Run("Manual and Dynamic Labels", func(t *testing.T) {
// list labels, has the built-in ones
builtinsMap := fleet.ReservedLabelNames()
var listResp listLabelsResponse
s.DoJSON("GET", "/api/latest/fleet/labels", nil, http.StatusOK, &listResp)
assert.True(t, len(listResp.Labels) > 0)
var builtinLbl fleet.Label
for _, lbl := range listResp.Labels {
_, ok := builtinsMap[lbl.Name]
assert.True(t, ok)
assert.Equal(t, fleet.LabelTypeBuiltIn, lbl.LabelType)
builtinLbl = lbl.Label
}
builtInsCount := len(listResp.Labels)
require.Equal(t, builtInsCount, len(builtinsMap))
// labels summary has the built-in ones
var summaryResp getLabelsSummaryResponse
s.DoJSON("GET", "/api/latest/fleet/labels/summary", nil, http.StatusOK, &summaryResp)
assert.Len(t, summaryResp.Labels, builtInsCount)
for _, lbl := range summaryResp.Labels {
_, ok := builtinsMap[lbl.Name]
assert.True(t, ok)
assert.Equal(t, fleet.LabelTypeBuiltIn, lbl.LabelType)
}
// labels summary has the built-in ones
var summaryResp getLabelsSummaryResponse
s.DoJSON("GET", "/api/latest/fleet/labels/summary", nil, http.StatusOK, &summaryResp)
assert.Len(t, summaryResp.Labels, builtInsCount)
for _, lbl := range summaryResp.Labels {
_, ok := builtinsMap[lbl.Name]
assert.True(t, ok)
assert.Equal(t, fleet.LabelTypeBuiltIn, lbl.LabelType)
}
// create a label without name, an error
var createResp createLabelResponse
s.DoJSON("POST", "/api/latest/fleet/labels", &fleet.LabelPayload{Query: "select 1"}, http.StatusUnprocessableEntity, &createResp)
// create a label without name, an error
var createResp createLabelResponse
s.DoJSON("POST", "/api/latest/fleet/labels", &fleet.LabelPayload{Query: "select 1"}, http.StatusUnprocessableEntity, &createResp)
// create a label with both a query and hosts, error
res := s.Do("POST", "/api/latest/fleet/labels", &fleet.LabelPayload{Name: t.Name(), Query: "select 1", Hosts: []string{manualHosts[0].UUID}}, http.StatusUnprocessableEntity)
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, `Only one of either "query" or "hosts/host_ids" can be included in the request.`)
// create a label with both a query and hosts, error
res := s.Do("POST", "/api/latest/fleet/labels", &fleet.LabelPayload{Name: t.Name(), Query: "select 1", Hosts: []string{manualHosts[0].UUID}}, http.StatusUnprocessableEntity)
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, `Only one of "criteria", "query" or "hosts/host_ids" can be included in the request.`)
// create invalid label, conflicts with builtin name
for n := range builtinsMap {
s.DoJSON("POST", "/api/latest/fleet/labels", &fleet.LabelPayload{Name: n, Query: "select 1"}, http.StatusUnprocessableEntity, &createResp)
}
// create invalid label, conflicts with builtin name
for n := range builtinsMap {
s.DoJSON("POST", "/api/latest/fleet/labels", &fleet.LabelPayload{Name: n, Query: "select 1"}, http.StatusUnprocessableEntity, &createResp)
}
// create a valid dynamic label
s.DoJSON("POST", "/api/latest/fleet/labels", &fleet.LabelPayload{Name: t.Name(), Query: "select 1"}, http.StatusOK, &createResp)
assert.NotZero(t, createResp.Label.ID)
assert.Equal(t, t.Name(), createResp.Label.Name)
assert.Empty(t, createResp.Label.HostIDs)
lbl1 := createResp.Label.Label
// create a valid dynamic label
s.DoJSON("POST", "/api/latest/fleet/labels", &fleet.LabelPayload{Name: t.Name(), Query: "select 1"}, http.StatusOK, &createResp)
assert.NotZero(t, createResp.Label.ID)
assert.Equal(t, t.Name(), createResp.Label.Name)
assert.Empty(t, createResp.Label.HostIDs)
lbl1 := createResp.Label.Label
// try to create a manual label with the same name
s.DoJSON("POST", "/api/latest/fleet/labels", &fleet.LabelPayload{Name: lbl1.Name, Hosts: []string{manualHosts[0].UUID}}, http.StatusConflict, &createResp)
// try to create a dynamic label with the same name
s.DoJSON("POST", "/api/latest/fleet/labels", &fleet.LabelPayload{Name: lbl1.Name, Query: "select 2"}, http.StatusConflict, &createResp)
// try to create a manual label with the same name
s.DoJSON("POST", "/api/latest/fleet/labels", &fleet.LabelPayload{Name: lbl1.Name, Hosts: []string{manualHosts[0].UUID}}, http.StatusConflict, &createResp)
// try to create a dynamic label with the same name
s.DoJSON("POST", "/api/latest/fleet/labels", &fleet.LabelPayload{Name: lbl1.Name, Query: "select 2"}, http.StatusConflict, &createResp)
// get the label
var getResp getLabelResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d", lbl1.ID), nil, http.StatusOK, &getResp)
assert.Equal(t, lbl1.ID, getResp.Label.ID)
assert.Empty(t, getResp.Label.HostIDs)
// get the label
var getResp getLabelResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d", lbl1.ID), nil, http.StatusOK, &getResp)
assert.Equal(t, lbl1.ID, getResp.Label.ID)
assert.Empty(t, getResp.Label.HostIDs)
// get a non-existing label
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d", lbl1.ID+1), nil, http.StatusNotFound, &getResp)
// get a non-existing label
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d", lbl1.ID+1), nil, http.StatusNotFound, &getResp)
// create a valid manual label
createResp = createLabelResponse{}
s.DoJSON("POST", "/api/latest/fleet/labels", &fleet.LabelPayload{Name: t.Name() + "manual", Hosts: []string{manualHosts[0].UUID, manualHosts[1].Hostname, *manualHosts[2].NodeKey}}, http.StatusOK, &createResp)
assert.NotZero(t, createResp.Label.ID)
assert.Equal(t, t.Name()+"manual", createResp.Label.Name)
assert.ElementsMatch(t, []uint{manualHosts[0].ID, manualHosts[1].ID, manualHosts[2].ID}, createResp.Label.HostIDs)
manualLbl1 := createResp.Label.Label
// create a valid manual label
createResp = createLabelResponse{}
s.DoJSON("POST", "/api/latest/fleet/labels", &fleet.LabelPayload{Name: t.Name() + "manual", Hosts: []string{manualHosts[0].UUID, manualHosts[1].Hostname, *manualHosts[2].NodeKey}}, http.StatusOK, &createResp)
assert.NotZero(t, createResp.Label.ID)
assert.Equal(t, t.Name()+"manual", createResp.Label.Name)
assert.ElementsMatch(t, []uint{manualHosts[0].ID, manualHosts[1].ID, manualHosts[2].ID}, createResp.Label.HostIDs)
manualLbl1 := createResp.Label.Label
// get the label
getResp = getLabelResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d", manualLbl1.ID), nil, http.StatusOK, &getResp)
assert.Equal(t, manualLbl1.ID, getResp.Label.ID)
assert.Equal(t, fleet.LabelTypeRegular, getResp.Label.LabelType)
assert.Equal(t, fleet.LabelMembershipTypeManual, getResp.Label.LabelMembershipType)
assert.ElementsMatch(t, []uint{manualHosts[0].ID, manualHosts[1].ID, manualHosts[2].ID}, getResp.Label.HostIDs)
assert.EqualValues(t, 3, getResp.Label.HostCount)
// get the label
getResp = getLabelResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d", manualLbl1.ID), nil, http.StatusOK, &getResp)
assert.Equal(t, manualLbl1.ID, getResp.Label.ID)
assert.Equal(t, fleet.LabelTypeRegular, getResp.Label.LabelType)
assert.Equal(t, fleet.LabelMembershipTypeManual, getResp.Label.LabelMembershipType)
assert.ElementsMatch(t, []uint{manualHosts[0].ID, manualHosts[1].ID, manualHosts[2].ID}, getResp.Label.HostIDs)
assert.EqualValues(t, 3, getResp.Label.HostCount)
// create a valid empty manual label
createResp = createLabelResponse{}
s.DoJSON("POST", "/api/latest/fleet/labels", &fleet.LabelPayload{Name: strings.ReplaceAll(t.Name(), "/", "_") + "manual2"}, http.StatusOK, &createResp)
assert.NotZero(t, createResp.Label.ID)
assert.Equal(t, strings.ReplaceAll(t.Name(), "/", "_")+"manual2", createResp.Label.Name)
assert.Empty(t, createResp.Label.HostIDs)
manualLbl2 := createResp.Label.Label
// create a valid empty manual label
createResp = createLabelResponse{}
s.DoJSON("POST", "/api/latest/fleet/labels", &fleet.LabelPayload{Name: strings.ReplaceAll(t.Name(), "/", "_") + "manual2"}, http.StatusOK, &createResp)
assert.NotZero(t, createResp.Label.ID)
assert.Equal(t, strings.ReplaceAll(t.Name(), "/", "_")+"manual2", createResp.Label.Name)
assert.Empty(t, createResp.Label.HostIDs)
manualLbl2 := createResp.Label.Label
// try to create a manual label with the same name
s.DoJSON("POST", "/api/latest/fleet/labels", &fleet.LabelPayload{Name: manualLbl2.Name, Hosts: []string{manualHosts[0].UUID}}, http.StatusConflict, &createResp)
// try to create a dynamic label with the same name
s.DoJSON("POST", "/api/latest/fleet/labels", &fleet.LabelPayload{Name: manualLbl2.Name, Query: "select 2"}, http.StatusConflict, &createResp)
// try to create a manual label with the same name
s.DoJSON("POST", "/api/latest/fleet/labels", &fleet.LabelPayload{Name: manualLbl2.Name, Hosts: []string{manualHosts[0].UUID}}, http.StatusConflict, &createResp)
// try to create a dynamic label with the same name
s.DoJSON("POST", "/api/latest/fleet/labels", &fleet.LabelPayload{Name: manualLbl2.Name, Query: "select 2"}, http.StatusConflict, &createResp)
// get the label
getResp = getLabelResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d", manualLbl2.ID), nil, http.StatusOK, &getResp)
assert.Equal(t, manualLbl2.ID, getResp.Label.ID)
assert.Equal(t, fleet.LabelTypeRegular, getResp.Label.LabelType)
assert.Equal(t, fleet.LabelMembershipTypeManual, getResp.Label.LabelMembershipType)
assert.Empty(t, getResp.Label.HostIDs)
assert.EqualValues(t, 0, getResp.Label.HostCount)
// get the label
getResp = getLabelResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d", manualLbl2.ID), nil, http.StatusOK, &getResp)
assert.Equal(t, manualLbl2.ID, getResp.Label.ID)
assert.Equal(t, fleet.LabelTypeRegular, getResp.Label.LabelType)
assert.Equal(t, fleet.LabelMembershipTypeManual, getResp.Label.LabelMembershipType)
assert.Empty(t, getResp.Label.HostIDs)
assert.EqualValues(t, 0, getResp.Label.HostCount)
// get a non-existing label
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d", 9999), nil, http.StatusNotFound, &getResp)
// get a non-existing label
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d", 9999), nil, http.StatusNotFound, &getResp)
// modify dynamic label lbl1
var modResp modifyLabelResponse
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/labels/%d", lbl1.ID), &fleet.ModifyLabelPayload{Name: ptr.String(t.Name() + "zzz")}, http.StatusOK, &modResp)
assert.Equal(t, lbl1.ID, modResp.Label.ID)
assert.Empty(t, modResp.Label.HostIDs)
assert.NotEqual(t, lbl1.Name, modResp.Label.Name)
// modify dynamic label lbl1
var modResp modifyLabelResponse
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/labels/%d", lbl1.ID), &fleet.ModifyLabelPayload{Name: ptr.String(t.Name() + "zzz")}, http.StatusOK, &modResp)
assert.Equal(t, lbl1.ID, modResp.Label.ID)
assert.Empty(t, modResp.Label.HostIDs)
assert.NotEqual(t, lbl1.Name, modResp.Label.Name)
// attempt to modify a label to a reserved name
for n := range builtinsMap {
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/labels/%d", lbl1.ID), &fleet.ModifyLabelPayload{Name: ptr.String(n)}, http.StatusUnprocessableEntity, &modResp)
}
// attempt to modify a label to a reserved name
for n := range builtinsMap {
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/labels/%d", lbl1.ID), &fleet.ModifyLabelPayload{Name: ptr.String(n)}, http.StatusUnprocessableEntity, &modResp)
}
// modify a non-existing label
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/labels/%d", 9999), &fleet.ModifyLabelPayload{Name: ptr.String("zzz")}, http.StatusNotFound, &modResp)
// modify a built-in label
res = s.Do("PATCH", fmt.Sprintf("/api/latest/fleet/labels/%d", builtinLbl.ID), &fleet.ModifyLabelPayload{Name: ptr.String("zzz")}, http.StatusUnprocessableEntity)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "cannot modify built-in label")
// modify a non-existing label
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/labels/%d", 9999), &fleet.ModifyLabelPayload{Name: ptr.String("zzz")}, http.StatusNotFound, &modResp)
// modify a built-in label
res = s.Do("PATCH", fmt.Sprintf("/api/latest/fleet/labels/%d", builtinLbl.ID), &fleet.ModifyLabelPayload{Name: ptr.String("zzz")}, http.StatusUnprocessableEntity)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "cannot modify built-in label")
// modify manual label 1 without modifying its hosts
modResp = modifyLabelResponse{}
newName := "modified_manual_label1"
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/labels/%d", manualLbl1.ID), &fleet.ModifyLabelPayload{Name: &newName}, http.StatusOK,
&modResp)
assert.Equal(t, manualLbl1.ID, modResp.Label.ID)
assert.Equal(t, fleet.LabelTypeRegular, modResp.Label.LabelType)
assert.Equal(t, fleet.LabelMembershipTypeManual, modResp.Label.LabelMembershipType)
assert.ElementsMatch(t, []uint{manualHosts[0].ID, manualHosts[1].ID, manualHosts[2].ID}, modResp.Label.HostIDs)
assert.EqualValues(t, 3, modResp.Label.HostCount)
assert.Equal(t, newName, modResp.Label.Name)
// modify manual label 1 without modifying its hosts
modResp = modifyLabelResponse{}
newName := "modified_manual_label1"
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/labels/%d", manualLbl1.ID), &fleet.ModifyLabelPayload{Name: &newName}, http.StatusOK,
&modResp)
assert.Equal(t, manualLbl1.ID, modResp.Label.ID)
assert.Equal(t, fleet.LabelTypeRegular, modResp.Label.LabelType)
assert.Equal(t, fleet.LabelMembershipTypeManual, modResp.Label.LabelMembershipType)
assert.ElementsMatch(t, []uint{manualHosts[0].ID, manualHosts[1].ID, manualHosts[2].ID}, modResp.Label.HostIDs)
assert.EqualValues(t, 3, modResp.Label.HostCount)
assert.Equal(t, newName, modResp.Label.Name)
// add a host with the same name as another host to manual label 2, confirm only one host is added
sameName, err := s.ds.NewHost(context.Background(), &fleet.Host{
HardwareSerial: "ABCDE",
Hostname: manualHosts[0].Hostname,
Platform: "darwin",
})
require.NoError(t, err)
modResp = modifyLabelResponse{}
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/labels/%d", manualLbl2.ID),
&fleet.ModifyLabelPayload{Hosts: []string{sameName.HardwareSerial}}, http.StatusOK, &modResp)
assert.Len(t, modResp.Label.HostIDs, 1)
assert.NotEqual(t, manualHosts[0].ID, modResp.Label.HostIDs[0])
assert.Equal(t, manualLbl2.ID, modResp.Label.ID)
assert.Equal(t, fleet.LabelTypeRegular, modResp.Label.LabelType)
assert.Equal(t, fleet.LabelMembershipTypeManual, modResp.Label.LabelMembershipType)
assert.ElementsMatch(t, []uint{sameName.ID}, modResp.Label.HostIDs)
assert.EqualValues(t, 1, modResp.Label.HostCount)
// modify manual label 2 adding some hosts
modResp = modifyLabelResponse{}
newName = "modified_manual_label2"
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/labels/%d", manualLbl2.ID),
&fleet.ModifyLabelPayload{Name: &newName, Hosts: []string{manualHosts[0].UUID}}, http.StatusOK, &modResp)
assert.Equal(t, manualLbl2.ID, modResp.Label.ID)
assert.Equal(t, fleet.LabelTypeRegular, modResp.Label.LabelType)
assert.Equal(t, fleet.LabelMembershipTypeManual, modResp.Label.LabelMembershipType)
assert.ElementsMatch(t, []uint{manualHosts[0].ID}, modResp.Label.HostIDs)
assert.EqualValues(t, 1, modResp.Label.HostCount)
assert.Equal(t, newName, modResp.Label.Name)
manualLbl2.Name = newName
// modify manual label 2 adding some hosts by ID
modResp = modifyLabelResponse{}
newName = "modified_manual_label2"
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/labels/%d", manualLbl2.ID),
&fleet.ModifyLabelPayload{Name: &newName, HostIDs: []uint{manualHosts[1].ID, manualHosts[2].ID}}, http.StatusOK, &modResp)
assert.Equal(t, manualLbl2.ID, modResp.Label.ID)
assert.Equal(t, fleet.LabelTypeRegular, modResp.Label.LabelType)
assert.Equal(t, fleet.LabelMembershipTypeManual, modResp.Label.LabelMembershipType)
assert.ElementsMatch(t, []uint{manualHosts[1].ID, manualHosts[2].ID}, modResp.Label.HostIDs)
assert.EqualValues(t, 2, modResp.Label.HostCount)
assert.Equal(t, newName, modResp.Label.Name)
manualLbl2.Name = newName
// modify manual label 2 clearing its hosts
modResp = modifyLabelResponse{}
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/labels/%d", manualLbl2.ID), &fleet.ModifyLabelPayload{Hosts: []string{}, Description: ptr.String("desc")}, http.StatusOK, &modResp)
assert.Equal(t, manualLbl2.ID, modResp.Label.ID)
assert.Equal(t, "desc", modResp.Label.Description)
assert.Empty(t, modResp.Label.HostIDs)
assert.EqualValues(t, 0, modResp.Label.HostCount)
// list labels
dynamicLabels := []fleet.Label{lbl1}
manualLabels := []fleet.Label{manualLbl1, manualLbl2}
s.DoJSON("GET", "/api/latest/fleet/labels", nil, http.StatusOK, &listResp, "per_page", strconv.Itoa(100))
assert.Len(t, listResp.Labels, builtInsCount+len(dynamicLabels)+len(manualLabels))
// labels summary
s.DoJSON("GET", "/api/latest/fleet/labels/summary", nil, http.StatusOK, &summaryResp)
assert.Len(t, summaryResp.Labels, builtInsCount+len(dynamicLabels)+len(manualLabels))
// next page is empty
s.DoJSON("GET", "/api/latest/fleet/labels", nil, http.StatusOK, &listResp, "per_page", "100", "page", "1")
assert.Len(t, listResp.Labels, 0)
// list labels with invalid query params
s.DoJSON("GET", "/api/latest/fleet/labels", nil, http.StatusBadRequest, &listResp, "per_page", strconv.Itoa(builtInsCount+1), "order_key", "id", "after", "1")
s.DoJSON("GET", "/api/latest/fleet/labels", nil, http.StatusBadRequest, &listResp, "per_page", strconv.Itoa(builtInsCount+1), "query", "no match query for this endpoint")
// create another dynamic label
s.DoJSON("POST", "/api/latest/fleet/labels", &fleet.LabelPayload{Name: strings.ReplaceAll(t.Name(), "/", "_"), Query: "select 1"}, http.StatusOK, &createResp)
assert.NotZero(t, createResp.Label.ID)
lbl2 := createResp.Label.Label
dynamicLabels = append(dynamicLabels, lbl2)
require.Len(t, dynamicLabels, 2) // to make linter happy (dynamicLabels is not used past this point)
// add lbl2 hosts to that label
for _, h := range lbl2Hosts {
err := s.ds.RecordLabelQueryExecutions(context.Background(), h, map[uint]*bool{lbl2.ID: ptr.Bool(true)}, time.Now(), false)
// add a host with the same name as another host to manual label 2, confirm only one host is added
sameName, err := s.ds.NewHost(context.Background(), &fleet.Host{
HardwareSerial: "ABCDE",
Hostname: manualHosts[0].Hostname,
Platform: "darwin",
})
require.NoError(t, err)
}
// list hosts in dynamic label lbl2
var listHostsResp listHostsResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d/hosts", lbl2.ID), nil, http.StatusOK, &listHostsResp)
assert.Len(t, listHostsResp.Hosts, len(lbl2Hosts))
modResp = modifyLabelResponse{}
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/labels/%d", manualLbl2.ID),
&fleet.ModifyLabelPayload{Hosts: []string{sameName.HardwareSerial}}, http.StatusOK, &modResp)
assert.Len(t, modResp.Label.HostIDs, 1)
assert.NotEqual(t, manualHosts[0].ID, modResp.Label.HostIDs[0])
assert.Equal(t, manualLbl2.ID, modResp.Label.ID)
assert.Equal(t, fleet.LabelTypeRegular, modResp.Label.LabelType)
assert.Equal(t, fleet.LabelMembershipTypeManual, modResp.Label.LabelMembershipType)
assert.ElementsMatch(t, []uint{sameName.ID}, modResp.Label.HostIDs)
assert.EqualValues(t, 1, modResp.Label.HostCount)
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d/hosts", lbl2.ID), nil, http.StatusOK, &listHostsResp, "order_key", "id", "after", fmt.Sprintf("%d", lbl2Hosts[0].ID))
assert.Len(t, listHostsResp.Hosts, 2)
assert.Equal(t, lbl2Hosts[1].ID, listHostsResp.Hosts[0].ID)
assert.Equal(t, lbl2Hosts[2].ID, listHostsResp.Hosts[1].ID)
// modify manual label 2 adding some hosts
modResp = modifyLabelResponse{}
newName = "modified_manual_label2"
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/labels/%d", manualLbl2.ID),
&fleet.ModifyLabelPayload{Name: &newName, Hosts: []string{manualHosts[0].UUID}}, http.StatusOK, &modResp)
assert.Equal(t, manualLbl2.ID, modResp.Label.ID)
assert.Equal(t, fleet.LabelTypeRegular, modResp.Label.LabelType)
assert.Equal(t, fleet.LabelMembershipTypeManual, modResp.Label.LabelMembershipType)
assert.ElementsMatch(t, []uint{manualHosts[0].ID}, modResp.Label.HostIDs)
assert.EqualValues(t, 1, modResp.Label.HostCount)
assert.Equal(t, newName, modResp.Label.Name)
manualLbl2.Name = newName
// list hosts in manual label 1
listHostsResp = listHostsResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d/hosts", manualLbl1.ID), nil, http.StatusOK, &listHostsResp, "order_key", "id")
assert.Len(t, listHostsResp.Hosts, manualLbl1.HostCount)
assert.Equal(t, manualHosts[0].ID, listHostsResp.Hosts[0].ID)
assert.Equal(t, manualHosts[1].ID, listHostsResp.Hosts[1].ID)
assert.Equal(t, manualHosts[2].ID, listHostsResp.Hosts[2].ID)
// modify manual label 2 adding some hosts by ID
modResp = modifyLabelResponse{}
newName = "modified_manual_label2"
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/labels/%d", manualLbl2.ID),
&fleet.ModifyLabelPayload{Name: &newName, HostIDs: []uint{manualHosts[1].ID, manualHosts[2].ID}}, http.StatusOK, &modResp)
assert.Equal(t, manualLbl2.ID, modResp.Label.ID)
assert.Equal(t, fleet.LabelTypeRegular, modResp.Label.LabelType)
assert.Equal(t, fleet.LabelMembershipTypeManual, modResp.Label.LabelMembershipType)
assert.ElementsMatch(t, []uint{manualHosts[1].ID, manualHosts[2].ID}, modResp.Label.HostIDs)
assert.EqualValues(t, 2, modResp.Label.HostCount)
assert.Equal(t, newName, modResp.Label.Name)
manualLbl2.Name = newName
// list hosts in manual label 2
listHostsResp = listHostsResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d/hosts", manualLbl2.ID), nil, http.StatusOK, &listHostsResp, "order_key", "id")
assert.Len(t, listHostsResp.Hosts, 0)
// modify manual label 2 clearing its hosts
modResp = modifyLabelResponse{}
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/labels/%d", manualLbl2.ID), &fleet.ModifyLabelPayload{Hosts: []string{}, Description: ptr.String("desc")}, http.StatusOK, &modResp)
assert.Equal(t, manualLbl2.ID, modResp.Label.ID)
assert.Equal(t, "desc", modResp.Label.Description)
assert.Empty(t, modResp.Label.HostIDs)
assert.EqualValues(t, 0, modResp.Label.HostCount)
// list hosts in dynamic label 2 searching by display_name
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d/hosts", lbl2.ID), nil, http.StatusOK, &listHostsResp, "order_key", "display_name", "order_direction", "desc")
assert.Len(t, listHostsResp.Hosts, len(lbl2Hosts))
// first in the list is the last one, as the names are ordered with the index
// of creation, and vice-versa
assert.Equal(t, lbl2Hosts[len(lbl2Hosts)-1].ID, listHostsResp.Hosts[0].ID)
assert.Equal(t, lbl2Hosts[0].ID, listHostsResp.Hosts[len(lbl2Hosts)-1].ID)
// list labels
dynamicLabels := []fleet.Label{lbl1}
manualLabels := []fleet.Label{manualLbl1, manualLbl2}
s.DoJSON("GET", "/api/latest/fleet/labels", nil, http.StatusOK, &listResp, "per_page", strconv.Itoa(100))
assert.Len(t, listResp.Labels, builtInsCount+len(dynamicLabels)+len(manualLabels))
mysql.ExecAdhocSQL(t, s.ds, func(db sqlx.ExtContext) error {
_, err := db.ExecContext(
context.Background(),
`INSERT INTO host_emails (host_id, email, source) VALUES (?, ?, ?)`,
lbl2Hosts[0].ID, "a@b.c", "src1")
// labels summary
s.DoJSON("GET", "/api/latest/fleet/labels/summary", nil, http.StatusOK, &summaryResp)
assert.Len(t, summaryResp.Labels, builtInsCount+len(dynamicLabels)+len(manualLabels))
return err
// next page is empty
s.DoJSON("GET", "/api/latest/fleet/labels", nil, http.StatusOK, &listResp, "per_page", "100", "page", "1")
assert.Len(t, listResp.Labels, 0)
// list labels with invalid query params
s.DoJSON("GET", "/api/latest/fleet/labels", nil, http.StatusBadRequest, &listResp, "per_page", strconv.Itoa(builtInsCount+1), "order_key", "id", "after", "1")
s.DoJSON("GET", "/api/latest/fleet/labels", nil, http.StatusBadRequest, &listResp, "per_page", strconv.Itoa(builtInsCount+1), "query", "no match query for this endpoint")
// create another dynamic label
s.DoJSON("POST", "/api/latest/fleet/labels", &fleet.LabelPayload{Name: strings.ReplaceAll(t.Name(), "/", "_"), Query: "select 1"}, http.StatusOK, &createResp)
assert.NotZero(t, createResp.Label.ID)
lbl2 := createResp.Label.Label
dynamicLabels = append(dynamicLabels, lbl2)
require.Len(t, dynamicLabels, 2) // to make linter happy (dynamicLabels is not used past this point)
// add lbl2 hosts to that label
for _, h := range lbl2Hosts {
err := s.ds.RecordLabelQueryExecutions(context.Background(), h, map[uint]*bool{lbl2.ID: ptr.Bool(true)}, time.Now(), false)
require.NoError(t, err)
}
// list hosts in dynamic label lbl2
var listHostsResp listHostsResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d/hosts", lbl2.ID), nil, http.StatusOK, &listHostsResp)
assert.Len(t, listHostsResp.Hosts, len(lbl2Hosts))
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d/hosts", lbl2.ID), nil, http.StatusOK, &listHostsResp, "order_key", "id", "after", fmt.Sprintf("%d", lbl2Hosts[0].ID))
assert.Len(t, listHostsResp.Hosts, 2)
assert.Equal(t, lbl2Hosts[1].ID, listHostsResp.Hosts[0].ID)
assert.Equal(t, lbl2Hosts[2].ID, listHostsResp.Hosts[1].ID)
// list hosts in manual label 1
listHostsResp = listHostsResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d/hosts", manualLbl1.ID), nil, http.StatusOK, &listHostsResp, "order_key", "id")
assert.Len(t, listHostsResp.Hosts, manualLbl1.HostCount)
assert.Equal(t, manualHosts[0].ID, listHostsResp.Hosts[0].ID)
assert.Equal(t, manualHosts[1].ID, listHostsResp.Hosts[1].ID)
assert.Equal(t, manualHosts[2].ID, listHostsResp.Hosts[2].ID)
// list hosts in manual label 2
listHostsResp = listHostsResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d/hosts", manualLbl2.ID), nil, http.StatusOK, &listHostsResp, "order_key", "id")
assert.Len(t, listHostsResp.Hosts, 0)
// list hosts in dynamic label 2 searching by display_name
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d/hosts", lbl2.ID), nil, http.StatusOK, &listHostsResp, "order_key", "display_name", "order_direction", "desc")
assert.Len(t, listHostsResp.Hosts, len(lbl2Hosts))
// first in the list is the last one, as the names are ordered with the index
// of creation, and vice-versa
assert.Equal(t, lbl2Hosts[len(lbl2Hosts)-1].ID, listHostsResp.Hosts[0].ID)
assert.Equal(t, lbl2Hosts[0].ID, listHostsResp.Hosts[len(lbl2Hosts)-1].ID)
mysql.ExecAdhocSQL(t, s.ds, func(db sqlx.ExtContext) error {
_, err := db.ExecContext(
context.Background(),
`INSERT INTO host_emails (host_id, email, source) VALUES (?, ?, ?)`,
lbl2Hosts[0].ID, "a@b.c", "src1")
return err
})
// list hosts in label searching by email address
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d/hosts", lbl2.ID), nil, http.StatusOK, &listHostsResp, "query", "a@b.c")
assert.Len(t, listHostsResp.Hosts, 1)
assert.Equal(t, lbl2Hosts[0].ID, listHostsResp.Hosts[0].ID)
// list hosts in label searching by email address with leading/trailing whitespace
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d/hosts", lbl2.ID), nil, http.StatusOK, &listHostsResp, "query", " a@b.c ")
assert.Len(t, listHostsResp.Hosts, 1)
assert.Equal(t, lbl2Hosts[0].ID, listHostsResp.Hosts[0].ID)
// count hosts in label order by display_name
var countResp countHostsResponse
s.DoJSON("GET", "/api/latest/fleet/hosts/count", nil, http.StatusOK, &countResp, "label_id", fmt.Sprint(lbl2.ID), "order_key", "display_name", "order_direction", "desc")
assert.Equal(t, len(lbl2Hosts), countResp.Count)
// lists hosts in label without hosts
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d/hosts", lbl1.ID), nil, http.StatusOK, &listHostsResp)
assert.Len(t, listHostsResp.Hosts, 0)
// count hosts in label
s.DoJSON("GET", "/api/latest/fleet/hosts/count", nil, http.StatusOK, &countResp, "label_id", fmt.Sprint(lbl1.ID))
assert.Equal(t, 0, countResp.Count)
// lists hosts in invalid label
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d/hosts", lbl2.ID+1), nil, http.StatusOK, &listHostsResp)
assert.Len(t, listHostsResp.Hosts, 0)
// set MDM information on a host
require.NoError(t, s.ds.SetOrUpdateMDMData(context.Background(), lbl2Hosts[0].ID, false, true, "https://simplemdm.com", false, fleet.WellKnownMDMSimpleMDM, ""))
var mdmID uint
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(context.Background(), q, &mdmID,
`SELECT id FROM mobile_device_management_solutions WHERE name = ? AND server_url = ?`, fleet.WellKnownMDMSimpleMDM, "https://simplemdm.com")
})
// generate aggregated stats
require.NoError(t, s.ds.GenerateAggregatedMunkiAndMDM(context.Background()))
// list host in label by mdm_id
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d/hosts", lbl2.ID), nil, http.StatusOK, &listHostsResp, "mdm_id", fmt.Sprint(mdmID))
require.Len(t, listHostsResp.Hosts, 1)
assert.Nil(t, listHostsResp.Software)
assert.Nil(t, listHostsResp.MunkiIssue)
require.NotNil(t, listHostsResp.MDMSolution)
assert.Equal(t, mdmID, listHostsResp.MDMSolution.ID)
assert.Equal(t, fleet.WellKnownMDMSimpleMDM, listHostsResp.MDMSolution.Name)
assert.Equal(t, "https://simplemdm.com", listHostsResp.MDMSolution.ServerURL)
// delete a label by id
var delIDResp deleteLabelByIDResponse
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/labels/id/%d", lbl1.ID), nil, http.StatusOK, &delIDResp)
// delete a non-existing label by id
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/labels/id/%d", lbl2.ID+1), nil, http.StatusNotFound, &delIDResp)
// delete a label by name
var delResp deleteLabelResponse
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/labels/%s", url.PathEscape(lbl2.Name)), nil, http.StatusOK, &delResp)
// delete a non-existing label by name
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/labels/%s", url.PathEscape(lbl2.Name)), nil, http.StatusNotFound, &delResp)
// delete a manual label by id
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/labels/id/%d", manualLbl1.ID), nil, http.StatusOK, &delIDResp)
// delete a manual label by name
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/labels/%s", url.PathEscape(manualLbl2.Name)), nil, http.StatusOK, &delResp)
// list labels, only the built-ins remain
s.DoJSON("GET", "/api/latest/fleet/labels", nil, http.StatusOK, &listResp, "per_page", strconv.Itoa(builtInsCount+1))
assert.Len(t, listResp.Labels, builtInsCount)
idsByName := make(map[string]uint, len(listResp.Labels))
for _, lbl := range listResp.Labels {
_, ok := builtinsMap[lbl.Name]
assert.True(t, ok)
assert.Equal(t, fleet.LabelTypeBuiltIn, lbl.LabelType)
idsByName[lbl.Name] = lbl.ID
}
// labels summary, only the built-ins remains
s.DoJSON("GET", "/api/latest/fleet/labels/summary", nil, http.StatusOK, &summaryResp)
assert.Len(t, summaryResp.Labels, builtInsCount)
for _, lbl := range summaryResp.Labels {
_, ok := builtinsMap[lbl.Name]
assert.True(t, ok)
assert.Equal(t, fleet.LabelTypeBuiltIn, lbl.LabelType)
assert.Equal(t, idsByName[lbl.Name], lbl.ID)
}
// host summary matches built-ins count
var hostSummaryResp getHostSummaryResponse
s.DoJSON("GET", "/api/latest/fleet/host_summary", nil, http.StatusOK, &hostSummaryResp)
assert.Len(t, hostSummaryResp.BuiltinLabels, builtInsCount)
for _, lbl := range hostSummaryResp.BuiltinLabels {
_, ok := builtinsMap[lbl.Name]
assert.True(t, ok)
assert.Equal(t, fleet.LabelTypeBuiltIn, lbl.LabelType)
assert.Equal(t, idsByName[lbl.Name], lbl.ID)
}
require.Len(t, idsByName, len(builtinsMap))
for name := range builtinsMap {
id, ok := idsByName[name]
require.True(t, ok)
// attempt to delete by name
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/labels/%s", url.PathEscape(name)), nil, http.StatusUnprocessableEntity, &delResp)
// attempt to delete by id
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/labels/id/%d", id), nil, http.StatusUnprocessableEntity, &delIDResp)
}
})
// list hosts in label searching by email address
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d/hosts", lbl2.ID), nil, http.StatusOK, &listHostsResp, "query", "a@b.c")
assert.Len(t, listHostsResp.Hosts, 1)
assert.Equal(t, lbl2Hosts[0].ID, listHostsResp.Hosts[0].ID)
t.Run("IdP Labels", func(t *testing.T) {
// Add some SCIM users
mysql.ExecAdhocSQL(
t, s.ds, func(db sqlx.ExtContext) error {
_, err := db.ExecContext(
context.Background(),
"INSERT INTO scim_users (id, user_name) VALUES (?, ?), (?, ?), (?, ?), (?, ?)",
1,
"no_groups",
2,
"one_group",
3,
"all_the_groups",
4,
"wrong_groups",
)
return err
},
)
// Add some SCIM groups
mysql.ExecAdhocSQL(
t, s.ds, func(db sqlx.ExtContext) error {
_, err := db.ExecContext(
context.Background(),
"INSERT INTO scim_groups (id, display_name) VALUES (?, ?), (?, ?), (?, ?)",
1,
"group_good",
2,
"group_bad",
3,
"group_great",
)
return err
},
)
// Add some SCIM group memberships
mysql.ExecAdhocSQL(
t, s.ds, func(db sqlx.ExtContext) error {
_, err := db.ExecContext(
context.Background(),
"INSERT INTO scim_user_group (scim_user_id, group_id) VALUES (?, ?), (?, ?), (?, ?), (?, ?), (?, ?)",
2, 1, // "one_group" -> "group_good"
3, 1, // "all_the_groups" -> "group_good"
3, 2, // "all_the_groups" -> "group_bad"
3, 3, // "all_the_groups" -> "group_great"
4, 2, // "wrong_groups" -> "group_bad"
)
return err
},
)
// Add some host->scim user mappings
mysql.ExecAdhocSQL(
t, s.ds, func(db sqlx.ExtContext) error {
_, err := db.ExecContext(
context.Background(),
"INSERT INTO host_scim_user (host_id, scim_user_id) VALUES (?, ?), (?, ?), (?, ?), (?, ?), (?, ?)",
hosts[0].ID, 1, // host 1 shouldn't be returned because its scim user has no groups
hosts[1].ID, 2, // host 2 should be returned because its scim user has the "group_good" group
hosts[2].ID, 3, // host 3 should be returned because it has a scim user with the "group_good" group
hosts[3].ID, 2, // host 4 should be returned because it has a scim user with the "group_good" group
hosts[4].ID, 4, // host 5 shouldn't be returned because its scim user only has the "group_bad" group
// host 6 shouldn't be returned because it has no scim user
)
return err
},
)
// list hosts in label searching by email address with leading/trailing whitespace
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d/hosts", lbl2.ID), nil, http.StatusOK, &listHostsResp, "query", " a@b.c ")
assert.Len(t, listHostsResp.Hosts, 1)
assert.Equal(t, lbl2Hosts[0].ID, listHostsResp.Hosts[0].ID)
t.Run("IdP Group Label", func(t *testing.T) {
// Create a label for an IdP group
criteria := &fleet.HostVitalCriteria{
Vital: ptr.String("end_user_idp_group"),
Value: ptr.String("group_good"),
}
// count hosts in label order by display_name
var countResp countHostsResponse
s.DoJSON("GET", "/api/latest/fleet/hosts/count", nil, http.StatusOK, &countResp, "label_id", fmt.Sprint(lbl2.ID), "order_key", "display_name", "order_direction", "desc")
assert.Equal(t, len(lbl2Hosts), countResp.Count)
labelParams := createLabelRequest{
fleet.LabelPayload{
Name: "Test IdP Group Label",
Criteria: criteria,
},
}
// lists hosts in label without hosts
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d/hosts", lbl1.ID), nil, http.StatusOK, &listHostsResp)
assert.Len(t, listHostsResp.Hosts, 0)
labelResp := createLabelResponse{}
s.DoJSON("POST", "/api/latest/fleet/labels", labelParams, http.StatusOK, &labelResp)
require.NotNil(t, labelResp.Label)
// count hosts in label
s.DoJSON("GET", "/api/latest/fleet/hosts/count", nil, http.StatusOK, &countResp, "label_id", fmt.Sprint(lbl1.ID))
assert.Equal(t, 0, countResp.Count)
filter := fleet.TeamFilter{User: test.UserAdmin}
label, _, err := s.ds.Label(context.Background(), labelResp.Label.Label.ID, filter)
require.NoError(t, err)
// lists hosts in invalid label
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d/hosts", lbl2.ID+1), nil, http.StatusOK, &listHostsResp)
assert.Len(t, listHostsResp.Hosts, 0)
// Verify that the query and values are correct.
// Test parsing the criteria
query, queryValues, err := label.CalculateHostVitalsQuery()
require.NoError(t, err)
queryValuesJson, err := json.Marshal(queryValues)
require.NoError(t, err)
// set MDM information on a host
require.NoError(t, s.ds.SetOrUpdateMDMData(context.Background(), lbl2Hosts[0].ID, false, true, "https://simplemdm.com", false, fleet.WellKnownMDMSimpleMDM, ""))
var mdmID uint
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(context.Background(), q, &mdmID,
`SELECT id FROM mobile_device_management_solutions WHERE name = ? AND server_url = ?`, fleet.WellKnownMDMSimpleMDM, "https://simplemdm.com")
assert.Equal(t, "SELECT %s FROM %s RIGHT JOIN host_scim_user ON (hosts.id = host_scim_user.host_id) JOIN scim_users ON (host_scim_user.scim_user_id = scim_users.id) JOIN scim_user_group ON (host_scim_user.scim_user_id = scim_user_group.scim_user_id) JOIN scim_groups ON (scim_user_group.group_id = scim_groups.id) WHERE scim_groups.display_name = ? GROUP BY hosts.id", query)
assert.Equal(t, `["group_good"]`, string(queryValuesJson))
// Update label membership.
_, err = s.ds.UpdateLabelMembershipByHostCriteria(context.Background(), label)
require.NoError(t, err)
// Verify that the label has the correct hosts.
// Check that the label has the correct hosts
hostsInLabel, err := s.ds.ListHostsInLabel(context.Background(), filter, label.ID, fleet.HostListOptions{})
require.NoError(t, err)
require.Len(t, hostsInLabel, 3)
require.ElementsMatch(t, []uint{hosts[1].ID, hosts[2].ID, hosts[3].ID}, []uint{hostsInLabel[0].ID, hostsInLabel[1].ID, hostsInLabel[2].ID})
// Check that the label has the correct host count
label, _, err = s.ds.Label(context.Background(), labelResp.Label.Label.ID, filter)
require.NoError(t, err)
assert.Equal(t, 3, label.HostCount)
})
})
// generate aggregated stats
require.NoError(t, s.ds.GenerateAggregatedMunkiAndMDM(context.Background()))
// list host in label by mdm_id
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d/hosts", lbl2.ID), nil, http.StatusOK, &listHostsResp, "mdm_id", fmt.Sprint(mdmID))
require.Len(t, listHostsResp.Hosts, 1)
assert.Nil(t, listHostsResp.Software)
assert.Nil(t, listHostsResp.MunkiIssue)
require.NotNil(t, listHostsResp.MDMSolution)
assert.Equal(t, mdmID, listHostsResp.MDMSolution.ID)
assert.Equal(t, fleet.WellKnownMDMSimpleMDM, listHostsResp.MDMSolution.Name)
assert.Equal(t, "https://simplemdm.com", listHostsResp.MDMSolution.ServerURL)
// delete a label by id
var delIDResp deleteLabelByIDResponse
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/labels/id/%d", lbl1.ID), nil, http.StatusOK, &delIDResp)
// delete a non-existing label by id
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/labels/id/%d", lbl2.ID+1), nil, http.StatusNotFound, &delIDResp)
// delete a label by name
var delResp deleteLabelResponse
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/labels/%s", url.PathEscape(lbl2.Name)), nil, http.StatusOK, &delResp)
// delete a non-existing label by name
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/labels/%s", url.PathEscape(lbl2.Name)), nil, http.StatusNotFound, &delResp)
// delete a manual label by id
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/labels/id/%d", manualLbl1.ID), nil, http.StatusOK, &delIDResp)
// delete a manual label by name
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/labels/%s", url.PathEscape(manualLbl2.Name)), nil, http.StatusOK, &delResp)
// list labels, only the built-ins remain
s.DoJSON("GET", "/api/latest/fleet/labels", nil, http.StatusOK, &listResp, "per_page", strconv.Itoa(builtInsCount+1))
assert.Len(t, listResp.Labels, builtInsCount)
idsByName := make(map[string]uint, len(listResp.Labels))
for _, lbl := range listResp.Labels {
_, ok := builtinsMap[lbl.Name]
assert.True(t, ok)
assert.Equal(t, fleet.LabelTypeBuiltIn, lbl.LabelType)
idsByName[lbl.Name] = lbl.ID
}
// labels summary, only the built-ins remains
s.DoJSON("GET", "/api/latest/fleet/labels/summary", nil, http.StatusOK, &summaryResp)
assert.Len(t, summaryResp.Labels, builtInsCount)
for _, lbl := range summaryResp.Labels {
_, ok := builtinsMap[lbl.Name]
assert.True(t, ok)
assert.Equal(t, fleet.LabelTypeBuiltIn, lbl.LabelType)
assert.Equal(t, idsByName[lbl.Name], lbl.ID)
}
// host summary matches built-ins count
var hostSummaryResp getHostSummaryResponse
s.DoJSON("GET", "/api/latest/fleet/host_summary", nil, http.StatusOK, &hostSummaryResp)
assert.Len(t, hostSummaryResp.BuiltinLabels, builtInsCount)
for _, lbl := range hostSummaryResp.BuiltinLabels {
_, ok := builtinsMap[lbl.Name]
assert.True(t, ok)
assert.Equal(t, fleet.LabelTypeBuiltIn, lbl.LabelType)
assert.Equal(t, idsByName[lbl.Name], lbl.ID)
}
require.Len(t, idsByName, len(builtinsMap))
for name := range builtinsMap {
id, ok := idsByName[name]
require.True(t, ok)
// attempt to delete by name
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/labels/%s", url.PathEscape(name)), nil, http.StatusUnprocessableEntity, &delResp)
// attempt to delete by id
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/labels/id/%d", id), nil, http.StatusUnprocessableEntity, &delIDResp)
}
}
// Sanity test to make sure fleet/labels/<all>/hosts and fleet/hosts return the same thing.
+30 -6
View File
@@ -2,6 +2,7 @@ package service
import (
"context"
"encoding/json"
"fmt"
"net/http"
@@ -71,12 +72,29 @@ func (svc *Service) NewLabel(ctx context.Context, p fleet.LabelPayload) (*fleet.
}
label.Name = p.Name
if p.Query != "" && (len(p.Hosts) > 0 || len(p.HostIDs) > 0) {
return nil, nil, fleet.NewInvalidArgumentError("query", `Only one of either "query" or "hosts/host_ids" can be included in the request.`)
}
label.Query = p.Query
if p.Query == "" {
label.LabelMembershipType = fleet.LabelMembershipTypeManual
if p.Criteria != nil {
if p.Query != "" || (len(p.Hosts) > 0 || len(p.HostIDs) > 0) {
return nil, nil, fleet.NewInvalidArgumentError("criteria", `Only one of "criteria", "query" or "hosts/host_ids" can be included in the request.`)
}
label.LabelMembershipType = fleet.LabelMembershipTypeHostVitals
labelCriteriaJson, err := json.Marshal(p.Criteria)
if err != nil {
return nil, nil, fleet.NewInvalidArgumentError("criteria", fmt.Sprintf("invalid criteria: %s", err.Error()))
}
label.HostVitalsCriteria = ptr.RawMessage(json.RawMessage(labelCriteriaJson))
// Attempt to calculate a query from the criteria.
_, _, err = label.CalculateHostVitalsQuery()
if err != nil {
return nil, nil, fleet.NewInvalidArgumentError("criteria", fmt.Sprintf("invalid criteria: %s", err.Error()))
}
} else {
if p.Query != "" && (len(p.Hosts) > 0 || len(p.HostIDs) > 0) {
return nil, nil, fleet.NewInvalidArgumentError("query", `Only one of "criteria", "query" or "hosts/host_ids" can be included in the request.`)
}
label.Query = p.Query
if p.Query == "" {
label.LabelMembershipType = fleet.LabelMembershipTypeManual
}
}
label.Platform = p.Platform
@@ -532,6 +550,12 @@ func (svc *Service) ApplyLabelSpecs(ctx context.Context, specs []*fleet.LabelSpe
ctxerr.Errorf(ctx, "label %s is declared as manual but contains no `hosts key`", spec.Name), http.StatusUnprocessableEntity,
)
}
if spec.LabelMembershipType == fleet.LabelMembershipTypeHostVitals && spec.HostVitalsCriteria == nil {
// Criteria is required for host vitals labels.
return fleet.NewUserMessageError(
ctxerr.Errorf(ctx, "label %s is declared as host vitals but contains no `criteria` key", spec.Name), http.StatusUnprocessableEntity,
)
}
if spec.LabelType == fleet.LabelTypeBuiltIn {
// We allow specs to contain built-in labels as long as they are not being modified.
// This allows the user to do the following workflow without manually removing built-in labels:
+32
View File
@@ -2,6 +2,7 @@ package service
import (
"context"
"encoding/json"
"testing"
"time"
@@ -515,3 +516,34 @@ func TestModifyManualLabel(t *testing.T) {
require.NoError(t, err)
})
}
func TestNewHostVitalsLabel(t *testing.T) {
ds := new(mock.Store)
svc, ctx := newTestService(t, ds, nil, nil)
ctx = viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}})
ds.NewLabelFunc = func(ctx context.Context, lbl *fleet.Label, opts ...fleet.OptionalArg) (*fleet.Label, error) {
return lbl, nil
}
t.Run("create host vitals label", func(t *testing.T) {
lbl, _, err := svc.NewLabel(ctx, fleet.LabelPayload{
Name: "foo",
Criteria: &fleet.HostVitalCriteria{
Vital: ptr.String("end_user_idp_group"),
Value: ptr.String("admin"),
},
})
require.NoError(t, err)
assert.Equal(t, fleet.LabelTypeRegular, lbl.LabelType)
assert.Equal(t, fleet.LabelMembershipTypeHostVitals, lbl.LabelMembershipType)
// Test parsing the criteria
query, queryValues, err := lbl.CalculateHostVitalsQuery()
require.NoError(t, err)
queryValuesJson, err := json.Marshal(queryValues)
require.NoError(t, err)
assert.Equal(t, "SELECT %s FROM %s RIGHT JOIN host_scim_user ON (hosts.id = host_scim_user.host_id) JOIN scim_users ON (host_scim_user.scim_user_id = scim_users.id) JOIN scim_user_group ON (host_scim_user.scim_user_id = scim_user_group.scim_user_id) JOIN scim_groups ON (scim_user_group.group_id = scim_groups.id) WHERE scim_groups.display_name = ? GROUP BY hosts.id", query)
assert.Equal(t, `["admin"]`, string(queryValuesJson))
})
}