Skip to content

Commit 98b827e

Browse files
Add host name detail API, resend, and transfer/enrollment reconciliation
Relates to #48624 Surface per-host host-name template state and wire the enforcement lifecycle: - host.mdm.os_settings.host_name in the host detail response, omitted for ineligible hosts (recovery-lock style) - fold device-name rows into the OS-settings aggregate counts (GetMDMAppleProfilesSummary) and the os_settings host-list filter via the shared apple-status CASE + a new device-name join - POST /hosts/{id}/name_template/resend endpoint (202/409/404, profile resend authz) - transfer reconciliation inside the AddHostsToTeam transaction (covers all entry points) - enrollment reconciliation on both Apple lifecycle branches (turn-on and reset)
1 parent f278514 commit 98b827e

19 files changed

Lines changed: 961 additions & 43 deletions

File tree

cmd/fleetctl/fleetctl/testing_utils/testing_utils.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,9 @@ func RunServerWithMockedDS(t *testing.T, opts ...*service.TestServerOpts) (*http
165165
ds.GetHostManagedLocalAccountStatusFunc = func(ctx context.Context, hostUUID string) (*fleet.HostMDMManagedLocalAccount, error) {
166166
return nil, nil
167167
}
168+
ds.GetHostDeviceNameEnforcementFunc = func(ctx context.Context, hostUUID string) (*fleet.HostDeviceNameEnforcement, error) {
169+
return nil, nil
170+
}
168171
ds.TeamMDMConfigFunc = func(ctx context.Context, teamID uint) (*fleet.TeamMDM, error) {
169172
return &fleet.TeamMDM{}, nil
170173
}

server/datastore/mysql/apple_device_names.go

Lines changed: 41 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -199,44 +199,48 @@ func (ds *Datastore) ReconcileHostDeviceNamesForHosts(ctx context.Context, hostI
199199
if len(hostIDs) == 0 {
200200
return nil
201201
}
202-
203-
// A host should have a queued enforcement row when it is eligible and its team
204-
// carries a non-empty name template; otherwise any existing row is removed.
205-
// The template lives in teams.config JSON at $.mdm.name_template (empty string
206-
// when unset, NULL for teams whose config predates the feature).
207-
//
208-
// Rather than conditionally upsert-or-delete per host, we delete every row for
209-
// the given hosts and re-create queued rows only for the ones that should
210-
// still be enforced. Both statements run in a single transaction, so no
211-
// window ever exposes a missing row; the only observable difference from an
212-
// upsert is that created_at resets, which is correct after a team change.
213202
return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
214-
deleteStmt, deleteArgs, err := sqlx.In(`
215-
DELETE hmadn
216-
FROM host_mdm_apple_device_names hmadn
217-
JOIN hosts h ON h.uuid = hmadn.host_uuid
218-
WHERE h.id IN (?)`, hostIDs)
219-
if err != nil {
220-
return ctxerr.Wrap(ctx, err, "build reconcile device name delete")
221-
}
222-
if _, err := tx.ExecContext(ctx, deleteStmt, deleteArgs...); err != nil {
223-
return ctxerr.Wrap(ctx, err, "reconcile device name delete")
224-
}
203+
return reconcileHostDeviceNamesForHostsDB(ctx, tx, hostIDs)
204+
})
205+
}
225206

226-
insertStmt, insertArgs, err := sqlx.In(`
227-
INSERT INTO host_mdm_apple_device_names (host_uuid, status)
228-
SELECT h.uuid, NULL`+deviceNameEligibleHostsJoins+`
229-
JOIN teams t ON t.id = h.team_id
230-
WHERE h.id IN (?)
231-
AND `+deviceNameEligibleHostsWhere+`
232-
AND t.config->>'$.mdm.name_template' IS NOT NULL
233-
AND t.config->>'$.mdm.name_template' != ''`, hostIDs)
234-
if err != nil {
235-
return ctxerr.Wrap(ctx, err, "build reconcile device name insert")
236-
}
237-
if _, err := tx.ExecContext(ctx, insertStmt, insertArgs...); err != nil {
238-
return ctxerr.Wrap(ctx, err, "reconcile device name insert")
239-
}
207+
// reconcileHostDeviceNamesForHostsDB upserts or deletes host-name enforcement
208+
// rows for the given hosts based on each host's current team template.
209+
//
210+
// A host should have a queued enforcement row when it is eligible and its team
211+
// carries a non-empty name template; otherwise any existing row is removed. The
212+
// template lives in teams.config JSON at $.mdm.name_template (empty string when
213+
// unset, NULL for teams whose config predates the feature).
214+
func reconcileHostDeviceNamesForHostsDB(ctx context.Context, tx sqlx.ExtContext, hostIDs []uint) error {
215+
if len(hostIDs) == 0 {
240216
return nil
241-
})
217+
}
218+
219+
deleteStmt, deleteArgs, err := sqlx.In(`
220+
DELETE hmadn
221+
FROM host_mdm_apple_device_names hmadn
222+
JOIN hosts h ON h.uuid = hmadn.host_uuid
223+
WHERE h.id IN (?)`, hostIDs)
224+
if err != nil {
225+
return ctxerr.Wrap(ctx, err, "build reconcile device name delete")
226+
}
227+
if _, err := tx.ExecContext(ctx, deleteStmt, deleteArgs...); err != nil {
228+
return ctxerr.Wrap(ctx, err, "reconcile device name delete")
229+
}
230+
231+
insertStmt, insertArgs, err := sqlx.In(`
232+
INSERT INTO host_mdm_apple_device_names (host_uuid, status)
233+
SELECT h.uuid, NULL`+deviceNameEligibleHostsJoins+`
234+
JOIN teams t ON t.id = h.team_id
235+
WHERE h.id IN (?)
236+
AND `+deviceNameEligibleHostsWhere+`
237+
AND t.config->>'$.mdm.name_template' IS NOT NULL
238+
AND t.config->>'$.mdm.name_template' != ''`, hostIDs)
239+
if err != nil {
240+
return ctxerr.Wrap(ctx, err, "build reconcile device name insert")
241+
}
242+
if _, err := tx.ExecContext(ctx, insertStmt, insertArgs...); err != nil {
243+
return ctxerr.Wrap(ctx, err, "reconcile device name insert")
244+
}
245+
return nil
242246
}

server/datastore/mysql/apple_device_names_test.go

Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"github.com/fleetdm/fleet/v4/server/fleet"
88
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
99
"github.com/fleetdm/fleet/v4/server/test"
10+
"github.com/jmoiron/sqlx"
1011
"github.com/stretchr/testify/require"
1112
)
1213

@@ -22,6 +23,11 @@ func TestHostDeviceNames(t *testing.T) {
2223
{"Verify", testHostDeviceNamesVerify},
2324
{"Resend", testHostDeviceNamesResend},
2425
{"Reconcile", testHostDeviceNamesReconcile},
26+
{"TransferViaAddHostsToTeam", testHostDeviceNamesTransferViaAddHostsToTeam},
27+
{"TransferBatched", testHostDeviceNamesTransferBatched},
28+
{"SummaryAndFilter", testHostDeviceNamesSummaryAndFilter},
29+
{"SummaryFilterLabel", testHostDeviceNamesSummaryFilterLabel},
30+
{"TeamDeletionCleanup", testHostDeviceNamesTeamDeletionCleanup},
2531
{"HostDeletionCleanup", testHostDeviceNamesHostDeletionCleanup},
2632
{"FullLifecycle", testHostDeviceNamesFullLifecycle},
2733
}
@@ -302,6 +308,249 @@ func testHostDeviceNamesReconcile(t *testing.T, ds *Datastore) {
302308
require.NoError(t, ds.ReconcileHostDeviceNamesForHosts(ctx, nil))
303309
}
304310

311+
// setDeviceNameTemplate writes name_template into a team's config JSON directly,
312+
// so the eligibility/reconcile SQL sees a non-empty template without depending on
313+
// the TeamMDM struct field.
314+
func setDeviceNameTemplate(t *testing.T, ds *Datastore, teamID uint, tmpl string) {
315+
_, err := ds.writer(t.Context()).ExecContext(t.Context(),
316+
`UPDATE teams SET config = JSON_SET(config, '$.mdm.name_template', ?) WHERE id = ?`, tmpl, teamID)
317+
require.NoError(t, err)
318+
}
319+
320+
// testHostDeviceNamesTransferViaAddHostsToTeam exercises the transfer
321+
// reconciliation wired into the AddHostsToTeam datastore transaction, which
322+
// covers every service entry point that moves hosts between teams.
323+
func testHostDeviceNamesTransferViaAddHostsToTeam(t *testing.T, ds *Datastore) {
324+
ctx := t.Context()
325+
326+
withTemplate, err := ds.NewTeam(ctx, &fleet.Team{Name: "transfer-with-template"})
327+
require.NoError(t, err)
328+
setDeviceNameTemplate(t, ds, withTemplate.ID, "WS-$FLEET_VAR_HOST_HARDWARE_SERIAL")
329+
330+
noTemplate, err := ds.NewTeam(ctx, &fleet.Team{Name: "transfer-no-template"})
331+
require.NoError(t, err)
332+
333+
// A host starts in the template team with a queued row.
334+
host := enrollAppleHostForDeviceName(t, ds, "mac", "darwin", withTemplate.ID, false)
335+
require.NoError(t, ds.BulkUpsertHostDeviceNameEnforcement(ctx, withTemplate.ID))
336+
require.Nil(t, getDeviceNameRow(t, ds, host.UUID).Status)
337+
338+
// Give the host a name so we can assert the transfer never renames it.
339+
_, err = ds.writer(ctx).ExecContext(ctx, `UPDATE hosts SET computer_name = ? WHERE id = ?`, "keep-this-name", host.ID)
340+
require.NoError(t, err)
341+
342+
// template -> template-less: the row is deleted (enforcement stops) and the
343+
// host's name is left untouched.
344+
require.NoError(t, ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&noTemplate.ID, []uint{host.ID})))
345+
_, err = ds.GetHostDeviceNameEnforcement(ctx, host.UUID)
346+
require.True(t, fleet.IsNotFound(err), "transfer to a template-less team should delete the enforcement row")
347+
var name string
348+
require.NoError(t, sqlx.GetContext(ctx, ds.reader(ctx), &name, `SELECT computer_name FROM hosts WHERE id = ?`, host.ID))
349+
require.Equal(t, "keep-this-name", name, "transfer must not rename the host")
350+
351+
// template-less -> template: reconcile re-creates the queued row (UI reverts
352+
// to Enforcing/Pending).
353+
require.NoError(t, ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&withTemplate.ID, []uint{host.ID})))
354+
require.Nil(t, getDeviceNameRow(t, ds, host.UUID).Status, "transfer into a template team should create a queued row")
355+
356+
// template -> template (a different team that also has a template): the row is
357+
// reset to NULL so the destination team's template is enforced afresh. Mark it
358+
// verified first to prove the transfer resets an already-settled row.
359+
_, err = ds.writer(ctx).ExecContext(ctx, `UPDATE host_mdm_apple_device_names SET status = ? WHERE host_uuid = ?`, fleet.MDMDeliveryVerified, host.UUID)
360+
require.NoError(t, err)
361+
otherTemplate, err := ds.NewTeam(ctx, &fleet.Team{Name: "transfer-other-template"})
362+
require.NoError(t, err)
363+
setDeviceNameTemplate(t, ds, otherTemplate.ID, "Lab-$FLEET_VAR_HOST_HARDWARE_SERIAL")
364+
require.NoError(t, ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&otherTemplate.ID, []uint{host.ID})))
365+
require.Nil(t, getDeviceNameRow(t, ds, host.UUID).Status, "transfer between template teams should reset the row to queued")
366+
367+
// template -> No team: the row is deleted (No team never enforces).
368+
require.NoError(t, ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(nil, []uint{host.ID})))
369+
_, err = ds.GetHostDeviceNameEnforcement(ctx, host.UUID)
370+
require.True(t, fleet.IsNotFound(err), "transfer to No team should delete the enforcement row")
371+
}
372+
373+
// testHostDeviceNamesTransferBatched moves several hosts at once with a batch
374+
// size smaller than the host count, so the reconcile runs across multiple batches
375+
// within AddHostsToTeam.
376+
func testHostDeviceNamesTransferBatched(t *testing.T, ds *Datastore) {
377+
ctx := t.Context()
378+
379+
noTemplate, err := ds.NewTeam(ctx, &fleet.Team{Name: "batch-no-template"})
380+
require.NoError(t, err)
381+
withTemplate, err := ds.NewTeam(ctx, &fleet.Team{Name: "batch-with-template"})
382+
require.NoError(t, err)
383+
setDeviceNameTemplate(t, ds, withTemplate.ID, "WS-$FLEET_VAR_HOST_HARDWARE_SERIAL")
384+
385+
// Three eligible hosts start in the template-less team (no rows) plus one BYOD
386+
// host that must never get a row.
387+
var hostIDs []uint
388+
eligible := make([]*fleet.Host, 0, 3)
389+
for i := range 3 {
390+
h := enrollAppleHostForDeviceName(t, ds, "batch"+string(rune('a'+i)), "darwin", noTemplate.ID, false)
391+
eligible = append(eligible, h)
392+
hostIDs = append(hostIDs, h.ID)
393+
}
394+
byod := enrollAppleHostForDeviceName(t, ds, "batch-byod", "ios", noTemplate.ID, true)
395+
hostIDs = append(hostIDs, byod.ID)
396+
397+
// Move all four into the template team with a batch size of 1 so the reconcile
398+
// runs once per batch.
399+
require.NoError(t, ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&withTemplate.ID, hostIDs).WithBatchSize(1)))
400+
for _, h := range eligible {
401+
require.Nil(t, getDeviceNameRow(t, ds, h.UUID).Status, "eligible host %s should be queued after batched transfer", h.Hostname)
402+
}
403+
_, err = ds.GetHostDeviceNameEnforcement(ctx, byod.UUID)
404+
require.True(t, fleet.IsNotFound(err), "BYOD host must not get a row")
405+
406+
// Move them all back to the template-less team, again batched: all rows deleted.
407+
require.NoError(t, ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&noTemplate.ID, hostIDs).WithBatchSize(2)))
408+
for _, h := range eligible {
409+
_, err = ds.GetHostDeviceNameEnforcement(ctx, h.UUID)
410+
require.True(t, fleet.IsNotFound(err), "row for %s should be deleted after batched transfer out", h.Hostname)
411+
}
412+
}
413+
414+
// testHostDeviceNamesSummaryAndFilter asserts that host-name enforcement rows are
415+
// folded into the OS-settings aggregate counts and the os_settings host-list
416+
// filter, and that ineligible hosts (no row) count in no bucket.
417+
func testHostDeviceNamesSummaryAndFilter(t *testing.T, ds *Datastore) {
418+
ctx := t.Context()
419+
420+
team, err := ds.NewTeam(ctx, &fleet.Team{Name: "summary-team"})
421+
require.NoError(t, err)
422+
setDeviceNameTemplate(t, ds, team.ID, "WS-$FLEET_VAR_HOST_HARDWARE_SERIAL")
423+
424+
failedHost := enrollAppleHostForDeviceName(t, ds, "failed", "darwin", team.ID, false)
425+
verifiedHost := enrollAppleHostForDeviceName(t, ds, "verified", "ios", team.ID, false)
426+
verifyingHost := enrollAppleHostForDeviceName(t, ds, "verifying", "ios", team.ID, false)
427+
queuedHost := enrollAppleHostForDeviceName(t, ds, "queued", "ipados", team.ID, false)
428+
comboHost := enrollAppleHostForDeviceName(t, ds, "combo", "darwin", team.ID, false)
429+
byodHost := enrollAppleHostForDeviceName(t, ds, "byod", "ios", team.ID, true)
430+
431+
require.NoError(t, ds.BulkUpsertHostDeviceNameEnforcement(ctx, team.ID))
432+
// The BYOD host is ineligible, so it never got a row.
433+
_, err = ds.GetHostDeviceNameEnforcement(ctx, byodHost.UUID)
434+
require.True(t, fleet.IsNotFound(err))
435+
436+
setStatus := func(hostUUID string, status fleet.MDMDeliveryStatus) {
437+
_, err := ds.writer(ctx).ExecContext(ctx, `UPDATE host_mdm_apple_device_names SET status = ? WHERE host_uuid = ?`, status, hostUUID)
438+
require.NoError(t, err)
439+
}
440+
setStatus(failedHost.UUID, fleet.MDMDeliveryFailed)
441+
setStatus(verifiedHost.UUID, fleet.MDMDeliveryVerified)
442+
setStatus(verifyingHost.UUID, fleet.MDMDeliveryVerifying)
443+
setStatus(comboHost.UUID, fleet.MDMDeliveryFailed)
444+
// queuedHost keeps its NULL status from the bulk upsert, which renders as pending.
445+
446+
// comboHost has a failed rename (set above) AND a verified (install) config
447+
// profile: the shared status CASE must combine the profile and device-name
448+
// buckets and let the failed rename win (failed > verified precedence).
449+
_, err = ds.writer(ctx).ExecContext(ctx, `
450+
INSERT INTO host_mdm_apple_profiles
451+
(host_uuid, profile_uuid, command_uuid, status, operation_type, detail, profile_name, profile_identifier, checksum)
452+
VALUES (?, ?, '', ?, ?, '', 'p1', 'com.example.p1', ?)`,
453+
comboHost.UUID, "a"+comboHost.UUID, fleet.MDMDeliveryVerified, fleet.MDMOperationTypeInstall, []byte("csum"))
454+
require.NoError(t, err)
455+
456+
// Aggregate summary folds the rename statuses in (a NULL/queued row counts as
457+
// pending); the BYOD host is in no bucket, and comboHost lands in failed.
458+
summary, err := ds.GetMDMAppleProfilesSummary(ctx, &team.ID)
459+
require.NoError(t, err)
460+
require.Equal(t, uint(2), summary.Failed, "failedHost + comboHost (failed rename wins over its verified profile)")
461+
require.Equal(t, uint(1), summary.Verified)
462+
require.Equal(t, uint(1), summary.Verifying)
463+
require.Equal(t, uint(1), summary.Pending)
464+
465+
// Each aggregate card's host-list filter returns the matching hosts, including
466+
// the queued (NULL-status) host under pending and comboHost under failed.
467+
userFilter := fleet.TeamFilter{User: test.UserAdmin}
468+
assertFilter := func(status fleet.OSSettingsStatus, want ...*fleet.Host) {
469+
hosts, err := ds.ListHosts(ctx, userFilter, fleet.HostListOptions{TeamFilter: &team.ID, OSSettingsFilter: status})
470+
require.NoError(t, err)
471+
gotIDs := make([]uint, 0, len(hosts))
472+
for _, h := range hosts {
473+
gotIDs = append(gotIDs, h.ID)
474+
}
475+
wantIDs := make([]uint, 0, len(want))
476+
for _, h := range want {
477+
wantIDs = append(wantIDs, h.ID)
478+
}
479+
require.ElementsMatch(t, wantIDs, gotIDs)
480+
}
481+
assertFilter(fleet.OSSettingsFailed, failedHost, comboHost)
482+
assertFilter(fleet.OSSettingsVerified, verifiedHost)
483+
assertFilter(fleet.OSSettingsVerifying, verifyingHost)
484+
assertFilter(fleet.OSSettingsPending, queuedHost)
485+
}
486+
487+
// testHostDeviceNamesSummaryFilterLabel covers the os_settings host-list filter
488+
// on the label-hosts path (ListHostsInLabel), which folds in the same
489+
// device-name status join as the main list path.
490+
func testHostDeviceNamesSummaryFilterLabel(t *testing.T, ds *Datastore) {
491+
ctx := t.Context()
492+
493+
team, err := ds.NewTeam(ctx, &fleet.Team{Name: "label-team"})
494+
require.NoError(t, err)
495+
setDeviceNameTemplate(t, ds, team.ID, "WS-$FLEET_VAR_HOST_HARDWARE_SERIAL")
496+
497+
failedHost := enrollAppleHostForDeviceName(t, ds, "label-failed", "darwin", team.ID, false)
498+
verifiedHost := enrollAppleHostForDeviceName(t, ds, "label-verified", "ios", team.ID, false)
499+
require.NoError(t, ds.BulkUpsertHostDeviceNameEnforcement(ctx, team.ID))
500+
_, err = ds.writer(ctx).ExecContext(ctx, `UPDATE host_mdm_apple_device_names SET status = ? WHERE host_uuid = ?`, fleet.MDMDeliveryFailed, failedHost.UUID)
501+
require.NoError(t, err)
502+
_, err = ds.writer(ctx).ExecContext(ctx, `UPDATE host_mdm_apple_device_names SET status = ? WHERE host_uuid = ?`, fleet.MDMDeliveryVerified, verifiedHost.UUID)
503+
require.NoError(t, err)
504+
505+
label, err := ds.NewLabel(ctx, &fleet.Label{Name: "label-dn", Query: "select 1"})
506+
require.NoError(t, err)
507+
for _, h := range []*fleet.Host{failedHost, verifiedHost} {
508+
require.NoError(t, ds.RecordLabelQueryExecutions(ctx, h, map[uint]*bool{label.ID: new(true)}, time.Now(), false))
509+
}
510+
511+
userFilter := fleet.TeamFilter{User: test.UserAdmin}
512+
hosts, err := ds.ListHostsInLabel(ctx, userFilter, label.ID, fleet.HostListOptions{TeamFilter: &team.ID, OSSettingsFilter: fleet.OSSettingsFailed})
513+
require.NoError(t, err)
514+
require.Len(t, hosts, 1)
515+
require.Equal(t, failedHost.ID, hosts[0].ID)
516+
}
517+
518+
// testHostDeviceNamesTeamDeletionCleanup asserts that deleting a team removes the
519+
// host-name enforcement rows for its hosts. Team deletion moves the hosts to "No
520+
// team" via ON DELETE SET NULL (not AddHostsToTeam), so the enforcement rows must
521+
// be cleaned up explicitly in DeleteTeam.
522+
func testHostDeviceNamesTeamDeletionCleanup(t *testing.T, ds *Datastore) {
523+
ctx := t.Context()
524+
525+
team, err := ds.NewTeam(ctx, &fleet.Team{Name: "delete-me"})
526+
require.NoError(t, err)
527+
setDeviceNameTemplate(t, ds, team.ID, "WS-$FLEET_VAR_HOST_HARDWARE_SERIAL")
528+
host := enrollAppleHostForDeviceName(t, ds, "del", "darwin", team.ID, false)
529+
530+
otherTeam, err := ds.NewTeam(ctx, &fleet.Team{Name: "keep-me"})
531+
require.NoError(t, err)
532+
setDeviceNameTemplate(t, ds, otherTeam.ID, "WS-$FLEET_VAR_HOST_HARDWARE_SERIAL")
533+
otherHost := enrollAppleHostForDeviceName(t, ds, "keep", "darwin", otherTeam.ID, false)
534+
535+
require.NoError(t, ds.BulkUpsertHostDeviceNameEnforcement(ctx, team.ID))
536+
require.NoError(t, ds.BulkUpsertHostDeviceNameEnforcement(ctx, otherTeam.ID))
537+
require.Nil(t, getDeviceNameRow(t, ds, host.UUID).Status)
538+
require.Nil(t, getDeviceNameRow(t, ds, otherHost.UUID).Status)
539+
540+
require.NoError(t, ds.DeleteTeam(ctx, team.ID))
541+
542+
// The deleted team's host keeps its record but moves to No team; its
543+
// enforcement row is gone.
544+
_, err = ds.GetHostDeviceNameEnforcement(ctx, host.UUID)
545+
require.True(t, fleet.IsNotFound(err), "enforcement row must be deleted with the team")
546+
movedHost, err := ds.Host(ctx, host.ID)
547+
require.NoError(t, err)
548+
require.Nil(t, movedHost.TeamID, "host should have moved to No team, not been deleted")
549+
550+
// The other team's row is untouched.
551+
require.Nil(t, getDeviceNameRow(t, ds, otherHost.UUID).Status, "other team's row must survive")
552+
}
553+
305554
func testHostDeviceNamesHostDeletionCleanup(t *testing.T, ds *Datastore) {
306555
ctx := t.Context()
307556

0 commit comments

Comments
 (0)