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:
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user