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/44950-gitops-null-label-id
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
* Fixed a GitOps failure ("converting NULL to uint is unsupported") when moving labels from global to fleet scope, caused by deleted label associations with NULL `label_id` values in `mdm_configuration_profile_labels` and `mdm_declaration_labels`.
4 changes: 2 additions & 2 deletions server/datastore/mysql/apple_mdm.go
Original file line number Diff line number Diff line change
Expand Up @@ -5593,7 +5593,7 @@ func batchSetDeclarationLabelAssociationsDB(ctx context.Context, tx sqlx.ExtCont
// unrelated decl+label tuples)
deleteStmt := `
DELETE FROM mdm_declaration_labels
WHERE (apple_declaration_uuid, label_id) NOT IN (%s) AND
WHERE ((apple_declaration_uuid, label_id) NOT IN (%s) OR label_id IS NULL) AND
apple_declaration_uuid IN (?)
`

Expand All @@ -5616,7 +5616,7 @@ func batchSetDeclarationLabelAssociationsDB(ctx context.Context, tx sqlx.ExtCont
`

selectStmt := `
SELECT apple_declaration_uuid as profile_uuid, label_name, label_id, exclude, require_all FROM mdm_declaration_labels
SELECT apple_declaration_uuid as profile_uuid, label_name, COALESCE(label_id, 0) as label_id, exclude, require_all FROM mdm_declaration_labels
WHERE (apple_declaration_uuid, label_name) IN (%s)
`

Expand Down
2 changes: 1 addition & 1 deletion server/datastore/mysql/mdm.go
Original file line number Diff line number Diff line change
Expand Up @@ -1572,7 +1572,7 @@ func batchSetProfileLabelAssociationsDB(
`

selectStmt := `
SELECT %s_profile_uuid as profile_uuid, label_id, label_name, exclude, require_all FROM mdm_configuration_profile_labels
SELECT %s_profile_uuid as profile_uuid, COALESCE(label_id, 0) as label_id, label_name, exclude, require_all FROM mdm_configuration_profile_labels
WHERE (%s_profile_uuid, label_name) IN (%s)
`

Expand Down
82 changes: 82 additions & 0 deletions server/datastore/mysql/mdm_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7283,6 +7283,88 @@ func testBatchSetProfileLabelAssociations(t *testing.T, ds *Datastore) {
require.NoError(t, err)
expectLabels(t, uuid, platform, nil)
})

t.Run("same label name recreated after deletion "+platform, func(t *testing.T) {
// Regression test for https://github.com/fleetdm/fleet/issues/44950.
// Reproduces: global label referenced by a profile is deleted (label_id
// becomes NULL via ON DELETE SET NULL), then a fleet-level label with
// the SAME name is created and re-applied. The SELECT in
// batchSetProfileLabelAssociationsDB used to fail with
// "converting NULL to uint is unsupported" because it selected the
// broken row's NULL label_id into a uint field.
origLabel := &fleet.Label{
Name: "gitops-label-" + platform,
Query: "select 1 from osquery_info;",
}
origLabel, err := ds.NewLabel(ctx, origLabel)
require.NoError(t, err)

// Associate profile with original label
profileLabels := []fleet.ConfigurationProfileLabel{
{ProfileUUID: uuid, LabelName: origLabel.Name, LabelID: origLabel.ID},
}
err = ds.withTx(ctx, func(tx sqlx.ExtContext) error {
_, err := batchSetProfileLabelAssociationsDB(ctx, tx, profileLabels, nil, platform)
return err
})
require.NoError(t, err)

// Delete the label — the FK ON DELETE SET NULL sets label_id to NULL
// in the mdm_configuration_profile_labels row.
ExecAdhocSQL(t, ds, func(tx sqlx.ExtContext) error {
_, err := tx.ExecContext(ctx, `DELETE FROM labels WHERE id = ?`, origLabel.ID)
return err
})

p := platform
if p == "darwin" {
p = "apple"
}

// Sanity check: the broken (NULL) row exists.
var brokenBefore int
ExecAdhocSQL(t, ds, func(tx sqlx.ExtContext) error {
return sqlx.GetContext(ctx, tx, &brokenBefore,
fmt.Sprintf(`SELECT COUNT(*) FROM mdm_configuration_profile_labels WHERE %s_profile_uuid = ? AND label_id IS NULL`, p),
uuid)
})
require.Equal(t, 1, brokenBefore, "expected broken row after label deletion")

// Create a new label with the SAME name (simulates moving from global to fleet scope)
newLabel := &fleet.Label{
Name: origLabel.Name,
Query: "select 1 from osquery_info;",
}
newLabel, err = ds.NewLabel(ctx, newLabel)
require.NoError(t, err)

// Re-apply with the new label ID but same name — this is the
// call that used to fail with "converting NULL to uint".
profileLabels = []fleet.ConfigurationProfileLabel{
{ProfileUUID: uuid, LabelName: newLabel.Name, LabelID: newLabel.ID},
}
err = ds.withTx(ctx, func(tx sqlx.ExtContext) error {
_, err := batchSetProfileLabelAssociationsDB(ctx, tx, profileLabels, nil, platform)
return err
})
require.NoError(t, err, "batchSetProfileLabelAssociationsDB should not fail when a broken row exists with the same label name")

// Verify only the new label association exists (no broken row)
var brokenAfter int
ExecAdhocSQL(t, ds, func(tx sqlx.ExtContext) error {
return sqlx.GetContext(ctx, tx, &brokenAfter,
fmt.Sprintf(`SELECT COUNT(*) FROM mdm_configuration_profile_labels WHERE %s_profile_uuid = ? AND label_id IS NULL`, p),
uuid)
})
require.Equal(t, 0, brokenAfter, "broken (NULL label_id) row should have been cleaned up")

// Verify the correct label association exists with the new label ID.
expectLabels(t, uuid, platform, profileLabels)

// Other profiles must remain untouched.
expectLabels(t, otherWinProfile.ProfileUUID, "windows", wantOtherWin)
expectLabels(t, otherMacProfile.ProfileUUID, "darwin", wantOtherMac)
})
}

t.Run("unsupported platform", func(t *testing.T) {
Expand Down
Loading