Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changes/36976-activities-for-labels
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
* Added generation of activities when users create, edit, or delete labels (`created_label`, `edited_label`, and `deleted_label` respectively).
3 changes: 3 additions & 0 deletions cmd/fleetctl/fleetctl/apply_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2021,6 +2021,9 @@ func TestApplyLabels(t *testing.T) {
fleet.BuiltinLabelNameUbuntuLinux: ubuntuLabel,
}, nil
}
// Reset invocation flag — earlier sub-cases call LabelsByName as part of
// the regular-label apply flow (used for created/edited activity detection).
ds.LabelsByNameFuncInvoked = false

name = writeTmpYml(t, builtinLabelSpec)
_, err := RunAppNoChecks([]string{"apply", "-f", name})
Expand Down
15 changes: 15 additions & 0 deletions cmd/fleetctl/fleetctl/testing_utils/testing_utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,21 @@ func RunServerWithMockedDS(t *testing.T, opts ...*service.TestServerOpts) (*http
ds.TeamMDMConfigFunc = func(ctx context.Context, teamID uint) (*fleet.TeamMDM, error) {
return &fleet.TeamMDM{}, nil
}
// Default mocks used by ApplyLabelSpecs to detect created vs edited labels
// for activity emission. Tests can override these.
ds.LabelsByNameFunc = func(ctx context.Context, names []string, filter fleet.TeamFilter) (map[string]*fleet.Label, error) {
return map[string]*fleet.Label{}, nil
}
ds.LabelFunc = func(ctx context.Context, lid uint, filter fleet.TeamFilter) (*fleet.LabelWithTeamName, []uint, error) {
return &fleet.LabelWithTeamName{Label: fleet.Label{ID: lid}}, nil, nil
}
ds.LabelMembershipHostIDsFunc = func(ctx context.Context, labelID uint) ([]uint, error) {
return nil, nil
}
ds.TeamLiteFunc = func(ctx context.Context, tid uint) (*fleet.TeamLite, error) {
return &fleet.TeamLite{ID: tid}, nil
}

var cachedDS fleet.Datastore
if len(opts) > 0 && opts[0].NoCacheDatastore {
cachedDS = ds
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import (
"testing"
"text/template"

activity_api "github.com/fleetdm/fleet/v4/server/activity/api"

"github.com/fleetdm/fleet/v4/cmd/fleetctl/fleetctl"
"github.com/fleetdm/fleet/v4/cmd/fleetctl/fleetctl/testing_utils"
"github.com/fleetdm/fleet/v4/cmd/fleetctl/integrationtest"
Expand All @@ -32,6 +34,7 @@ import (
"github.com/fleetdm/fleet/v4/server/fleet"
appleMdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/tokenpki"
mock_pkg "github.com/fleetdm/fleet/v4/server/mock"
"github.com/fleetdm/fleet/v4/server/platform/logging"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/fleetdm/fleet/v4/server/service"
Expand All @@ -58,6 +61,7 @@ type enterpriseIntegrationGitopsTestSuite struct {
integrationtest.WithServer
fleetCfg config.FleetConfig
softwareTitleIconStore fleet.SoftwareTitleIconStore
activityMock *mock_pkg.MockActivityService
}

func (s *enterpriseIntegrationGitopsTestSuite) SetupSuite() {
Expand Down Expand Up @@ -124,6 +128,7 @@ func (s *enterpriseIntegrationGitopsTestSuite) SetupSuite() {
serverConfig.Logger = slog.New(slog.DiscardHandler)
}
users, server := service.RunServerForTestsWithDS(s.T(), s.DS, &serverConfig)
s.activityMock = serverConfig.ActivityMock
s.T().Setenv("FLEET_SERVER_ADDRESS", server.URL) // fleetctl always uses this env var in tests
s.Server = server
s.Users = users
Expand Down Expand Up @@ -3279,6 +3284,260 @@ func labelTeamIDResult(t *testing.T, s *enterpriseIntegrationGitopsTestSuite, ct
return got
}

// captureLabelActivities replaces the suite's activity mock with a recorder.
// It returns a function that returns and resets the captured label activities.
// Cleanup restores the previous NewActivityFunc.
func (s *enterpriseIntegrationGitopsTestSuite) captureLabelActivities(t *testing.T) func() []activity_api.ActivityDetails {
t.Helper()
require.NotNil(t, s.activityMock, "activity mock should be wired up via TestServerOpts.ActivityMock")
prev := s.activityMock.NewActivityFunc
var (
mu sync.Mutex
captured []activity_api.ActivityDetails
)
s.activityMock.NewActivityFunc = func(ctx context.Context, user *activity_api.User, a activity_api.ActivityDetails) error {
switch a.(type) {
case fleet.ActivityTypeCreatedLabel, fleet.ActivityTypeEditedLabel, fleet.ActivityTypeDeletedLabel:
mu.Lock()
captured = append(captured, a)
mu.Unlock()
}
if prev != nil {
return prev(ctx, user, a)
}
return nil
}
t.Cleanup(func() { s.activityMock.NewActivityFunc = prev })

return func() []activity_api.ActivityDetails {
mu.Lock()
defer mu.Unlock()
out := captured
captured = nil
return out
}
}

func (s *enterpriseIntegrationGitopsTestSuite) TestGitOpsLabelActivities() {
t := s.T()
ctx := context.Background()

user := s.createGitOpsUser(t)
fleetCfg := s.createFleetctlConfig(t, user)

teamName := uuid.NewString()
_, err := s.DS.NewTeam(ctx, &fleet.Team{Name: teamName})
require.NoError(t, err)

// Hosts to assign to the manual label in later phases.
for _, h := range []string{"label-act-host-1", "label-act-host-2"} {
_, err := s.DS.NewHost(ctx, &fleet.Host{
UUID: h,
Hostname: h,
Platform: "linux",
HardwareSerial: h,
})
require.NoError(t, err)
}

globalFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
teamFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)

writeGlobal := func(body string) {
require.NoError(t, os.WriteFile(globalFile.Name(), []byte(`
agent_options:
controls:
org_settings:
secrets:
- secret: test_secret
policies:
reports:
labels:
`+body), 0o644))
}
writeTeam := func(body string) {
require.NoError(t, os.WriteFile(teamFile.Name(), fmt.Appendf(nil, `
controls:
software:
reports:
policies:
agent_options:
name: %s
settings:
secrets: [{"secret":"enroll_secret"}]
labels:
%s`, teamName, body), 0o644))
}
apply := func() {
s.assertRealRunOutput(t, fleetctl.RunAppForTest(t, []string{
"gitops", "--config", fleetCfg.Name(),
"-f", globalFile.Name(), "-f", teamFile.Name(),
}))
}

flush := s.captureLabelActivities(t)

// Phase 1: initial apply creates one global and one team label.
writeGlobal(` - name: lbl-global
description: original-global
label_membership_type: dynamic
query: SELECT 1
`)
writeTeam(` - name: lbl-team
description: original-team
label_membership_type: dynamic
query: SELECT 2
`)
apply()
got := flush()
require.Len(t, got, 2, "expected created_label for global + team")

byName := map[string]fleet.ActivityTypeCreatedLabel{}
for _, a := range got {
c, ok := a.(fleet.ActivityTypeCreatedLabel)
require.True(t, ok, "expected created_label, got %T", a)
byName[c.Name] = c
}
require.Contains(t, byName, "lbl-global")
require.Contains(t, byName, "lbl-team")
require.Nil(t, byName["lbl-global"].FleetID, "global label should have nil fleet_id")
require.NotNil(t, byName["lbl-team"].FleetID, "team label should have a fleet_id")
require.NotNil(t, byName["lbl-team"].FleetName)
require.Equal(t, teamName, *byName["lbl-team"].FleetName)

// Phase 2: re-apply identical specs — no activity should fire.
apply()
require.Empty(t, flush(), "no-op apply should produce no activity")

// Phase 3: edit the global label's description and the team label's query.
writeGlobal(` - name: lbl-global
description: edited-global
label_membership_type: dynamic
query: SELECT 1
`)
writeTeam(` - name: lbl-team
description: original-team
label_membership_type: dynamic
query: SELECT 99
`)
apply()
got = flush()
require.Len(t, got, 2, "expected edited_label for both edits")
editedNames := map[string]struct{}{}
for _, a := range got {
e, ok := a.(fleet.ActivityTypeEditedLabel)
require.True(t, ok, "expected edited_label, got %T", a)
editedNames[e.Name] = struct{}{}
}
require.Contains(t, editedNames, "lbl-global")
require.Contains(t, editedNames, "lbl-team")

// Phase 4: change lbl-global from dynamic to manual (membership_type swap)
// and assign one host. Should emit a single edited_label for lbl-global.
writeGlobal(` - name: lbl-global
description: edited-global
label_membership_type: manual
hosts:
- label-act-host-1
`)
writeTeam(` - name: lbl-team
description: original-team
label_membership_type: dynamic
query: SELECT 99
`)
apply()
got = flush()
require.Len(t, got, 1, "expected edited_label for membership_type change")
e, ok := got[0].(fleet.ActivityTypeEditedLabel)
require.True(t, ok, "expected edited_label, got %T", got[0])
require.Equal(t, "lbl-global", e.Name)

// Phase 5: extend the manual label's host list. Should emit a single
// edited_label even though all other fields stayed the same.
writeGlobal(` - name: lbl-global
description: edited-global
label_membership_type: manual
hosts:
- label-act-host-1
- label-act-host-2
`)
apply()
got = flush()
require.Len(t, got, 1, "expected edited_label for host list change")
e, ok = got[0].(fleet.ActivityTypeEditedLabel)
require.True(t, ok, "expected edited_label, got %T", got[0])
require.Equal(t, "lbl-global", e.Name)

// Phase 5b: re-apply same host list — must be a no-op.
apply()
require.Empty(t, flush(), "no-op host list should produce no activity")

// Phase 6: add a host_vitals label alongside the existing labels.
writeGlobal(` - name: lbl-global
description: edited-global
label_membership_type: manual
hosts:
- label-act-host-1
- label-act-host-2
- name: lbl-host-vitals
description: vitals-label
label_membership_type: host_vitals
criteria:
vital: end_user_idp_group
value: original-group
`)
apply()
got = flush()
require.Len(t, got, 1, "expected created_label for host_vitals label")
c, ok := got[0].(fleet.ActivityTypeCreatedLabel)
require.True(t, ok, "expected created_label, got %T", got[0])
require.Equal(t, "lbl-host-vitals", c.Name)

// Phase 7: update the host_vitals criteria — should emit edited_label.
writeGlobal(` - name: lbl-global
description: edited-global
label_membership_type: manual
hosts:
- label-act-host-1
- label-act-host-2
- name: lbl-host-vitals
description: vitals-label
label_membership_type: host_vitals
criteria:
vital: end_user_idp_group
value: updated-group
`)
apply()
got = flush()
require.Len(t, got, 1, "expected edited_label for criteria change")
e, ok = got[0].(fleet.ActivityTypeEditedLabel)
require.True(t, ok, "expected edited_label, got %T", got[0])
require.Equal(t, "lbl-host-vitals", e.Name)

// Phase 7b: re-apply the same criteria — must be a no-op.
apply()
require.Empty(t, flush(), "no-op criteria re-apply should produce no activity")

// Phase 8: remove all labels from the spec — gitops issues delete calls
// per name, which should each emit a deleted_label activity.
writeGlobal("")
writeTeam("")
apply()
got = flush()
require.Len(t, got, 3, "expected deleted_label for all three labels")
deletedNames := map[string]struct{}{}
for _, a := range got {
d, ok := a.(fleet.ActivityTypeDeletedLabel)
require.True(t, ok, "expected deleted_label, got %T", a)
deletedNames[d.Name] = struct{}{}
}
require.Contains(t, deletedNames, "lbl-global")
require.Contains(t, deletedNames, "lbl-team")
require.Contains(t, deletedNames, "lbl-host-vitals")
}

// TestGitOpsVPPAppAutoUpdate tests that auto-update settings for VPP apps (iOS/iPadOS)
// are properly applied via GitOps.
func (s *enterpriseIntegrationGitopsTestSuite) TestGitOpsVPPAppAutoUpdate() {
Expand Down
10 changes: 10 additions & 0 deletions frontend/interfaces/activity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,9 @@ export enum ActivityType {
DisabledManagedLocalAccount = "disabled_managed_local_account",
ViewedManagedLocalAccount = "read_managed_local_account",
CreatedManagedLocalAccount = "created_managed_local_account",
CreatedLabel = "created_label",
EditedLabel = "edited_label",
DeletedLabel = "deleted_label",
}

/** This is a subset of ActivityType that are shown only for the host past activities */
Expand Down Expand Up @@ -305,6 +308,10 @@ export interface IActivityDetails {
certificate_template_id?: number;
detail?: string;
exception?: string;
label_id?: number;
label_name?: string;
fleet_id?: number | null;
fleet_name?: string | null;
}

// maps activity types to their corresponding label to use when filtering activites via the dropdown
Expand Down Expand Up @@ -485,4 +492,7 @@ export const ACTIVITY_TYPE_TO_FILTER_LABEL: Record<ActivityType, string> = {
"Turned off managed local account",
[ActivityType.ViewedManagedLocalAccount]: "Viewed managed account",
[ActivityType.CreatedManagedLocalAccount]: "Created managed account",
[ActivityType.CreatedLabel]: "Created label",
[ActivityType.EditedLabel]: "Edited label",
[ActivityType.DeletedLabel]: "Deleted label",
};
Original file line number Diff line number Diff line change
Expand Up @@ -1839,4 +1839,42 @@ describe("Activity Feed", () => {
screen.getByText("disabled the software exception for GitOps.")
).toBeInTheDocument();
});

it("renders a created_label activity for a global label", () => {
const activity = createMockActivity({
type: ActivityType.CreatedLabel,
details: { label_id: 1, label_name: "Workstations" },
});
render(<GlobalActivityItem activity={activity} isPremiumTier />);
expect(screen.getByText("created a label .")).toBeInTheDocument();
expect(screen.getByText("Workstations")).toBeInTheDocument();
});

it("renders an edited_label activity scoped to a fleet", () => {
const activity = createMockActivity({
type: ActivityType.EditedLabel,
details: {
label_id: 1,
label_name: "Workstations",
fleet_id: 5,
fleet_name: "Engineering",
},
});
render(<GlobalActivityItem activity={activity} isPremiumTier />);
expect(
screen.getByText("edited the label on the fleet.")
).toBeInTheDocument();
expect(screen.getByText("Workstations")).toBeInTheDocument();
expect(screen.getByText("Engineering")).toBeInTheDocument();
});

it("renders a deleted_label activity for a global label", () => {
const activity = createMockActivity({
type: ActivityType.DeletedLabel,
details: { label_id: 1, label_name: "Workstations" },
});
render(<GlobalActivityItem activity={activity} isPremiumTier />);
expect(screen.getByText("deleted the label .")).toBeInTheDocument();
expect(screen.getByText("Workstations")).toBeInTheDocument();
});
});
Loading
Loading