diff --git a/changes/43502-android-pubsub-dedup b/changes/43502-android-pubsub-dedup new file mode 100644 index 0000000000..1e5f983746 --- /dev/null +++ b/changes/43502-android-pubsub-dedup @@ -0,0 +1 @@ +- Added deduplication and out-of-order protection to the Android MDM Pub/Sub notification handler. Duplicate deliveries from Google Pub/Sub no longer re-run the setup experience or emit duplicate activities, and a stale device-deleted notification arriving after a re-enrollment no longer leaves the host stuck showing unenrolled. diff --git a/server/datastore/mysql/android.go b/server/datastore/mysql/android.go index 4b30796f14..92514d11ac 100644 --- a/server/datastore/mysql/android.go +++ b/server/datastore/mysql/android.go @@ -8,6 +8,7 @@ import ( "fmt" "slices" "strings" + "time" "unicode/utf8" "github.com/fleetdm/fleet/v4/server/contexts/ctxdb" @@ -576,6 +577,128 @@ UPDATE host_mdm return rows > 0, nil } +// GetAndroidPubSubDedupState returns the last-processed Google Pub/Sub messageId +// and AMAPI event timestamp recorded for the host, used by the AMAPI notification +// handler to drop duplicate (same messageId) and stale (older timestamp) +// deliveries. When the android_devices row exists but nothing has been recorded +// yet, it returns an empty messageId and nil eventTime with no error. When no +// android_devices row exists for the host, it returns a NotFound error. +func (ds *Datastore) GetAndroidPubSubDedupState(ctx context.Context, hostID uint) (messageID string, eventTime *time.Time, err error) { + var state struct { + MessageID *string `db:"last_pubsub_message_id"` + EventTime *time.Time `db:"last_pubsub_event_time"` + } + err = sqlx.GetContext(ctx, ds.reader(ctx), &state, + `SELECT last_pubsub_message_id, last_pubsub_event_time FROM android_devices WHERE host_id = ?`, hostID) + switch { + case errors.Is(err, sql.ErrNoRows): + return "", nil, ctxerr.Wrap(ctx, notFound("AndroidDevice").WithID(hostID), "get android pubsub dedup state") + case err != nil: + return "", nil, ctxerr.Wrap(ctx, err, "get android pubsub dedup state") + } + return ptr.ValOrZero(state.MessageID), state.EventTime, nil +} + +// SetAndroidPubSubDedupState records the last-processed Google Pub/Sub messageId +// and AMAPI event timestamp for the host after a notification is handled +// successfully. Returns a NotFound error when no android_devices row matches +// hostID, so a missing row surfaces (via the caller's log) instead of silently +// dropping dedup state. +// +// An empty messageID or nil eventTime leaves that column at its previous value +// rather than clearing it. A notification that carries no usable timestamp says +// nothing about ordering, so overwriting the recorded baseline with NULL would +// disable staleness protection for the host until some later message happened to +// carry a parseable timestamp. The columns only ever move forward. +func (ds *Datastore) SetAndroidPubSubDedupState(ctx context.Context, hostID uint, messageID string, eventTime *time.Time) error { + // clientFoundRows is set on the DSN, so RowsAffected below counts matched rows, not + // changed rows — a write that preserves both columns still reports 1 for an existing row. + result, err := ds.writer(ctx).ExecContext(ctx, ` +UPDATE android_devices + SET last_pubsub_message_id = IF(? = '', last_pubsub_message_id, ?), + last_pubsub_event_time = CASE + WHEN ? IS NULL THEN last_pubsub_event_time + WHEN last_pubsub_event_time IS NULL OR ? > last_pubsub_event_time THEN ? + ELSE last_pubsub_event_time + END + WHERE host_id = ?`, + messageID, messageID, eventTime, eventTime, eventTime, hostID) + if err != nil { + return ctxerr.Wrap(ctx, err, "set android pubsub dedup state") + } + rows, err := result.RowsAffected() + if err != nil { + return ctxerr.Wrap(ctx, err, "get rows affected for set android pubsub dedup state") + } + if rows == 0 { + return ctxerr.Wrap(ctx, notFound("AndroidDevice").WithID(hostID), "set android pubsub dedup state") + } + return nil +} + +// SetAndroidHostEnrolled flips host_mdm back to enrolled for an Android host that +// is currently marked unenrolled. This recovers a host that was wrongly unenrolled +// by an out-of-order DELETED delivery: a live device sending a STATUS_REPORT is by +// definition still managed. It is a no-op (returns false) when the host is already +// enrolled or has no host_mdm row, so it is safe to call on every STATUS_REPORT. It +// intentionally does not re-run enrollment side effects (setup experience, cert +// templates, team assignment) — those belong to the ENROLLMENT path. +// +// It preserves the existing is_personal_enrollment classification rather than +// recomputing it: the triggering STATUS_REPORT payload may omit Ownership, which +// would otherwise misclassify a COBO (company-owned) host as personal. +func (ds *Datastore) SetAndroidHostEnrolled(ctx context.Context, hostID uint) (bool, error) { + // Fast path: this is called on every STATUS_REPORT, but almost always the host is + // already enrolled and there is nothing to do. Check that with a cheap read before + // opening a write transaction. The transaction below re-reads authoritatively, so a + // stale replica read here at worst causes a redundant (still-correct) transaction or + // defers recovery to the next report. + var enrolled bool + switch err := sqlx.GetContext(ctx, ds.reader(ctx), &enrolled, + `SELECT enrolled FROM host_mdm WHERE host_id = ?`, hostID); { + case errors.Is(err, sql.ErrNoRows): + return false, nil + case err != nil: + return false, ctxerr.Wrap(ctx, err, "check android host_mdm enrolled state") + case enrolled: + return false, nil + } + + appCfg, err := ds.AppConfig(ctx) + if err != nil { + return false, ctxerr.Wrap(ctx, err, "set android host enrolled get app config") + } + + var didEnroll bool + err = ds.withTx(ctx, func(tx sqlx.ExtContext) error { + var current struct { + Enrolled bool `db:"enrolled"` + IsPersonalEnrollment bool `db:"is_personal_enrollment"` + } + err := sqlx.GetContext(ctx, tx, ¤t, + `SELECT enrolled, is_personal_enrollment FROM host_mdm WHERE host_id = ?`, hostID) + switch { + case errors.Is(err, sql.ErrNoRows): + // No host_mdm row yet; leave enrollment to the ENROLLMENT path. + return nil + case err != nil: + return ctxerr.Wrap(ctx, err, "get android host_mdm enrolled state") + case current.Enrolled: + // Already enrolled: nothing to recover. + return nil + } + if err := upsertAndroidHostMDMInfoDB(ctx, tx, appCfg.ServerSettings.ServerURL, !current.IsPersonalEnrollment, true, hostID); err != nil { + return ctxerr.Wrap(ctx, err, "re-enroll android host_mdm info") + } + didEnroll = true + return nil + }) + if err != nil { + return false, err + } + return didEnroll, nil +} + func upsertAndroidHostMDMInfoDB(ctx context.Context, tx sqlx.ExtContext, serverURL string, companyOwned, enrolled bool, hostID uint) error { result, err := tx.ExecContext(ctx, ` INSERT INTO mobile_device_management_solutions (name, server_url) VALUES (?, ?) diff --git a/server/datastore/mysql/android_test.go b/server/datastore/mysql/android_test.go index 12a15b1497..2e9a79a9d7 100644 --- a/server/datastore/mysql/android_test.go +++ b/server/datastore/mysql/android_test.go @@ -61,6 +61,8 @@ func TestAndroid(t *testing.T) { {"NewAndroidHostWithIdP", testNewAndroidHostWithIdP}, {"AndroidBYODDetection", testAndroidBYODDetection}, {"SetAndroidHostUnenrolled", testSetAndroidHostUnenrolled}, + {"SetAndroidHostEnrolled", testSetAndroidHostEnrolled}, + {"AndroidPubSubDedupState", testAndroidPubSubDedupState}, {"BulkSetAndroidHostsUnenrolled", testBulkSetAndroidHostsUnenrolled}, {"InsertAndGetAndroidAppConfiguration", testInsertAndGetAndroidAppConfiguration}, {"UpdateAndroidAppConfiguration", testUpdateAndroidAppConfiguration}, @@ -3393,6 +3395,130 @@ func testAndroidBYODDetection(t *testing.T, ds *Datastore) { } // NEW TEST: verify single-host unenroll updates host_mdm correctly +func testSetAndroidHostEnrolled(t *testing.T, ds *Datastore) { + appCfg, err := ds.AppConfig(testCtx()) + require.NoError(t, err) + appCfg.ServerSettings.ServerURL = "https://mdm.example.com" + require.NoError(t, ds.SaveAppConfig(testCtx(), appCfg)) + + // Create a BYO Android host (companyOwned=false) -> enrolled host_mdm row. + esid := "enterprise-" + uuid.NewString() + res, err := ds.NewAndroidHost(testCtx(), createAndroidHost(esid), false) + require.NoError(t, err) + + // Already enrolled: no-op, returns false. + didEnroll, err := ds.SetAndroidHostEnrolled(testCtx(), res.Host.ID) + require.NoError(t, err) + require.False(t, didEnroll, "SetAndroidHostEnrolled must be a no-op when the host is already enrolled") + + // Unenroll, then recover. + unenrolled, err := ds.SetAndroidHostUnenrolled(testCtx(), res.Host.ID) + require.NoError(t, err) + require.True(t, unenrolled) + + didEnroll, err = ds.SetAndroidHostEnrolled(testCtx(), res.Host.ID) + require.NoError(t, err) + require.True(t, didEnroll, "SetAndroidHostEnrolled must restore enrollment for an unenrolled host") + + hostMDM, err := ds.GetHostMDM(testCtx(), res.Host.ID) + require.NoError(t, err) + require.True(t, hostMDM.Enrolled, "host_mdm.enrolled must be restored to 1") + require.Equal(t, "https://mdm.example.com", hostMDM.ServerURL, "server_url must be restored") + require.True(t, hostMDM.IsPersonalEnrollment, "BYO recovery must preserve is_personal_enrollment") + + // Calling again is a no-op. + didEnroll, err = ds.SetAndroidHostEnrolled(testCtx(), res.Host.ID) + require.NoError(t, err) + require.False(t, didEnroll) + + // Unknown host has no host_mdm row: no-op, no error. + didEnroll, err = ds.SetAndroidHostEnrolled(testCtx(), 999999) + require.NoError(t, err) + require.False(t, didEnroll) + + // COBO recovery must preserve is_personal_enrollment=0 even though the recovery does + // not know the ownership (it is derived from the existing row, not the status payload). + coboESID := "enterprise-cobo-" + uuid.NewString() + cobo, err := ds.NewAndroidHost(testCtx(), createAndroidHost(coboESID), true /* companyOwned */) + require.NoError(t, err) + coboMDM, err := ds.GetHostMDM(testCtx(), cobo.Host.ID) + require.NoError(t, err) + require.False(t, coboMDM.IsPersonalEnrollment, "fresh COBO enrollment is not a personal enrollment") + + unenrolled, err = ds.SetAndroidHostUnenrolled(testCtx(), cobo.Host.ID) + require.NoError(t, err) + require.True(t, unenrolled) + + didEnroll, err = ds.SetAndroidHostEnrolled(testCtx(), cobo.Host.ID) + require.NoError(t, err) + require.True(t, didEnroll) + coboMDM, err = ds.GetHostMDM(testCtx(), cobo.Host.ID) + require.NoError(t, err) + require.True(t, coboMDM.Enrolled) + require.False(t, coboMDM.IsPersonalEnrollment, "COBO recovery must not reclassify the host as personal") +} + +func testAndroidPubSubDedupState(t *testing.T, ds *Datastore) { + esid := "enterprise-" + uuid.NewString() + res, err := ds.NewAndroidHost(testCtx(), createAndroidHost(esid), false) + require.NoError(t, err) + hostID := res.Host.ID + + // Fresh host: no recorded state. + messageID, eventTime, err := ds.GetAndroidPubSubDedupState(testCtx(), hostID) + require.NoError(t, err) + require.Empty(t, messageID) + require.Nil(t, eventTime) + + // Record a messageId + event time. + t1 := time.Now().UTC().Truncate(time.Microsecond) + require.NoError(t, ds.SetAndroidPubSubDedupState(testCtx(), hostID, "msg-1", &t1)) + + messageID, eventTime, err = ds.GetAndroidPubSubDedupState(testCtx(), hostID) + require.NoError(t, err) + require.Equal(t, "msg-1", messageID) + require.NotNil(t, eventTime) + require.WithinDuration(t, t1, *eventTime, time.Millisecond) + + // Overwrite with a newer message. + t2 := t1.Add(time.Hour) + require.NoError(t, ds.SetAndroidPubSubDedupState(testCtx(), hostID, "msg-2", &t2)) + messageID, eventTime, err = ds.GetAndroidPubSubDedupState(testCtx(), hostID) + require.NoError(t, err) + require.Equal(t, "msg-2", messageID) + require.WithinDuration(t, t2, *eventTime, time.Millisecond) + + // A nil event time records the messageId but preserves the timestamp baseline. Clearing + // it to NULL would disable staleness protection for the host until some later message + // happened to carry a parseable timestamp. + require.NoError(t, ds.SetAndroidPubSubDedupState(testCtx(), hostID, "msg-3", nil)) + messageID, eventTime, err = ds.GetAndroidPubSubDedupState(testCtx(), hostID) + require.NoError(t, err) + require.Equal(t, "msg-3", messageID) + require.NotNil(t, eventTime, "a nil event time must not clear the recorded baseline") + require.WithinDuration(t, t2, *eventTime, time.Millisecond) + + // An empty messageId advances only the timestamp — this is how ReconcileAndroidDevices + // records an out-of-band unenroll, which has no Pub/Sub message of its own. + t3 := t2.Add(time.Hour) + require.NoError(t, ds.SetAndroidPubSubDedupState(testCtx(), hostID, "", &t3)) + messageID, eventTime, err = ds.GetAndroidPubSubDedupState(testCtx(), hostID) + require.NoError(t, err) + require.Equal(t, "msg-3", messageID, "an empty messageId must not clear the recorded messageId") + require.WithinDuration(t, t3, *eventTime, time.Millisecond) + + // Writing the same values again still reports the row as found (clientFoundRows), so it + // must not be mistaken for a missing android_devices row. + require.NoError(t, ds.SetAndroidPubSubDedupState(testCtx(), hostID, "msg-3", &t3)) + + // Unknown host -> NotFound (both get and set). + _, _, err = ds.GetAndroidPubSubDedupState(testCtx(), 999999) + require.True(t, fleet.IsNotFound(err), "expected NotFound for unknown host, got %v", err) + + err = ds.SetAndroidPubSubDedupState(testCtx(), 999999, "msg-x", &t2) + require.True(t, fleet.IsNotFound(err), "set on a missing android_devices row must surface NotFound, got %v", err) +} + func testSetAndroidHostUnenrolled(t *testing.T, ds *Datastore) { // Set a non-empty server URL so initial enrolled row has data to clear appCfg, err := ds.AppConfig(testCtx()) diff --git a/server/datastore/mysql/migrations/tables/20260806210232_AddPubSubDedupToAndroidDevices.go b/server/datastore/mysql/migrations/tables/20260806210232_AddPubSubDedupToAndroidDevices.go new file mode 100644 index 0000000000..b699a7d9b2 --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20260806210232_AddPubSubDedupToAndroidDevices.go @@ -0,0 +1,27 @@ +package tables + +import ( + "database/sql" + "fmt" +) + +func init() { + MigrationClient.AddMigration(Up_20260806210232, Down_20260806210232) +} + +func Up_20260806210232(tx *sql.Tx) error { + // Track the last-processed Google Pub/Sub message per Android device so the + // AMAPI notification handler can deduplicate at-least-once redeliveries + // (same messageId) and drop out-of-order deliveries (older event timestamp). + if _, err := tx.Exec(`ALTER TABLE android_devices + ADD COLUMN last_pubsub_message_id VARCHAR(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + ADD COLUMN last_pubsub_event_time TIMESTAMP(6) NULL DEFAULT NULL`); err != nil { + return fmt.Errorf("add pubsub dedup columns to android_devices: %w", err) + } + + return nil +} + +func Down_20260806210232(tx *sql.Tx) error { + return nil +} diff --git a/server/datastore/mysql/migrations/tables/20260806210232_AddPubSubDedupToAndroidDevices_test.go b/server/datastore/mysql/migrations/tables/20260806210232_AddPubSubDedupToAndroidDevices_test.go new file mode 100644 index 0000000000..41970b8b47 --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20260806210232_AddPubSubDedupToAndroidDevices_test.go @@ -0,0 +1,47 @@ +package tables + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestUp_20260806210232(t *testing.T) { + db := applyUpToPrev(t) + + // Create a host and its android_devices row before the migration. + res, err := db.Exec(`INSERT INTO hosts (hostname, uuid, platform, team_id, osquery_host_id, node_key, + detail_updated_at, label_updated_at, policy_updated_at) + VALUES ('android1', 'uuid1', 'android', NULL, 'oq1', 'nk1', '2026-01-01', '2026-01-01', '2026-01-01')`) + require.NoError(t, err) + hostID, err := res.LastInsertId() + require.NoError(t, err) + + _, err = db.Exec(`INSERT INTO android_devices (host_id, device_id, enterprise_specific_id) VALUES (?, 'd1', 'esid1')`, hostID) + require.NoError(t, err) + + // Apply migration. + applyNext(t, db) + + // Existing rows have NULL for both new columns. + var messageID *string + require.NoError(t, db.Get(&messageID, `SELECT last_pubsub_message_id FROM android_devices WHERE device_id = 'd1'`)) + require.Nil(t, messageID) + + var eventTime *string + require.NoError(t, db.Get(&eventTime, `SELECT last_pubsub_event_time FROM android_devices WHERE device_id = 'd1'`)) + require.Nil(t, eventTime) + + // The columns are writable and round-trip. + _, err = db.Exec(`UPDATE android_devices + SET last_pubsub_message_id = 'msg-123', last_pubsub_event_time = '2026-07-22 10:00:00.000000' + WHERE device_id = 'd1'`) + require.NoError(t, err) + + require.NoError(t, db.Get(&messageID, `SELECT last_pubsub_message_id FROM android_devices WHERE device_id = 'd1'`)) + require.NotNil(t, messageID) + require.Equal(t, "msg-123", *messageID) + + require.NoError(t, db.Get(&eventTime, `SELECT last_pubsub_event_time FROM android_devices WHERE device_id = 'd1'`)) + require.NotNil(t, eventTime) +} diff --git a/server/datastore/mysql/schema.sql b/server/datastore/mysql/schema.sql index 68cd4be4c2..eb8385fbcb 100644 --- a/server/datastore/mysql/schema.sql +++ b/server/datastore/mysql/schema.sql @@ -183,6 +183,8 @@ CREATE TABLE `android_devices` ( `applied_policy_id` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, `applied_policy_version` int DEFAULT NULL, `team_id` int unsigned DEFAULT NULL, + `last_pubsub_message_id` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `last_pubsub_event_time` timestamp(6) NULL DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `idx_android_devices_host_id` (`host_id`), UNIQUE KEY `idx_android_devices_device_id` (`device_id`), @@ -2254,9 +2256,9 @@ CREATE TABLE `migration_status_tables` ( `is_applied` tinyint(1) NOT NULL, `tstamp` timestamp NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`) -) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB AUTO_INCREMENT=584 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB AUTO_INCREMENT=585 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; -INSERT INTO `migration_status_tables` VALUES (1,0,1,'2020-01-01 01:01:01'),(2,20161118193812,1,'2020-01-01 01:01:01'),(3,20161118211713,1,'2020-01-01 01:01:01'),(4,20161118212436,1,'2020-01-01 01:01:01'),(5,20161118212515,1,'2020-01-01 01:01:01'),(6,20161118212528,1,'2020-01-01 01:01:01'),(7,20161118212538,1,'2020-01-01 01:01:01'),(8,20161118212549,1,'2020-01-01 01:01:01'),(9,20161118212557,1,'2020-01-01 01:01:01'),(10,20161118212604,1,'2020-01-01 01:01:01'),(11,20161118212613,1,'2020-01-01 01:01:01'),(12,20161118212621,1,'2020-01-01 01:01:01'),(13,20161118212630,1,'2020-01-01 01:01:01'),(14,20161118212641,1,'2020-01-01 01:01:01'),(15,20161118212649,1,'2020-01-01 01:01:01'),(16,20161118212656,1,'2020-01-01 01:01:01'),(17,20161118212758,1,'2020-01-01 01:01:01'),(18,20161128234849,1,'2020-01-01 01:01:01'),(19,20161230162221,1,'2020-01-01 01:01:01'),(20,20170104113816,1,'2020-01-01 01:01:01'),(21,20170105151732,1,'2020-01-01 01:01:01'),(22,20170108191242,1,'2020-01-01 01:01:01'),(23,20170109094020,1,'2020-01-01 01:01:01'),(24,20170109130438,1,'2020-01-01 01:01:01'),(25,20170110202752,1,'2020-01-01 01:01:01'),(26,20170111133013,1,'2020-01-01 01:01:01'),(27,20170117025759,1,'2020-01-01 01:01:01'),(28,20170118191001,1,'2020-01-01 01:01:01'),(29,20170119234632,1,'2020-01-01 01:01:01'),(30,20170124230432,1,'2020-01-01 01:01:01'),(31,20170127014618,1,'2020-01-01 01:01:01'),(32,20170131232841,1,'2020-01-01 01:01:01'),(33,20170223094154,1,'2020-01-01 01:01:01'),(34,20170306075207,1,'2020-01-01 01:01:01'),(35,20170309100733,1,'2020-01-01 01:01:01'),(36,20170331111922,1,'2020-01-01 01:01:01'),(37,20170502143928,1,'2020-01-01 01:01:01'),(38,20170504130602,1,'2020-01-01 01:01:01'),(39,20170509132100,1,'2020-01-01 01:01:01'),(40,20170519105647,1,'2020-01-01 01:01:01'),(41,20170519105648,1,'2020-01-01 01:01:01'),(42,20170831234300,1,'2020-01-01 01:01:01'),(43,20170831234301,1,'2020-01-01 01:01:01'),(44,20170831234303,1,'2020-01-01 01:01:01'),(45,20171116163618,1,'2020-01-01 01:01:01'),(46,20171219164727,1,'2020-01-01 01:01:01'),(47,20180620164811,1,'2020-01-01 01:01:01'),(48,20180620175054,1,'2020-01-01 01:01:01'),(49,20180620175055,1,'2020-01-01 01:01:01'),(50,20191010101639,1,'2020-01-01 01:01:01'),(51,20191010155147,1,'2020-01-01 01:01:01'),(52,20191220130734,1,'2020-01-01 01:01:01'),(53,20200311140000,1,'2020-01-01 01:01:01'),(54,20200405120000,1,'2020-01-01 01:01:01'),(55,20200407120000,1,'2020-01-01 01:01:01'),(56,20200420120000,1,'2020-01-01 01:01:01'),(57,20200504120000,1,'2020-01-01 01:01:01'),(58,20200512120000,1,'2020-01-01 01:01:01'),(59,20200707120000,1,'2020-01-01 01:01:01'),(60,20201011162341,1,'2020-01-01 01:01:01'),(61,20201021104586,1,'2020-01-01 01:01:01'),(62,20201102112520,1,'2020-01-01 01:01:01'),(63,20201208121729,1,'2020-01-01 01:01:01'),(64,20201215091637,1,'2020-01-01 01:01:01'),(65,20210119174155,1,'2020-01-01 01:01:01'),(66,20210326182902,1,'2020-01-01 01:01:01'),(67,20210421112652,1,'2020-01-01 01:01:01'),(68,20210506095025,1,'2020-01-01 01:01:01'),(69,20210513115729,1,'2020-01-01 01:01:01'),(70,20210526113559,1,'2020-01-01 01:01:01'),(71,20210601000001,1,'2020-01-01 01:01:01'),(72,20210601000002,1,'2020-01-01 01:01:01'),(73,20210601000003,1,'2020-01-01 01:01:01'),(74,20210601000004,1,'2020-01-01 01:01:01'),(75,20210601000005,1,'2020-01-01 01:01:01'),(76,20210601000006,1,'2020-01-01 01:01:01'),(77,20210601000007,1,'2020-01-01 01:01:01'),(78,20210601000008,1,'2020-01-01 01:01:01'),(79,20210606151329,1,'2020-01-01 01:01:01'),(80,20210616163757,1,'2020-01-01 01:01:01'),(81,20210617174723,1,'2020-01-01 01:01:01'),(82,20210622160235,1,'2020-01-01 01:01:01'),(83,20210623100031,1,'2020-01-01 01:01:01'),(84,20210623133615,1,'2020-01-01 01:01:01'),(85,20210708143152,1,'2020-01-01 01:01:01'),(86,20210709124443,1,'2020-01-01 01:01:01'),(87,20210712155608,1,'2020-01-01 01:01:01'),(88,20210714102108,1,'2020-01-01 01:01:01'),(89,20210719153709,1,'2020-01-01 01:01:01'),(90,20210721171531,1,'2020-01-01 01:01:01'),(91,20210723135713,1,'2020-01-01 01:01:01'),(92,20210802135933,1,'2020-01-01 01:01:01'),(93,20210806112844,1,'2020-01-01 01:01:01'),(94,20210810095603,1,'2020-01-01 01:01:01'),(95,20210811150223,1,'2020-01-01 01:01:01'),(96,20210818151827,1,'2020-01-01 01:01:01'),(97,20210818151828,1,'2020-01-01 01:01:01'),(98,20210818182258,1,'2020-01-01 01:01:01'),(99,20210819131107,1,'2020-01-01 01:01:01'),(100,20210819143446,1,'2020-01-01 01:01:01'),(101,20210903132338,1,'2020-01-01 01:01:01'),(102,20210915144307,1,'2020-01-01 01:01:01'),(103,20210920155130,1,'2020-01-01 01:01:01'),(104,20210927143115,1,'2020-01-01 01:01:01'),(105,20210927143116,1,'2020-01-01 01:01:01'),(106,20211013133706,1,'2020-01-01 01:01:01'),(107,20211013133707,1,'2020-01-01 01:01:01'),(108,20211102135149,1,'2020-01-01 01:01:01'),(109,20211109121546,1,'2020-01-01 01:01:01'),(110,20211110163320,1,'2020-01-01 01:01:01'),(111,20211116184029,1,'2020-01-01 01:01:01'),(112,20211116184030,1,'2020-01-01 01:01:01'),(113,20211202092042,1,'2020-01-01 01:01:01'),(114,20211202181033,1,'2020-01-01 01:01:01'),(115,20211207161856,1,'2020-01-01 01:01:01'),(116,20211216131203,1,'2020-01-01 01:01:01'),(117,20211221110132,1,'2020-01-01 01:01:01'),(118,20220107155700,1,'2020-01-01 01:01:01'),(119,20220125105650,1,'2020-01-01 01:01:01'),(120,20220201084510,1,'2020-01-01 01:01:01'),(121,20220208144830,1,'2020-01-01 01:01:01'),(122,20220208144831,1,'2020-01-01 01:01:01'),(123,20220215152203,1,'2020-01-01 01:01:01'),(124,20220223113157,1,'2020-01-01 01:01:01'),(125,20220307104655,1,'2020-01-01 01:01:01'),(126,20220309133956,1,'2020-01-01 01:01:01'),(127,20220316155700,1,'2020-01-01 01:01:01'),(128,20220323152301,1,'2020-01-01 01:01:01'),(129,20220330100659,1,'2020-01-01 01:01:01'),(130,20220404091216,1,'2020-01-01 01:01:01'),(131,20220419140750,1,'2020-01-01 01:01:01'),(132,20220428140039,1,'2020-01-01 01:01:01'),(133,20220503134048,1,'2020-01-01 01:01:01'),(134,20220524102918,1,'2020-01-01 01:01:01'),(135,20220526123327,1,'2020-01-01 01:01:01'),(136,20220526123328,1,'2020-01-01 01:01:01'),(137,20220526123329,1,'2020-01-01 01:01:01'),(138,20220608113128,1,'2020-01-01 01:01:01'),(139,20220627104817,1,'2020-01-01 01:01:01'),(140,20220704101843,1,'2020-01-01 01:01:01'),(141,20220708095046,1,'2020-01-01 01:01:01'),(142,20220713091130,1,'2020-01-01 01:01:01'),(143,20220802135510,1,'2020-01-01 01:01:01'),(144,20220818101352,1,'2020-01-01 01:01:01'),(145,20220822161445,1,'2020-01-01 01:01:01'),(146,20220831100036,1,'2020-01-01 01:01:01'),(147,20220831100151,1,'2020-01-01 01:01:01'),(148,20220908181826,1,'2020-01-01 01:01:01'),(149,20220914154915,1,'2020-01-01 01:01:01'),(150,20220915165115,1,'2020-01-01 01:01:01'),(151,20220915165116,1,'2020-01-01 01:01:01'),(152,20220928100158,1,'2020-01-01 01:01:01'),(153,20221014084130,1,'2020-01-01 01:01:01'),(154,20221027085019,1,'2020-01-01 01:01:01'),(155,20221101103952,1,'2020-01-01 01:01:01'),(156,20221104144401,1,'2020-01-01 01:01:01'),(157,20221109100749,1,'2020-01-01 01:01:01'),(158,20221115104546,1,'2020-01-01 01:01:01'),(159,20221130114928,1,'2020-01-01 01:01:01'),(160,20221205112142,1,'2020-01-01 01:01:01'),(161,20221216115820,1,'2020-01-01 01:01:01'),(162,20221220195934,1,'2020-01-01 01:01:01'),(163,20221220195935,1,'2020-01-01 01:01:01'),(164,20221223174807,1,'2020-01-01 01:01:01'),(165,20221227163855,1,'2020-01-01 01:01:01'),(166,20221227163856,1,'2020-01-01 01:01:01'),(167,20230202224725,1,'2020-01-01 01:01:01'),(168,20230206163608,1,'2020-01-01 01:01:01'),(169,20230214131519,1,'2020-01-01 01:01:01'),(170,20230303135738,1,'2020-01-01 01:01:01'),(171,20230313135301,1,'2020-01-01 01:01:01'),(172,20230313141819,1,'2020-01-01 01:01:01'),(173,20230315104937,1,'2020-01-01 01:01:01'),(174,20230317173844,1,'2020-01-01 01:01:01'),(175,20230320133602,1,'2020-01-01 01:01:01'),(176,20230330100011,1,'2020-01-01 01:01:01'),(177,20230330134823,1,'2020-01-01 01:01:01'),(178,20230405232025,1,'2020-01-01 01:01:01'),(179,20230408084104,1,'2020-01-01 01:01:01'),(180,20230411102858,1,'2020-01-01 01:01:01'),(181,20230421155932,1,'2020-01-01 01:01:01'),(182,20230425082126,1,'2020-01-01 01:01:01'),(183,20230425105727,1,'2020-01-01 01:01:01'),(184,20230501154913,1,'2020-01-01 01:01:01'),(185,20230503101418,1,'2020-01-01 01:01:01'),(186,20230515144206,1,'2020-01-01 01:01:01'),(187,20230517140952,1,'2020-01-01 01:01:01'),(188,20230517152807,1,'2020-01-01 01:01:01'),(189,20230518114155,1,'2020-01-01 01:01:01'),(190,20230520153236,1,'2020-01-01 01:01:01'),(191,20230525151159,1,'2020-01-01 01:01:01'),(192,20230530122103,1,'2020-01-01 01:01:01'),(193,20230602111827,1,'2020-01-01 01:01:01'),(194,20230608103123,1,'2020-01-01 01:01:01'),(195,20230629140529,1,'2020-01-01 01:01:01'),(196,20230629140530,1,'2020-01-01 01:01:01'),(197,20230711144622,1,'2020-01-01 01:01:01'),(198,20230721135421,1,'2020-01-01 01:01:01'),(199,20230721161508,1,'2020-01-01 01:01:01'),(200,20230726115701,1,'2020-01-01 01:01:01'),(201,20230807100822,1,'2020-01-01 01:01:01'),(202,20230814150442,1,'2020-01-01 01:01:01'),(203,20230823122728,1,'2020-01-01 01:01:01'),(204,20230906152143,1,'2020-01-01 01:01:01'),(205,20230911163618,1,'2020-01-01 01:01:01'),(206,20230912101759,1,'2020-01-01 01:01:01'),(207,20230915101341,1,'2020-01-01 01:01:01'),(208,20230918132351,1,'2020-01-01 01:01:01'),(209,20231004144339,1,'2020-01-01 01:01:01'),(210,20231009094541,1,'2020-01-01 01:01:01'),(211,20231009094542,1,'2020-01-01 01:01:01'),(212,20231009094543,1,'2020-01-01 01:01:01'),(213,20231009094544,1,'2020-01-01 01:01:01'),(214,20231016091915,1,'2020-01-01 01:01:01'),(215,20231024174135,1,'2020-01-01 01:01:01'),(216,20231025120016,1,'2020-01-01 01:01:01'),(217,20231025160156,1,'2020-01-01 01:01:01'),(218,20231031165350,1,'2020-01-01 01:01:01'),(219,20231106144110,1,'2020-01-01 01:01:01'),(220,20231107130934,1,'2020-01-01 01:01:01'),(221,20231109115838,1,'2020-01-01 01:01:01'),(222,20231121054530,1,'2020-01-01 01:01:01'),(223,20231122101320,1,'2020-01-01 01:01:01'),(224,20231130132828,1,'2020-01-01 01:01:01'),(225,20231130132931,1,'2020-01-01 01:01:01'),(226,20231204155427,1,'2020-01-01 01:01:01'),(227,20231206142340,1,'2020-01-01 01:01:01'),(228,20231207102320,1,'2020-01-01 01:01:01'),(229,20231207102321,1,'2020-01-01 01:01:01'),(230,20231207133731,1,'2020-01-01 01:01:01'),(231,20231212094238,1,'2020-01-01 01:01:01'),(232,20231212095734,1,'2020-01-01 01:01:01'),(233,20231212161121,1,'2020-01-01 01:01:01'),(234,20231215122713,1,'2020-01-01 01:01:01'),(235,20231219143041,1,'2020-01-01 01:01:01'),(236,20231224070653,1,'2020-01-01 01:01:01'),(237,20240110134315,1,'2020-01-01 01:01:01'),(238,20240119091637,1,'2020-01-01 01:01:01'),(239,20240126020642,1,'2020-01-01 01:01:01'),(240,20240126020643,1,'2020-01-01 01:01:01'),(241,20240129162819,1,'2020-01-01 01:01:01'),(242,20240130115133,1,'2020-01-01 01:01:01'),(243,20240131083822,1,'2020-01-01 01:01:01'),(244,20240205095928,1,'2020-01-01 01:01:01'),(245,20240205121956,1,'2020-01-01 01:01:01'),(246,20240209110212,1,'2020-01-01 01:01:01'),(247,20240212111533,1,'2020-01-01 01:01:01'),(248,20240221112844,1,'2020-01-01 01:01:01'),(249,20240222073518,1,'2020-01-01 01:01:01'),(250,20240222135115,1,'2020-01-01 01:01:01'),(251,20240226082255,1,'2020-01-01 01:01:01'),(252,20240228082706,1,'2020-01-01 01:01:01'),(253,20240301173035,1,'2020-01-01 01:01:01'),(254,20240302111134,1,'2020-01-01 01:01:01'),(255,20240312103753,1,'2020-01-01 01:01:01'),(256,20240313143416,1,'2020-01-01 01:01:01'),(257,20240314085226,1,'2020-01-01 01:01:01'),(258,20240314151747,1,'2020-01-01 01:01:01'),(259,20240320145650,1,'2020-01-01 01:01:01'),(260,20240327115530,1,'2020-01-01 01:01:01'),(261,20240327115617,1,'2020-01-01 01:01:01'),(262,20240408085837,1,'2020-01-01 01:01:01'),(263,20240415104633,1,'2020-01-01 01:01:01'),(264,20240430111727,1,'2020-01-01 01:01:01'),(265,20240515200020,1,'2020-01-01 01:01:01'),(266,20240521143023,1,'2020-01-01 01:01:01'),(267,20240521143024,1,'2020-01-01 01:01:01'),(268,20240601174138,1,'2020-01-01 01:01:01'),(269,20240607133721,1,'2020-01-01 01:01:01'),(270,20240612150059,1,'2020-01-01 01:01:01'),(271,20240613162201,1,'2020-01-01 01:01:01'),(272,20240613172616,1,'2020-01-01 01:01:01'),(273,20240618142419,1,'2020-01-01 01:01:01'),(274,20240625093543,1,'2020-01-01 01:01:01'),(275,20240626195531,1,'2020-01-01 01:01:01'),(276,20240702123921,1,'2020-01-01 01:01:01'),(277,20240703154849,1,'2020-01-01 01:01:01'),(278,20240707134035,1,'2020-01-01 01:01:01'),(279,20240707134036,1,'2020-01-01 01:01:01'),(280,20240709124958,1,'2020-01-01 01:01:01'),(281,20240709132642,1,'2020-01-01 01:01:01'),(282,20240709183940,1,'2020-01-01 01:01:01'),(283,20240710155623,1,'2020-01-01 01:01:01'),(284,20240723102712,1,'2020-01-01 01:01:01'),(285,20240725152735,1,'2020-01-01 01:01:01'),(286,20240725182118,1,'2020-01-01 01:01:01'),(287,20240726100517,1,'2020-01-01 01:01:01'),(288,20240730171504,1,'2020-01-01 01:01:01'),(289,20240730174056,1,'2020-01-01 01:01:01'),(290,20240730215453,1,'2020-01-01 01:01:01'),(291,20240730374423,1,'2020-01-01 01:01:01'),(292,20240801115359,1,'2020-01-01 01:01:01'),(293,20240802101043,1,'2020-01-01 01:01:01'),(294,20240802113716,1,'2020-01-01 01:01:01'),(295,20240814135330,1,'2020-01-01 01:01:01'),(296,20240815000000,1,'2020-01-01 01:01:01'),(297,20240815000001,1,'2020-01-01 01:01:01'),(298,20240816103247,1,'2020-01-01 01:01:01'),(299,20240820091218,1,'2020-01-01 01:01:01'),(300,20240826111228,1,'2020-01-01 01:01:01'),(301,20240826160025,1,'2020-01-01 01:01:01'),(302,20240829165448,1,'2020-01-01 01:01:01'),(303,20240829165605,1,'2020-01-01 01:01:01'),(304,20240829165715,1,'2020-01-01 01:01:01'),(305,20240829165930,1,'2020-01-01 01:01:01'),(306,20240829170023,1,'2020-01-01 01:01:01'),(307,20240829170033,1,'2020-01-01 01:01:01'),(308,20240829170044,1,'2020-01-01 01:01:01'),(309,20240905105135,1,'2020-01-01 01:01:01'),(310,20240905140514,1,'2020-01-01 01:01:01'),(311,20240905200000,1,'2020-01-01 01:01:01'),(312,20240905200001,1,'2020-01-01 01:01:01'),(313,20241002104104,1,'2020-01-01 01:01:01'),(314,20241002104105,1,'2020-01-01 01:01:01'),(315,20241002104106,1,'2020-01-01 01:01:01'),(316,20241002210000,1,'2020-01-01 01:01:01'),(317,20241003145349,1,'2020-01-01 01:01:01'),(318,20241004005000,1,'2020-01-01 01:01:01'),(319,20241008083925,1,'2020-01-01 01:01:01'),(320,20241009090010,1,'2020-01-01 01:01:01'),(321,20241017163402,1,'2020-01-01 01:01:01'),(322,20241021224359,1,'2020-01-01 01:01:01'),(323,20241022140321,1,'2020-01-01 01:01:01'),(324,20241025111236,1,'2020-01-01 01:01:01'),(325,20241025112748,1,'2020-01-01 01:01:01'),(326,20241025141855,1,'2020-01-01 01:01:01'),(327,20241110152839,1,'2020-01-01 01:01:01'),(328,20241110152840,1,'2020-01-01 01:01:01'),(329,20241110152841,1,'2020-01-01 01:01:01'),(330,20241116233322,1,'2020-01-01 01:01:01'),(331,20241122171434,1,'2020-01-01 01:01:01'),(332,20241125150614,1,'2020-01-01 01:01:01'),(333,20241203125346,1,'2020-01-01 01:01:01'),(334,20241203130032,1,'2020-01-01 01:01:01'),(335,20241205122800,1,'2020-01-01 01:01:01'),(336,20241209164540,1,'2020-01-01 01:01:01'),(337,20241210140021,1,'2020-01-01 01:01:01'),(338,20241219180042,1,'2020-01-01 01:01:01'),(339,20241220100000,1,'2020-01-01 01:01:01'),(340,20241220114903,1,'2020-01-01 01:01:01'),(341,20241220114904,1,'2020-01-01 01:01:01'),(342,20241224000000,1,'2020-01-01 01:01:01'),(343,20241230000000,1,'2020-01-01 01:01:01'),(344,20241231112624,1,'2020-01-01 01:01:01'),(345,20250102121439,1,'2020-01-01 01:01:01'),(346,20250121094045,1,'2020-01-01 01:01:01'),(347,20250121094500,1,'2020-01-01 01:01:01'),(348,20250121094600,1,'2020-01-01 01:01:01'),(349,20250121094700,1,'2020-01-01 01:01:01'),(350,20250124194347,1,'2020-01-01 01:01:01'),(351,20250127162751,1,'2020-01-01 01:01:01'),(352,20250213104005,1,'2020-01-01 01:01:01'),(353,20250214205657,1,'2020-01-01 01:01:01'),(354,20250217093329,1,'2020-01-01 01:01:01'),(355,20250219090511,1,'2020-01-01 01:01:01'),(356,20250219100000,1,'2020-01-01 01:01:01'),(357,20250219142401,1,'2020-01-01 01:01:01'),(358,20250224184002,1,'2020-01-01 01:01:01'),(359,20250225085436,1,'2020-01-01 01:01:01'),(360,20250226000000,1,'2020-01-01 01:01:01'),(361,20250226153445,1,'2020-01-01 01:01:01'),(362,20250304162702,1,'2020-01-01 01:01:01'),(363,20250306144233,1,'2020-01-01 01:01:01'),(364,20250313163430,1,'2020-01-01 01:01:01'),(365,20250317130944,1,'2020-01-01 01:01:01'),(366,20250318165922,1,'2020-01-01 01:01:01'),(367,20250320132525,1,'2020-01-01 01:01:01'),(368,20250320200000,1,'2020-01-01 01:01:01'),(369,20250326161930,1,'2020-01-01 01:01:01'),(370,20250326161931,1,'2020-01-01 01:01:01'),(371,20250331042354,1,'2020-01-01 01:01:01'),(372,20250331154206,1,'2020-01-01 01:01:01'),(373,20250401155831,1,'2020-01-01 01:01:01'),(374,20250408133233,1,'2020-01-01 01:01:01'),(375,20250410104321,1,'2020-01-01 01:01:01'),(376,20250421085116,1,'2020-01-01 01:01:01'),(377,20250422095806,1,'2020-01-01 01:01:01'),(378,20250424153059,1,'2020-01-01 01:01:01'),(379,20250430103833,1,'2020-01-01 01:01:01'),(380,20250430112622,1,'2020-01-01 01:01:01'),(381,20250501162727,1,'2020-01-01 01:01:01'),(382,20250502154517,1,'2020-01-01 01:01:01'),(383,20250502222222,1,'2020-01-01 01:01:01'),(384,20250507170845,1,'2020-01-01 01:01:01'),(385,20250513162912,1,'2020-01-01 01:01:01'),(386,20250519161614,1,'2020-01-01 01:01:01'),(387,20250519170000,1,'2020-01-01 01:01:01'),(388,20250520153848,1,'2020-01-01 01:01:01'),(389,20250528115932,1,'2020-01-01 01:01:01'),(390,20250529102706,1,'2020-01-01 01:01:01'),(391,20250603105558,1,'2020-01-01 01:01:01'),(392,20250609102714,1,'2020-01-01 01:01:01'),(393,20250609112613,1,'2020-01-01 01:01:01'),(394,20250613103810,1,'2020-01-01 01:01:01'),(395,20250616193950,1,'2020-01-01 01:01:01'),(396,20250624140757,1,'2020-01-01 01:01:01'),(397,20250626130239,1,'2020-01-01 01:01:01'),(398,20250629131032,1,'2020-01-01 01:01:01'),(399,20250701155654,1,'2020-01-01 01:01:01'),(400,20250707095725,1,'2020-01-01 01:01:01'),(401,20250716152435,1,'2020-01-01 01:01:01'),(402,20250718091828,1,'2020-01-01 01:01:01'),(403,20250728122229,1,'2020-01-01 01:01:01'),(404,20250731122715,1,'2020-01-01 01:01:01'),(405,20250731151000,1,'2020-01-01 01:01:01'),(406,20250803000000,1,'2020-01-01 01:01:01'),(407,20250805083116,1,'2020-01-01 01:01:01'),(408,20250807140441,1,'2020-01-01 01:01:01'),(409,20250808000000,1,'2020-01-01 01:01:01'),(410,20250811155036,1,'2020-01-01 01:01:01'),(411,20250813205039,1,'2020-01-01 01:01:01'),(412,20250814123333,1,'2020-01-01 01:01:01'),(413,20250815130115,1,'2020-01-01 01:01:01'),(414,20250816115553,1,'2020-01-01 01:01:01'),(415,20250817154557,1,'2020-01-01 01:01:01'),(416,20250825113751,1,'2020-01-01 01:01:01'),(417,20250827113140,1,'2020-01-01 01:01:01'),(418,20250828120836,1,'2020-01-01 01:01:01'),(419,20250902112642,1,'2020-01-01 01:01:01'),(420,20250904091745,1,'2020-01-01 01:01:01'),(421,20250905090000,1,'2020-01-01 01:01:01'),(422,20250922083056,1,'2020-01-01 01:01:01'),(423,20250923120000,1,'2020-01-01 01:01:01'),(424,20250926123048,1,'2020-01-01 01:01:01'),(425,20251015103505,1,'2020-01-01 01:01:01'),(426,20251015103600,1,'2020-01-01 01:01:01'),(427,20251015103700,1,'2020-01-01 01:01:01'),(428,20251015103800,1,'2020-01-01 01:01:01'),(429,20251015103900,1,'2020-01-01 01:01:01'),(430,20251028140000,1,'2020-01-01 01:01:01'),(431,20251028140100,1,'2020-01-01 01:01:01'),(432,20251028140110,1,'2020-01-01 01:01:01'),(433,20251028140200,1,'2020-01-01 01:01:01'),(434,20251028140300,1,'2020-01-01 01:01:01'),(435,20251028140400,1,'2020-01-01 01:01:01'),(436,20251031154558,1,'2020-01-01 01:01:01'),(437,20251103160848,1,'2020-01-01 01:01:01'),(438,20251104112849,1,'2020-01-01 01:01:01'),(439,20251106000000,1,'2020-01-01 01:01:01'),(440,20251107164629,1,'2020-01-01 01:01:01'),(441,20251107170854,1,'2020-01-01 01:01:01'),(442,20251110172137,1,'2020-01-01 01:01:01'),(443,20251111153133,1,'2020-01-01 01:01:01'),(444,20251117020000,1,'2020-01-01 01:01:01'),(445,20251117020100,1,'2020-01-01 01:01:01'),(446,20251117020200,1,'2020-01-01 01:01:01'),(447,20251121100000,1,'2020-01-01 01:01:01'),(448,20251121124239,1,'2020-01-01 01:01:01'),(449,20251124090450,1,'2020-01-01 01:01:01'),(450,20251124135808,1,'2020-01-01 01:01:01'),(451,20251124140138,1,'2020-01-01 01:01:01'),(452,20251124162948,1,'2020-01-01 01:01:01'),(453,20251127113559,1,'2020-01-01 01:01:01'),(454,20251202162232,1,'2020-01-01 01:01:01'),(455,20251203170808,1,'2020-01-01 01:01:01'),(456,20251207050413,1,'2020-01-01 01:01:01'),(457,20251208215800,1,'2020-01-01 01:01:01'),(458,20251209221730,1,'2020-01-01 01:01:01'),(459,20251209221850,1,'2020-01-01 01:01:01'),(460,20251215163721,1,'2020-01-01 01:01:01'),(461,20251217000000,1,'2020-01-01 01:01:01'),(462,20251217120000,1,'2020-01-01 01:01:01'),(463,20251229000000,1,'2020-01-01 01:01:01'),(464,20251229000010,1,'2020-01-01 01:01:01'),(465,20251229000020,1,'2020-01-01 01:01:01'),(466,20260106000000,1,'2020-01-01 01:01:01'),(467,20260108200708,1,'2020-01-01 01:01:01'),(468,20260108214732,1,'2020-01-01 01:01:01'),(469,20260109231821,1,'2020-01-01 01:01:01'),(470,20260113012054,1,'2020-01-01 01:01:01'),(471,20260124200020,1,'2020-01-01 01:01:01'),(472,20260126150840,1,'2020-01-01 01:01:01'),(473,20260126210724,1,'2020-01-01 01:01:01'),(474,20260202151756,1,'2020-01-01 01:01:01'),(475,20260205184907,1,'2020-01-01 01:01:01'),(476,20260210151544,1,'2020-01-01 01:01:01'),(477,20260210155109,1,'2020-01-01 01:01:01'),(478,20260210181120,1,'2020-01-01 01:01:01'),(479,20260211200153,1,'2020-01-01 01:01:01'),(480,20260217141240,1,'2020-01-01 01:01:01'),(481,20260217200906,1,'2020-01-01 01:01:01'),(482,20260218175704,1,'2020-01-01 01:01:01'),(483,20260314120000,1,'2020-01-01 01:01:01'),(484,20260316120000,1,'2020-01-01 01:01:01'),(485,20260316120001,1,'2020-01-01 01:01:01'),(486,20260316120002,1,'2020-01-01 01:01:01'),(487,20260316120003,1,'2020-01-01 01:01:01'),(488,20260316120004,1,'2020-01-01 01:01:01'),(489,20260316120005,1,'2020-01-01 01:01:01'),(490,20260316120006,1,'2020-01-01 01:01:01'),(491,20260316120007,1,'2020-01-01 01:01:01'),(492,20260316120008,1,'2020-01-01 01:01:01'),(493,20260316120009,1,'2020-01-01 01:01:01'),(494,20260316120010,1,'2020-01-01 01:01:01'),(495,20260317120000,1,'2020-01-01 01:01:01'),(496,20260318184559,1,'2020-01-01 01:01:01'),(497,20260319120000,1,'2020-01-01 01:01:01'),(498,20260323144117,1,'2020-01-01 01:01:01'),(499,20260324161944,1,'2020-01-01 01:01:01'),(500,20260324223334,1,'2020-01-01 01:01:01'),(501,20260326131501,1,'2020-01-01 01:01:01'),(502,20260326210603,1,'2020-01-01 01:01:01'),(503,20260331000000,1,'2020-01-01 01:01:01'),(504,20260401153000,1,'2020-01-01 01:01:01'),(505,20260401153001,1,'2020-01-01 01:01:01'),(506,20260401153503,1,'2020-01-01 01:01:01'),(507,20260403120000,1,'2020-01-01 01:01:01'),(508,20260409153713,1,'2020-01-01 01:01:01'),(509,20260409153714,1,'2020-01-01 01:01:01'),(510,20260409153715,1,'2020-01-01 01:01:01'),(511,20260409153716,1,'2020-01-01 01:01:01'),(512,20260409153717,1,'2020-01-01 01:01:01'),(513,20260409183610,1,'2020-01-01 01:01:01'),(514,20260410173222,1,'2020-01-01 01:01:01'),(515,20260422181702,1,'2020-01-01 01:01:01'),(516,20260423161823,1,'2020-01-01 01:01:01'),(517,20260423161824,1,'2020-01-01 01:01:01'),(518,20260518194422,1,'2020-01-01 01:01:01'),(519,20260522195224,1,'2020-01-01 01:01:01'),(520,20260522195225,1,'2020-01-01 01:01:01'),(521,20260522195226,1,'2020-01-01 01:01:01'),(522,20260522195227,1,'2020-01-01 01:01:01'),(523,20260522195229,1,'2020-01-01 01:01:01'),(524,20260522195230,1,'2020-01-01 01:01:01'),(525,20260522195231,1,'2020-01-01 01:01:01'),(526,20260522195232,1,'2020-01-01 01:01:01'),(527,20260522195233,1,'2020-01-01 01:01:01'),(528,20260522195234,1,'2020-01-01 01:01:01'),(529,20260522195235,1,'2020-01-01 01:01:01'),(530,20260527215817,1,'2020-01-01 01:01:01'),(531,20260527215818,1,'2020-01-01 01:01:01'),(532,20260528201143,1,'2020-01-01 01:01:01'),(533,20260528201150,1,'2020-01-01 01:01:01'),(534,20260528211626,1,'2020-01-01 01:01:01'),(535,20260528213326,1,'2020-01-01 01:01:01'),(536,20260529091823,1,'2020-01-01 01:01:01'),(537,20260529120000,1,'2020-01-01 01:01:01'),(538,20260601200727,1,'2020-01-01 01:01:01'),(539,20260603101320,1,'2020-01-01 01:01:01'),(540,20260603120000,1,'2020-01-01 01:01:01'),(541,20260604221206,1,'2020-01-01 01:01:01'),(542,20260605195941,1,'2020-01-01 01:01:01'),(543,20260606051849,1,'2020-01-01 01:01:01'),(544,20260608160653,1,'2020-01-01 01:01:01'),(545,20260608202705,1,'2020-01-01 01:01:01'),(546,20260608210432,1,'2020-01-01 01:01:01'),(547,20260610172952,1,'2020-01-01 01:01:01'),(548,20260624210253,1,'2020-01-01 01:01:01'),(549,20260624210311,1,'2020-01-01 01:01:01'),(550,20260626120000,1,'2020-01-01 01:01:01'),(551,20260702013055,1,'2020-01-01 01:01:01'),(552,20260702013056,1,'2020-01-01 01:01:01'),(553,20260702013057,1,'2020-01-01 01:01:01'),(554,20260702013058,1,'2020-01-01 01:01:01'),(555,20260702013059,1,'2020-01-01 01:01:01'),(556,20260702013100,1,'2020-01-01 01:01:01'),(557,20260702013101,1,'2020-01-01 01:01:01'),(558,20260702013102,1,'2020-01-01 01:01:01'),(559,20260702164518,1,'2020-01-01 01:01:01'),(560,20260717152653,1,'2020-01-01 01:01:01'),(561,20260721090128,1,'2020-01-01 01:01:01'),(562,20260721160351,1,'2020-01-01 01:01:01'),(563,20260723181401,1,'2020-01-01 01:01:01'),(564,20260723181402,1,'2020-01-01 01:01:01'),(565,20260723181403,1,'2020-01-01 01:01:01'),(566,20260723181404,1,'2020-01-01 01:01:01'),(567,20260723181405,1,'2020-01-01 01:01:01'),(568,20260723181406,1,'2020-01-01 01:01:01'),(569,20260723181407,1,'2020-01-01 01:01:01'),(570,20260723181408,1,'2020-01-01 01:01:01'),(571,20260723181409,1,'2020-01-01 01:01:01'),(572,20260723181410,1,'2020-01-01 01:01:01'),(573,20260723181411,1,'2020-01-01 01:01:01'),(574,20260724134801,1,'2020-01-01 01:01:01'),(575,20260727083533,1,'2020-01-01 01:01:01'),(576,20260727084359,1,'2020-01-01 01:01:01'),(577,20260729110229,1,'2020-01-01 01:01:01'),(578,20260729115013,1,'2020-01-01 01:01:01'),(579,20260731100711,1,'2020-01-01 01:01:01'),(580,20260731213352,1,'2020-01-01 01:01:01'),(581,20260803135530,1,'2020-01-01 01:01:01'),(582,20260803182251,1,'2020-01-01 01:01:01'),(583,20260805161502,1,'2020-01-01 01:01:01'); +INSERT INTO `migration_status_tables` VALUES (1,0,1,'2020-01-01 01:01:01'),(2,20161118193812,1,'2020-01-01 01:01:01'),(3,20161118211713,1,'2020-01-01 01:01:01'),(4,20161118212436,1,'2020-01-01 01:01:01'),(5,20161118212515,1,'2020-01-01 01:01:01'),(6,20161118212528,1,'2020-01-01 01:01:01'),(7,20161118212538,1,'2020-01-01 01:01:01'),(8,20161118212549,1,'2020-01-01 01:01:01'),(9,20161118212557,1,'2020-01-01 01:01:01'),(10,20161118212604,1,'2020-01-01 01:01:01'),(11,20161118212613,1,'2020-01-01 01:01:01'),(12,20161118212621,1,'2020-01-01 01:01:01'),(13,20161118212630,1,'2020-01-01 01:01:01'),(14,20161118212641,1,'2020-01-01 01:01:01'),(15,20161118212649,1,'2020-01-01 01:01:01'),(16,20161118212656,1,'2020-01-01 01:01:01'),(17,20161118212758,1,'2020-01-01 01:01:01'),(18,20161128234849,1,'2020-01-01 01:01:01'),(19,20161230162221,1,'2020-01-01 01:01:01'),(20,20170104113816,1,'2020-01-01 01:01:01'),(21,20170105151732,1,'2020-01-01 01:01:01'),(22,20170108191242,1,'2020-01-01 01:01:01'),(23,20170109094020,1,'2020-01-01 01:01:01'),(24,20170109130438,1,'2020-01-01 01:01:01'),(25,20170110202752,1,'2020-01-01 01:01:01'),(26,20170111133013,1,'2020-01-01 01:01:01'),(27,20170117025759,1,'2020-01-01 01:01:01'),(28,20170118191001,1,'2020-01-01 01:01:01'),(29,20170119234632,1,'2020-01-01 01:01:01'),(30,20170124230432,1,'2020-01-01 01:01:01'),(31,20170127014618,1,'2020-01-01 01:01:01'),(32,20170131232841,1,'2020-01-01 01:01:01'),(33,20170223094154,1,'2020-01-01 01:01:01'),(34,20170306075207,1,'2020-01-01 01:01:01'),(35,20170309100733,1,'2020-01-01 01:01:01'),(36,20170331111922,1,'2020-01-01 01:01:01'),(37,20170502143928,1,'2020-01-01 01:01:01'),(38,20170504130602,1,'2020-01-01 01:01:01'),(39,20170509132100,1,'2020-01-01 01:01:01'),(40,20170519105647,1,'2020-01-01 01:01:01'),(41,20170519105648,1,'2020-01-01 01:01:01'),(42,20170831234300,1,'2020-01-01 01:01:01'),(43,20170831234301,1,'2020-01-01 01:01:01'),(44,20170831234303,1,'2020-01-01 01:01:01'),(45,20171116163618,1,'2020-01-01 01:01:01'),(46,20171219164727,1,'2020-01-01 01:01:01'),(47,20180620164811,1,'2020-01-01 01:01:01'),(48,20180620175054,1,'2020-01-01 01:01:01'),(49,20180620175055,1,'2020-01-01 01:01:01'),(50,20191010101639,1,'2020-01-01 01:01:01'),(51,20191010155147,1,'2020-01-01 01:01:01'),(52,20191220130734,1,'2020-01-01 01:01:01'),(53,20200311140000,1,'2020-01-01 01:01:01'),(54,20200405120000,1,'2020-01-01 01:01:01'),(55,20200407120000,1,'2020-01-01 01:01:01'),(56,20200420120000,1,'2020-01-01 01:01:01'),(57,20200504120000,1,'2020-01-01 01:01:01'),(58,20200512120000,1,'2020-01-01 01:01:01'),(59,20200707120000,1,'2020-01-01 01:01:01'),(60,20201011162341,1,'2020-01-01 01:01:01'),(61,20201021104586,1,'2020-01-01 01:01:01'),(62,20201102112520,1,'2020-01-01 01:01:01'),(63,20201208121729,1,'2020-01-01 01:01:01'),(64,20201215091637,1,'2020-01-01 01:01:01'),(65,20210119174155,1,'2020-01-01 01:01:01'),(66,20210326182902,1,'2020-01-01 01:01:01'),(67,20210421112652,1,'2020-01-01 01:01:01'),(68,20210506095025,1,'2020-01-01 01:01:01'),(69,20210513115729,1,'2020-01-01 01:01:01'),(70,20210526113559,1,'2020-01-01 01:01:01'),(71,20210601000001,1,'2020-01-01 01:01:01'),(72,20210601000002,1,'2020-01-01 01:01:01'),(73,20210601000003,1,'2020-01-01 01:01:01'),(74,20210601000004,1,'2020-01-01 01:01:01'),(75,20210601000005,1,'2020-01-01 01:01:01'),(76,20210601000006,1,'2020-01-01 01:01:01'),(77,20210601000007,1,'2020-01-01 01:01:01'),(78,20210601000008,1,'2020-01-01 01:01:01'),(79,20210606151329,1,'2020-01-01 01:01:01'),(80,20210616163757,1,'2020-01-01 01:01:01'),(81,20210617174723,1,'2020-01-01 01:01:01'),(82,20210622160235,1,'2020-01-01 01:01:01'),(83,20210623100031,1,'2020-01-01 01:01:01'),(84,20210623133615,1,'2020-01-01 01:01:01'),(85,20210708143152,1,'2020-01-01 01:01:01'),(86,20210709124443,1,'2020-01-01 01:01:01'),(87,20210712155608,1,'2020-01-01 01:01:01'),(88,20210714102108,1,'2020-01-01 01:01:01'),(89,20210719153709,1,'2020-01-01 01:01:01'),(90,20210721171531,1,'2020-01-01 01:01:01'),(91,20210723135713,1,'2020-01-01 01:01:01'),(92,20210802135933,1,'2020-01-01 01:01:01'),(93,20210806112844,1,'2020-01-01 01:01:01'),(94,20210810095603,1,'2020-01-01 01:01:01'),(95,20210811150223,1,'2020-01-01 01:01:01'),(96,20210818151827,1,'2020-01-01 01:01:01'),(97,20210818151828,1,'2020-01-01 01:01:01'),(98,20210818182258,1,'2020-01-01 01:01:01'),(99,20210819131107,1,'2020-01-01 01:01:01'),(100,20210819143446,1,'2020-01-01 01:01:01'),(101,20210903132338,1,'2020-01-01 01:01:01'),(102,20210915144307,1,'2020-01-01 01:01:01'),(103,20210920155130,1,'2020-01-01 01:01:01'),(104,20210927143115,1,'2020-01-01 01:01:01'),(105,20210927143116,1,'2020-01-01 01:01:01'),(106,20211013133706,1,'2020-01-01 01:01:01'),(107,20211013133707,1,'2020-01-01 01:01:01'),(108,20211102135149,1,'2020-01-01 01:01:01'),(109,20211109121546,1,'2020-01-01 01:01:01'),(110,20211110163320,1,'2020-01-01 01:01:01'),(111,20211116184029,1,'2020-01-01 01:01:01'),(112,20211116184030,1,'2020-01-01 01:01:01'),(113,20211202092042,1,'2020-01-01 01:01:01'),(114,20211202181033,1,'2020-01-01 01:01:01'),(115,20211207161856,1,'2020-01-01 01:01:01'),(116,20211216131203,1,'2020-01-01 01:01:01'),(117,20211221110132,1,'2020-01-01 01:01:01'),(118,20220107155700,1,'2020-01-01 01:01:01'),(119,20220125105650,1,'2020-01-01 01:01:01'),(120,20220201084510,1,'2020-01-01 01:01:01'),(121,20220208144830,1,'2020-01-01 01:01:01'),(122,20220208144831,1,'2020-01-01 01:01:01'),(123,20220215152203,1,'2020-01-01 01:01:01'),(124,20220223113157,1,'2020-01-01 01:01:01'),(125,20220307104655,1,'2020-01-01 01:01:01'),(126,20220309133956,1,'2020-01-01 01:01:01'),(127,20220316155700,1,'2020-01-01 01:01:01'),(128,20220323152301,1,'2020-01-01 01:01:01'),(129,20220330100659,1,'2020-01-01 01:01:01'),(130,20220404091216,1,'2020-01-01 01:01:01'),(131,20220419140750,1,'2020-01-01 01:01:01'),(132,20220428140039,1,'2020-01-01 01:01:01'),(133,20220503134048,1,'2020-01-01 01:01:01'),(134,20220524102918,1,'2020-01-01 01:01:01'),(135,20220526123327,1,'2020-01-01 01:01:01'),(136,20220526123328,1,'2020-01-01 01:01:01'),(137,20220526123329,1,'2020-01-01 01:01:01'),(138,20220608113128,1,'2020-01-01 01:01:01'),(139,20220627104817,1,'2020-01-01 01:01:01'),(140,20220704101843,1,'2020-01-01 01:01:01'),(141,20220708095046,1,'2020-01-01 01:01:01'),(142,20220713091130,1,'2020-01-01 01:01:01'),(143,20220802135510,1,'2020-01-01 01:01:01'),(144,20220818101352,1,'2020-01-01 01:01:01'),(145,20220822161445,1,'2020-01-01 01:01:01'),(146,20220831100036,1,'2020-01-01 01:01:01'),(147,20220831100151,1,'2020-01-01 01:01:01'),(148,20220908181826,1,'2020-01-01 01:01:01'),(149,20220914154915,1,'2020-01-01 01:01:01'),(150,20220915165115,1,'2020-01-01 01:01:01'),(151,20220915165116,1,'2020-01-01 01:01:01'),(152,20220928100158,1,'2020-01-01 01:01:01'),(153,20221014084130,1,'2020-01-01 01:01:01'),(154,20221027085019,1,'2020-01-01 01:01:01'),(155,20221101103952,1,'2020-01-01 01:01:01'),(156,20221104144401,1,'2020-01-01 01:01:01'),(157,20221109100749,1,'2020-01-01 01:01:01'),(158,20221115104546,1,'2020-01-01 01:01:01'),(159,20221130114928,1,'2020-01-01 01:01:01'),(160,20221205112142,1,'2020-01-01 01:01:01'),(161,20221216115820,1,'2020-01-01 01:01:01'),(162,20221220195934,1,'2020-01-01 01:01:01'),(163,20221220195935,1,'2020-01-01 01:01:01'),(164,20221223174807,1,'2020-01-01 01:01:01'),(165,20221227163855,1,'2020-01-01 01:01:01'),(166,20221227163856,1,'2020-01-01 01:01:01'),(167,20230202224725,1,'2020-01-01 01:01:01'),(168,20230206163608,1,'2020-01-01 01:01:01'),(169,20230214131519,1,'2020-01-01 01:01:01'),(170,20230303135738,1,'2020-01-01 01:01:01'),(171,20230313135301,1,'2020-01-01 01:01:01'),(172,20230313141819,1,'2020-01-01 01:01:01'),(173,20230315104937,1,'2020-01-01 01:01:01'),(174,20230317173844,1,'2020-01-01 01:01:01'),(175,20230320133602,1,'2020-01-01 01:01:01'),(176,20230330100011,1,'2020-01-01 01:01:01'),(177,20230330134823,1,'2020-01-01 01:01:01'),(178,20230405232025,1,'2020-01-01 01:01:01'),(179,20230408084104,1,'2020-01-01 01:01:01'),(180,20230411102858,1,'2020-01-01 01:01:01'),(181,20230421155932,1,'2020-01-01 01:01:01'),(182,20230425082126,1,'2020-01-01 01:01:01'),(183,20230425105727,1,'2020-01-01 01:01:01'),(184,20230501154913,1,'2020-01-01 01:01:01'),(185,20230503101418,1,'2020-01-01 01:01:01'),(186,20230515144206,1,'2020-01-01 01:01:01'),(187,20230517140952,1,'2020-01-01 01:01:01'),(188,20230517152807,1,'2020-01-01 01:01:01'),(189,20230518114155,1,'2020-01-01 01:01:01'),(190,20230520153236,1,'2020-01-01 01:01:01'),(191,20230525151159,1,'2020-01-01 01:01:01'),(192,20230530122103,1,'2020-01-01 01:01:01'),(193,20230602111827,1,'2020-01-01 01:01:01'),(194,20230608103123,1,'2020-01-01 01:01:01'),(195,20230629140529,1,'2020-01-01 01:01:01'),(196,20230629140530,1,'2020-01-01 01:01:01'),(197,20230711144622,1,'2020-01-01 01:01:01'),(198,20230721135421,1,'2020-01-01 01:01:01'),(199,20230721161508,1,'2020-01-01 01:01:01'),(200,20230726115701,1,'2020-01-01 01:01:01'),(201,20230807100822,1,'2020-01-01 01:01:01'),(202,20230814150442,1,'2020-01-01 01:01:01'),(203,20230823122728,1,'2020-01-01 01:01:01'),(204,20230906152143,1,'2020-01-01 01:01:01'),(205,20230911163618,1,'2020-01-01 01:01:01'),(206,20230912101759,1,'2020-01-01 01:01:01'),(207,20230915101341,1,'2020-01-01 01:01:01'),(208,20230918132351,1,'2020-01-01 01:01:01'),(209,20231004144339,1,'2020-01-01 01:01:01'),(210,20231009094541,1,'2020-01-01 01:01:01'),(211,20231009094542,1,'2020-01-01 01:01:01'),(212,20231009094543,1,'2020-01-01 01:01:01'),(213,20231009094544,1,'2020-01-01 01:01:01'),(214,20231016091915,1,'2020-01-01 01:01:01'),(215,20231024174135,1,'2020-01-01 01:01:01'),(216,20231025120016,1,'2020-01-01 01:01:01'),(217,20231025160156,1,'2020-01-01 01:01:01'),(218,20231031165350,1,'2020-01-01 01:01:01'),(219,20231106144110,1,'2020-01-01 01:01:01'),(220,20231107130934,1,'2020-01-01 01:01:01'),(221,20231109115838,1,'2020-01-01 01:01:01'),(222,20231121054530,1,'2020-01-01 01:01:01'),(223,20231122101320,1,'2020-01-01 01:01:01'),(224,20231130132828,1,'2020-01-01 01:01:01'),(225,20231130132931,1,'2020-01-01 01:01:01'),(226,20231204155427,1,'2020-01-01 01:01:01'),(227,20231206142340,1,'2020-01-01 01:01:01'),(228,20231207102320,1,'2020-01-01 01:01:01'),(229,20231207102321,1,'2020-01-01 01:01:01'),(230,20231207133731,1,'2020-01-01 01:01:01'),(231,20231212094238,1,'2020-01-01 01:01:01'),(232,20231212095734,1,'2020-01-01 01:01:01'),(233,20231212161121,1,'2020-01-01 01:01:01'),(234,20231215122713,1,'2020-01-01 01:01:01'),(235,20231219143041,1,'2020-01-01 01:01:01'),(236,20231224070653,1,'2020-01-01 01:01:01'),(237,20240110134315,1,'2020-01-01 01:01:01'),(238,20240119091637,1,'2020-01-01 01:01:01'),(239,20240126020642,1,'2020-01-01 01:01:01'),(240,20240126020643,1,'2020-01-01 01:01:01'),(241,20240129162819,1,'2020-01-01 01:01:01'),(242,20240130115133,1,'2020-01-01 01:01:01'),(243,20240131083822,1,'2020-01-01 01:01:01'),(244,20240205095928,1,'2020-01-01 01:01:01'),(245,20240205121956,1,'2020-01-01 01:01:01'),(246,20240209110212,1,'2020-01-01 01:01:01'),(247,20240212111533,1,'2020-01-01 01:01:01'),(248,20240221112844,1,'2020-01-01 01:01:01'),(249,20240222073518,1,'2020-01-01 01:01:01'),(250,20240222135115,1,'2020-01-01 01:01:01'),(251,20240226082255,1,'2020-01-01 01:01:01'),(252,20240228082706,1,'2020-01-01 01:01:01'),(253,20240301173035,1,'2020-01-01 01:01:01'),(254,20240302111134,1,'2020-01-01 01:01:01'),(255,20240312103753,1,'2020-01-01 01:01:01'),(256,20240313143416,1,'2020-01-01 01:01:01'),(257,20240314085226,1,'2020-01-01 01:01:01'),(258,20240314151747,1,'2020-01-01 01:01:01'),(259,20240320145650,1,'2020-01-01 01:01:01'),(260,20240327115530,1,'2020-01-01 01:01:01'),(261,20240327115617,1,'2020-01-01 01:01:01'),(262,20240408085837,1,'2020-01-01 01:01:01'),(263,20240415104633,1,'2020-01-01 01:01:01'),(264,20240430111727,1,'2020-01-01 01:01:01'),(265,20240515200020,1,'2020-01-01 01:01:01'),(266,20240521143023,1,'2020-01-01 01:01:01'),(267,20240521143024,1,'2020-01-01 01:01:01'),(268,20240601174138,1,'2020-01-01 01:01:01'),(269,20240607133721,1,'2020-01-01 01:01:01'),(270,20240612150059,1,'2020-01-01 01:01:01'),(271,20240613162201,1,'2020-01-01 01:01:01'),(272,20240613172616,1,'2020-01-01 01:01:01'),(273,20240618142419,1,'2020-01-01 01:01:01'),(274,20240625093543,1,'2020-01-01 01:01:01'),(275,20240626195531,1,'2020-01-01 01:01:01'),(276,20240702123921,1,'2020-01-01 01:01:01'),(277,20240703154849,1,'2020-01-01 01:01:01'),(278,20240707134035,1,'2020-01-01 01:01:01'),(279,20240707134036,1,'2020-01-01 01:01:01'),(280,20240709124958,1,'2020-01-01 01:01:01'),(281,20240709132642,1,'2020-01-01 01:01:01'),(282,20240709183940,1,'2020-01-01 01:01:01'),(283,20240710155623,1,'2020-01-01 01:01:01'),(284,20240723102712,1,'2020-01-01 01:01:01'),(285,20240725152735,1,'2020-01-01 01:01:01'),(286,20240725182118,1,'2020-01-01 01:01:01'),(287,20240726100517,1,'2020-01-01 01:01:01'),(288,20240730171504,1,'2020-01-01 01:01:01'),(289,20240730174056,1,'2020-01-01 01:01:01'),(290,20240730215453,1,'2020-01-01 01:01:01'),(291,20240730374423,1,'2020-01-01 01:01:01'),(292,20240801115359,1,'2020-01-01 01:01:01'),(293,20240802101043,1,'2020-01-01 01:01:01'),(294,20240802113716,1,'2020-01-01 01:01:01'),(295,20240814135330,1,'2020-01-01 01:01:01'),(296,20240815000000,1,'2020-01-01 01:01:01'),(297,20240815000001,1,'2020-01-01 01:01:01'),(298,20240816103247,1,'2020-01-01 01:01:01'),(299,20240820091218,1,'2020-01-01 01:01:01'),(300,20240826111228,1,'2020-01-01 01:01:01'),(301,20240826160025,1,'2020-01-01 01:01:01'),(302,20240829165448,1,'2020-01-01 01:01:01'),(303,20240829165605,1,'2020-01-01 01:01:01'),(304,20240829165715,1,'2020-01-01 01:01:01'),(305,20240829165930,1,'2020-01-01 01:01:01'),(306,20240829170023,1,'2020-01-01 01:01:01'),(307,20240829170033,1,'2020-01-01 01:01:01'),(308,20240829170044,1,'2020-01-01 01:01:01'),(309,20240905105135,1,'2020-01-01 01:01:01'),(310,20240905140514,1,'2020-01-01 01:01:01'),(311,20240905200000,1,'2020-01-01 01:01:01'),(312,20240905200001,1,'2020-01-01 01:01:01'),(313,20241002104104,1,'2020-01-01 01:01:01'),(314,20241002104105,1,'2020-01-01 01:01:01'),(315,20241002104106,1,'2020-01-01 01:01:01'),(316,20241002210000,1,'2020-01-01 01:01:01'),(317,20241003145349,1,'2020-01-01 01:01:01'),(318,20241004005000,1,'2020-01-01 01:01:01'),(319,20241008083925,1,'2020-01-01 01:01:01'),(320,20241009090010,1,'2020-01-01 01:01:01'),(321,20241017163402,1,'2020-01-01 01:01:01'),(322,20241021224359,1,'2020-01-01 01:01:01'),(323,20241022140321,1,'2020-01-01 01:01:01'),(324,20241025111236,1,'2020-01-01 01:01:01'),(325,20241025112748,1,'2020-01-01 01:01:01'),(326,20241025141855,1,'2020-01-01 01:01:01'),(327,20241110152839,1,'2020-01-01 01:01:01'),(328,20241110152840,1,'2020-01-01 01:01:01'),(329,20241110152841,1,'2020-01-01 01:01:01'),(330,20241116233322,1,'2020-01-01 01:01:01'),(331,20241122171434,1,'2020-01-01 01:01:01'),(332,20241125150614,1,'2020-01-01 01:01:01'),(333,20241203125346,1,'2020-01-01 01:01:01'),(334,20241203130032,1,'2020-01-01 01:01:01'),(335,20241205122800,1,'2020-01-01 01:01:01'),(336,20241209164540,1,'2020-01-01 01:01:01'),(337,20241210140021,1,'2020-01-01 01:01:01'),(338,20241219180042,1,'2020-01-01 01:01:01'),(339,20241220100000,1,'2020-01-01 01:01:01'),(340,20241220114903,1,'2020-01-01 01:01:01'),(341,20241220114904,1,'2020-01-01 01:01:01'),(342,20241224000000,1,'2020-01-01 01:01:01'),(343,20241230000000,1,'2020-01-01 01:01:01'),(344,20241231112624,1,'2020-01-01 01:01:01'),(345,20250102121439,1,'2020-01-01 01:01:01'),(346,20250121094045,1,'2020-01-01 01:01:01'),(347,20250121094500,1,'2020-01-01 01:01:01'),(348,20250121094600,1,'2020-01-01 01:01:01'),(349,20250121094700,1,'2020-01-01 01:01:01'),(350,20250124194347,1,'2020-01-01 01:01:01'),(351,20250127162751,1,'2020-01-01 01:01:01'),(352,20250213104005,1,'2020-01-01 01:01:01'),(353,20250214205657,1,'2020-01-01 01:01:01'),(354,20250217093329,1,'2020-01-01 01:01:01'),(355,20250219090511,1,'2020-01-01 01:01:01'),(356,20250219100000,1,'2020-01-01 01:01:01'),(357,20250219142401,1,'2020-01-01 01:01:01'),(358,20250224184002,1,'2020-01-01 01:01:01'),(359,20250225085436,1,'2020-01-01 01:01:01'),(360,20250226000000,1,'2020-01-01 01:01:01'),(361,20250226153445,1,'2020-01-01 01:01:01'),(362,20250304162702,1,'2020-01-01 01:01:01'),(363,20250306144233,1,'2020-01-01 01:01:01'),(364,20250313163430,1,'2020-01-01 01:01:01'),(365,20250317130944,1,'2020-01-01 01:01:01'),(366,20250318165922,1,'2020-01-01 01:01:01'),(367,20250320132525,1,'2020-01-01 01:01:01'),(368,20250320200000,1,'2020-01-01 01:01:01'),(369,20250326161930,1,'2020-01-01 01:01:01'),(370,20250326161931,1,'2020-01-01 01:01:01'),(371,20250331042354,1,'2020-01-01 01:01:01'),(372,20250331154206,1,'2020-01-01 01:01:01'),(373,20250401155831,1,'2020-01-01 01:01:01'),(374,20250408133233,1,'2020-01-01 01:01:01'),(375,20250410104321,1,'2020-01-01 01:01:01'),(376,20250421085116,1,'2020-01-01 01:01:01'),(377,20250422095806,1,'2020-01-01 01:01:01'),(378,20250424153059,1,'2020-01-01 01:01:01'),(379,20250430103833,1,'2020-01-01 01:01:01'),(380,20250430112622,1,'2020-01-01 01:01:01'),(381,20250501162727,1,'2020-01-01 01:01:01'),(382,20250502154517,1,'2020-01-01 01:01:01'),(383,20250502222222,1,'2020-01-01 01:01:01'),(384,20250507170845,1,'2020-01-01 01:01:01'),(385,20250513162912,1,'2020-01-01 01:01:01'),(386,20250519161614,1,'2020-01-01 01:01:01'),(387,20250519170000,1,'2020-01-01 01:01:01'),(388,20250520153848,1,'2020-01-01 01:01:01'),(389,20250528115932,1,'2020-01-01 01:01:01'),(390,20250529102706,1,'2020-01-01 01:01:01'),(391,20250603105558,1,'2020-01-01 01:01:01'),(392,20250609102714,1,'2020-01-01 01:01:01'),(393,20250609112613,1,'2020-01-01 01:01:01'),(394,20250613103810,1,'2020-01-01 01:01:01'),(395,20250616193950,1,'2020-01-01 01:01:01'),(396,20250624140757,1,'2020-01-01 01:01:01'),(397,20250626130239,1,'2020-01-01 01:01:01'),(398,20250629131032,1,'2020-01-01 01:01:01'),(399,20250701155654,1,'2020-01-01 01:01:01'),(400,20250707095725,1,'2020-01-01 01:01:01'),(401,20250716152435,1,'2020-01-01 01:01:01'),(402,20250718091828,1,'2020-01-01 01:01:01'),(403,20250728122229,1,'2020-01-01 01:01:01'),(404,20250731122715,1,'2020-01-01 01:01:01'),(405,20250731151000,1,'2020-01-01 01:01:01'),(406,20250803000000,1,'2020-01-01 01:01:01'),(407,20250805083116,1,'2020-01-01 01:01:01'),(408,20250807140441,1,'2020-01-01 01:01:01'),(409,20250808000000,1,'2020-01-01 01:01:01'),(410,20250811155036,1,'2020-01-01 01:01:01'),(411,20250813205039,1,'2020-01-01 01:01:01'),(412,20250814123333,1,'2020-01-01 01:01:01'),(413,20250815130115,1,'2020-01-01 01:01:01'),(414,20250816115553,1,'2020-01-01 01:01:01'),(415,20250817154557,1,'2020-01-01 01:01:01'),(416,20250825113751,1,'2020-01-01 01:01:01'),(417,20250827113140,1,'2020-01-01 01:01:01'),(418,20250828120836,1,'2020-01-01 01:01:01'),(419,20250902112642,1,'2020-01-01 01:01:01'),(420,20250904091745,1,'2020-01-01 01:01:01'),(421,20250905090000,1,'2020-01-01 01:01:01'),(422,20250922083056,1,'2020-01-01 01:01:01'),(423,20250923120000,1,'2020-01-01 01:01:01'),(424,20250926123048,1,'2020-01-01 01:01:01'),(425,20251015103505,1,'2020-01-01 01:01:01'),(426,20251015103600,1,'2020-01-01 01:01:01'),(427,20251015103700,1,'2020-01-01 01:01:01'),(428,20251015103800,1,'2020-01-01 01:01:01'),(429,20251015103900,1,'2020-01-01 01:01:01'),(430,20251028140000,1,'2020-01-01 01:01:01'),(431,20251028140100,1,'2020-01-01 01:01:01'),(432,20251028140110,1,'2020-01-01 01:01:01'),(433,20251028140200,1,'2020-01-01 01:01:01'),(434,20251028140300,1,'2020-01-01 01:01:01'),(435,20251028140400,1,'2020-01-01 01:01:01'),(436,20251031154558,1,'2020-01-01 01:01:01'),(437,20251103160848,1,'2020-01-01 01:01:01'),(438,20251104112849,1,'2020-01-01 01:01:01'),(439,20251106000000,1,'2020-01-01 01:01:01'),(440,20251107164629,1,'2020-01-01 01:01:01'),(441,20251107170854,1,'2020-01-01 01:01:01'),(442,20251110172137,1,'2020-01-01 01:01:01'),(443,20251111153133,1,'2020-01-01 01:01:01'),(444,20251117020000,1,'2020-01-01 01:01:01'),(445,20251117020100,1,'2020-01-01 01:01:01'),(446,20251117020200,1,'2020-01-01 01:01:01'),(447,20251121100000,1,'2020-01-01 01:01:01'),(448,20251121124239,1,'2020-01-01 01:01:01'),(449,20251124090450,1,'2020-01-01 01:01:01'),(450,20251124135808,1,'2020-01-01 01:01:01'),(451,20251124140138,1,'2020-01-01 01:01:01'),(452,20251124162948,1,'2020-01-01 01:01:01'),(453,20251127113559,1,'2020-01-01 01:01:01'),(454,20251202162232,1,'2020-01-01 01:01:01'),(455,20251203170808,1,'2020-01-01 01:01:01'),(456,20251207050413,1,'2020-01-01 01:01:01'),(457,20251208215800,1,'2020-01-01 01:01:01'),(458,20251209221730,1,'2020-01-01 01:01:01'),(459,20251209221850,1,'2020-01-01 01:01:01'),(460,20251215163721,1,'2020-01-01 01:01:01'),(461,20251217000000,1,'2020-01-01 01:01:01'),(462,20251217120000,1,'2020-01-01 01:01:01'),(463,20251229000000,1,'2020-01-01 01:01:01'),(464,20251229000010,1,'2020-01-01 01:01:01'),(465,20251229000020,1,'2020-01-01 01:01:01'),(466,20260106000000,1,'2020-01-01 01:01:01'),(467,20260108200708,1,'2020-01-01 01:01:01'),(468,20260108214732,1,'2020-01-01 01:01:01'),(469,20260109231821,1,'2020-01-01 01:01:01'),(470,20260113012054,1,'2020-01-01 01:01:01'),(471,20260124200020,1,'2020-01-01 01:01:01'),(472,20260126150840,1,'2020-01-01 01:01:01'),(473,20260126210724,1,'2020-01-01 01:01:01'),(474,20260202151756,1,'2020-01-01 01:01:01'),(475,20260205184907,1,'2020-01-01 01:01:01'),(476,20260210151544,1,'2020-01-01 01:01:01'),(477,20260210155109,1,'2020-01-01 01:01:01'),(478,20260210181120,1,'2020-01-01 01:01:01'),(479,20260211200153,1,'2020-01-01 01:01:01'),(480,20260217141240,1,'2020-01-01 01:01:01'),(481,20260217200906,1,'2020-01-01 01:01:01'),(482,20260218175704,1,'2020-01-01 01:01:01'),(483,20260314120000,1,'2020-01-01 01:01:01'),(484,20260316120000,1,'2020-01-01 01:01:01'),(485,20260316120001,1,'2020-01-01 01:01:01'),(486,20260316120002,1,'2020-01-01 01:01:01'),(487,20260316120003,1,'2020-01-01 01:01:01'),(488,20260316120004,1,'2020-01-01 01:01:01'),(489,20260316120005,1,'2020-01-01 01:01:01'),(490,20260316120006,1,'2020-01-01 01:01:01'),(491,20260316120007,1,'2020-01-01 01:01:01'),(492,20260316120008,1,'2020-01-01 01:01:01'),(493,20260316120009,1,'2020-01-01 01:01:01'),(494,20260316120010,1,'2020-01-01 01:01:01'),(495,20260317120000,1,'2020-01-01 01:01:01'),(496,20260318184559,1,'2020-01-01 01:01:01'),(497,20260319120000,1,'2020-01-01 01:01:01'),(498,20260323144117,1,'2020-01-01 01:01:01'),(499,20260324161944,1,'2020-01-01 01:01:01'),(500,20260324223334,1,'2020-01-01 01:01:01'),(501,20260326131501,1,'2020-01-01 01:01:01'),(502,20260326210603,1,'2020-01-01 01:01:01'),(503,20260331000000,1,'2020-01-01 01:01:01'),(504,20260401153000,1,'2020-01-01 01:01:01'),(505,20260401153001,1,'2020-01-01 01:01:01'),(506,20260401153503,1,'2020-01-01 01:01:01'),(507,20260403120000,1,'2020-01-01 01:01:01'),(508,20260409153713,1,'2020-01-01 01:01:01'),(509,20260409153714,1,'2020-01-01 01:01:01'),(510,20260409153715,1,'2020-01-01 01:01:01'),(511,20260409153716,1,'2020-01-01 01:01:01'),(512,20260409153717,1,'2020-01-01 01:01:01'),(513,20260409183610,1,'2020-01-01 01:01:01'),(514,20260410173222,1,'2020-01-01 01:01:01'),(515,20260422181702,1,'2020-01-01 01:01:01'),(516,20260423161823,1,'2020-01-01 01:01:01'),(517,20260423161824,1,'2020-01-01 01:01:01'),(518,20260518194422,1,'2020-01-01 01:01:01'),(519,20260522195224,1,'2020-01-01 01:01:01'),(520,20260522195225,1,'2020-01-01 01:01:01'),(521,20260522195226,1,'2020-01-01 01:01:01'),(522,20260522195227,1,'2020-01-01 01:01:01'),(523,20260522195229,1,'2020-01-01 01:01:01'),(524,20260522195230,1,'2020-01-01 01:01:01'),(525,20260522195231,1,'2020-01-01 01:01:01'),(526,20260522195232,1,'2020-01-01 01:01:01'),(527,20260522195233,1,'2020-01-01 01:01:01'),(528,20260522195234,1,'2020-01-01 01:01:01'),(529,20260522195235,1,'2020-01-01 01:01:01'),(530,20260527215817,1,'2020-01-01 01:01:01'),(531,20260527215818,1,'2020-01-01 01:01:01'),(532,20260528201143,1,'2020-01-01 01:01:01'),(533,20260528201150,1,'2020-01-01 01:01:01'),(534,20260528211626,1,'2020-01-01 01:01:01'),(535,20260528213326,1,'2020-01-01 01:01:01'),(536,20260529091823,1,'2020-01-01 01:01:01'),(537,20260529120000,1,'2020-01-01 01:01:01'),(538,20260601200727,1,'2020-01-01 01:01:01'),(539,20260603101320,1,'2020-01-01 01:01:01'),(540,20260603120000,1,'2020-01-01 01:01:01'),(541,20260604221206,1,'2020-01-01 01:01:01'),(542,20260605195941,1,'2020-01-01 01:01:01'),(543,20260606051849,1,'2020-01-01 01:01:01'),(544,20260608160653,1,'2020-01-01 01:01:01'),(545,20260608202705,1,'2020-01-01 01:01:01'),(546,20260608210432,1,'2020-01-01 01:01:01'),(547,20260610172952,1,'2020-01-01 01:01:01'),(548,20260624210253,1,'2020-01-01 01:01:01'),(549,20260624210311,1,'2020-01-01 01:01:01'),(550,20260626120000,1,'2020-01-01 01:01:01'),(551,20260702013055,1,'2020-01-01 01:01:01'),(552,20260702013056,1,'2020-01-01 01:01:01'),(553,20260702013057,1,'2020-01-01 01:01:01'),(554,20260702013058,1,'2020-01-01 01:01:01'),(555,20260702013059,1,'2020-01-01 01:01:01'),(556,20260702013100,1,'2020-01-01 01:01:01'),(557,20260702013101,1,'2020-01-01 01:01:01'),(558,20260702013102,1,'2020-01-01 01:01:01'),(559,20260702164518,1,'2020-01-01 01:01:01'),(560,20260717152653,1,'2020-01-01 01:01:01'),(561,20260721090128,1,'2020-01-01 01:01:01'),(562,20260721160351,1,'2020-01-01 01:01:01'),(563,20260723181401,1,'2020-01-01 01:01:01'),(564,20260723181402,1,'2020-01-01 01:01:01'),(565,20260723181403,1,'2020-01-01 01:01:01'),(566,20260723181404,1,'2020-01-01 01:01:01'),(567,20260723181405,1,'2020-01-01 01:01:01'),(568,20260723181406,1,'2020-01-01 01:01:01'),(569,20260723181407,1,'2020-01-01 01:01:01'),(570,20260723181408,1,'2020-01-01 01:01:01'),(571,20260723181409,1,'2020-01-01 01:01:01'),(572,20260723181410,1,'2020-01-01 01:01:01'),(573,20260723181411,1,'2020-01-01 01:01:01'),(574,20260724134801,1,'2020-01-01 01:01:01'),(575,20260727083533,1,'2020-01-01 01:01:01'),(576,20260727084359,1,'2020-01-01 01:01:01'),(577,20260729110229,1,'2020-01-01 01:01:01'),(578,20260729115013,1,'2020-01-01 01:01:01'),(579,20260731100711,1,'2020-01-01 01:01:01'),(580,20260731213352,1,'2020-01-01 01:01:01'),(581,20260803135530,1,'2020-01-01 01:01:01'),(582,20260803182251,1,'2020-01-01 01:01:01'),(583,20260805161502,1,'2020-01-01 01:01:01'),(584,20260806210232,1,'2020-01-01 01:01:01'); /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `mobile_device_management_solutions` ( diff --git a/server/datastore/mysql/vpp.go b/server/datastore/mysql/vpp.go index 0ebe562211..36dbf20380 100644 --- a/server/datastore/mysql/vpp.go +++ b/server/datastore/mysql/vpp.go @@ -2726,6 +2726,12 @@ func (ds *Datastore) markAllPendingVPPInstallsAsFailedForHost(ctx context.Contex return nil, nil, ctxerr.New(ctx, fmt.Sprintf("softwareType %s not supported", softwareType)) } + // The activities returned to the caller are derived solely from failedCmds, which + // is scoped to still-pending installs (verification_failed_at IS NULL AND + // verification_at IS NULL AND canceled = 0). This makes the function idempotent for + // the Android DELETED path: a duplicate Pub/Sub DELETED delivery finds those rows + // already marked failed, so the SELECT returns an empty set and no duplicate + // failed-install activities are emitted. const loadFailedCmdsStmt = ` SELECT command_uuid diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index cdb53bcfdd..347ec5be98 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -3892,6 +3892,19 @@ type AndroidDatastore interface { AppConfig(ctx context.Context) (*AppConfig, error) BulkSetAndroidHostsUnenrolled(ctx context.Context) error SetAndroidHostUnenrolled(ctx context.Context, hostID uint) (bool, error) + // SetAndroidHostEnrolled flips host_mdm back to enrolled for an Android host + // that is currently marked unenrolled, recovering a host wrongly unenrolled by + // an out-of-order Pub/Sub DELETED delivery. Returns false (no-op) when the host + // is already enrolled or has no host_mdm row. It preserves the existing + // is_personal_enrollment classification. + SetAndroidHostEnrolled(ctx context.Context, hostID uint) (bool, error) + // GetAndroidPubSubDedupState returns the last-processed Google Pub/Sub messageId + // and AMAPI event timestamp recorded for the host, for dropping duplicate and + // stale AMAPI notification deliveries. + GetAndroidPubSubDedupState(ctx context.Context, hostID uint) (messageID string, eventTime *time.Time, err error) + // SetAndroidPubSubDedupState records the last-processed Google Pub/Sub messageId + // and AMAPI event timestamp for the host after a notification is handled. + SetAndroidPubSubDedupState(ctx context.Context, hostID uint, messageID string, eventTime *time.Time) error DeleteMDMConfigAssetsByName(ctx context.Context, assetNames []MDMAssetName) error GetAllMDMConfigAssetsByName(ctx context.Context, assetNames []MDMAssetName, queryerContext sqlx.QueryerContext) (map[MDMAssetName]MDMConfigAsset, error) diff --git a/server/mdm/android/pubsub.go b/server/mdm/android/pubsub.go index 84250cdcc7..60a599244c 100644 --- a/server/mdm/android/pubsub.go +++ b/server/mdm/android/pubsub.go @@ -19,4 +19,10 @@ const ( type PubSubMessage struct { Attributes map[string]string `json:"attributes"` Data string `json:"data"` + // MessageID and PublishTime are set by Google Pub/Sub on the push envelope as + // siblings of Attributes/Data. MessageID is stable across at-least-once + // redeliveries of the same message; PublishTime is an RFC3339 timestamp used as + // a staleness fallback when the AMAPI payload carries no event timestamp. + MessageID string `json:"messageId"` + PublishTime string `json:"publishTime"` } diff --git a/server/mdm/android/service/enterprises_test.go b/server/mdm/android/service/enterprises_test.go index 3ce081b876..9cf0d5bb9d 100644 --- a/server/mdm/android/service/enterprises_test.go +++ b/server/mdm/android/service/enterprises_test.go @@ -7,6 +7,7 @@ import ( "net/http" "os" "testing" + "time" "github.com/fleetdm/fleet/v4/server/authz" "github.com/fleetdm/fleet/v4/server/config" @@ -237,6 +238,15 @@ func InitCommonDSMocks() *AndroidMockDS { ds.Store.UpdateTeamIDOnAndroidDevicesFunc = func(ctx context.Context, hostUUIDs []string, teamID *uint) error { return nil } + ds.Store.GetAndroidPubSubDedupStateFunc = func(ctx context.Context, hostID uint) (string, *time.Time, error) { + return "", nil, nil + } + ds.Store.SetAndroidPubSubDedupStateFunc = func(ctx context.Context, hostID uint, messageID string, eventTime *time.Time) error { + return nil + } + ds.Store.SetAndroidHostEnrolledFunc = func(ctx context.Context, hostID uint) (bool, error) { + return false, nil + } return &ds } diff --git a/server/mdm/android/service/pubsub.go b/server/mdm/android/service/pubsub.go index dddf693132..68290a119a 100644 --- a/server/mdm/android/service/pubsub.go +++ b/server/mdm/android/service/pubsub.go @@ -64,11 +64,11 @@ func (svc *Service) ProcessPubSubPush(ctx context.Context, token string, message switch android.NotificationType(notificationType) { case android.PubSubEnrollment: - return svc.handlePubSubEnrollment(ctx, token, rawData) + return svc.handlePubSubEnrollment(ctx, token, rawData, message.MessageID, message.PublishTime) case android.PubSubStatusReport: - return svc.handlePubSubStatusReport(ctx, token, rawData) + return svc.handlePubSubStatusReport(ctx, token, rawData, message.MessageID, message.PublishTime) case android.PubSubCommand: - return svc.handlePubSubCommand(ctx, token, rawData) + return svc.handlePubSubCommand(ctx, token, rawData, message.MessageID, message.PublishTime) default: // Ignore unknown notification types svc.logger.DebugContext(ctx, "Ignoring PubSub notification type", "notification", notificationType) @@ -146,7 +146,7 @@ func clearAndroidBYOWipeRef(ctx context.Context, ds fleet.Datastore, hostID uint // notification to the Fleet row via operation_name and transition the mdm_android_commands row from pending to // acknowledged or error. host_mdm_actions does not need updating: HostLockWipeStatus reads the row status string // directly. -func (svc *Service) handlePubSubCommand(ctx context.Context, token string, rawData []byte) error { +func (svc *Service) handlePubSubCommand(ctx context.Context, token string, rawData []byte, messageID, publishTime string) error { if err := svc.authenticatePubSub(ctx, token); err != nil { return err } @@ -192,7 +192,7 @@ func (svc *Service) handlePubSubCommand(ctx context.Context, token string, rawDa // failures on the original delivery recover on this retry. if cmd.Status != string(android.MDMAndroidCommandStatusPending) { if cmd.CommandType == string(android.MDMAndroidCommandTypeWipe) && cmd.Status == string(android.MDMAndroidCommandStatusAcknowledged) { - if err := svc.handleAndroidWipeAckUnenroll(ctx, cmd); err != nil { + if err := svc.handleAndroidWipeAckUnenroll(ctx, cmd, messageID, publishTime); err != nil { return err } } @@ -220,7 +220,7 @@ func (svc *Service) handlePubSubCommand(ctx context.Context, token string, rawDa // not reliably send for a factory-reset COBO device (the agent is gone, nothing left to phone home). For BYO the DELETED // notification typically arrives and is now a no-op because we already flipped state. if cmd.CommandType == string(android.MDMAndroidCommandTypeWipe) && newStatus == string(android.MDMAndroidCommandStatusAcknowledged) { - if err := svc.handleAndroidWipeAckUnenroll(ctx, cmd); err != nil { + if err := svc.handleAndroidWipeAckUnenroll(ctx, cmd, messageID, publishTime); err != nil { return err } } @@ -237,7 +237,7 @@ func (svc *Service) handlePubSubCommand(ctx context.Context, token string, rawDa // handleAndroidWipeAckUnenroll runs after a successful WIPE ack: flips host_mdm.enrolled, clears host_mdm_actions for BYO (so the // "Wiped" badge does not stick on a host whose only the work profile was removed), and emits mdm_unenrolled if state actually // changed. Returns errors so Pub/Sub retries on transient DB failures. -func (svc *Service) handleAndroidWipeAckUnenroll(ctx context.Context, cmd *android.MDMAndroidCommand) error { +func (svc *Service) handleAndroidWipeAckUnenroll(ctx context.Context, cmd *android.MDMAndroidCommand, messageID, publishTime string) error { ah, err := svc.ds.AndroidHostLiteByHostUUID(ctx, cmd.HostUUID) if err != nil { return ctxerr.Wrap(ctx, err, "android wipe-ack unenroll: lookup host by uuid") @@ -256,6 +256,7 @@ func (svc *Service) handleAndroidWipeAckUnenroll(ctx context.Context, cmd *andro if err != nil { return ctxerr.Wrap(ctx, err, "android wipe-ack unenroll: set host_mdm unenrolled") } + if !didUnenroll { // Already unenrolled (e.g. the API wrapper for BYO Unenroll already ran, a prior DELETED // notification beat us, or a prior delivery flipped state and is now retrying). No state @@ -263,9 +264,21 @@ func (svc *Service) handleAndroidWipeAckUnenroll(ctx context.Context, cmd *andro // the tradeoff is no duplicate activity rows after a successful first delivery, at the cost // of losing the activity in the rare "flip succeeded then activity failed" race. The state // flip is what matters; the activity loss is detectable via logs. + // + // We also do NOT re-record dedup state here: a redelivery of an already-terminal wipe must + // not move last_pubsub_event_time backwards to the (older) wipe publish time if a newer + // notification has since been recorded. return nil } + // Advance the dedup event time to the wipe notification's publish time. This is the + // authoritative COBO unenroll signal (AMAPI does not reliably send DELETED for a + // factory-reset device), and the COMMAND envelope carries no device timestamp. Recording + // it here means a STATUS_REPORT published before the wipe but delivered afterwards (Pub/Sub + // is unordered) is dropped as stale by handlePubSubStatusReport, so it cannot re-enroll a + // device that was just wiped. Only done when this delivery actually flipped state. + svc.recordPubSubProcessed(ctx, ah.Host.ID, messageID, pubSubEventTime("", publishTime)) + displayName := "" if hosts, herr := svc.fleetDS.ListHostsLiteByIDs(ctx, []uint{ah.Host.ID}); herr == nil && len(hosts) == 1 && hosts[0] != nil { displayName = hosts[0].DisplayName() @@ -336,7 +349,74 @@ func googleStatusCode(code int64) string { return fmt.Sprintf("%d", code) } -func (svc *Service) handlePubSubStatusReport(ctx context.Context, token string, rawData []byte) error { +// pubSubEventTime derives the AMAPI event timestamp used for staleness comparison. +// It prefers the device's LastStatusReportTime (present on STATUS_REPORT and, +// usually, ENROLLMENT device payloads) and falls back to the Pub/Sub envelope +// publishTime. Returns nil when neither is a parseable RFC3339 timestamp, in which +// case the staleness check is skipped and only messageId dedup applies. +// +// Caveat: the two sources are different Google clocks (device status time vs. +// Pub/Sub publish time). ENROLLMENT payloads often omit LastStatusReportTime, so a +// comparison may end up device-time vs. publish-time. Both are Google-side and close +// in practice, so the risk of misordering is low, but callers should not assume +// same-clock semantics. +func pubSubEventTime(deviceTime, publishTime string) *time.Time { + for _, ts := range []string{deviceTime, publishTime} { + if ts == "" { + continue + } + if t, err := time.Parse(time.RFC3339, ts); err == nil { + return &t + } + } + return nil +} + +// isDuplicateOrStalePubSub reports whether an AMAPI notification for hostID should +// be skipped because it is a redelivery (same messageId as the last processed) or +// arrived out of order (event timestamp older than the last processed). Google +// Pub/Sub gives at-least-once, unordered delivery, so both cases occur in normal +// operation. A host with no recorded state yet is never a duplicate. +func (svc *Service) isDuplicateOrStalePubSub(ctx context.Context, hostID uint, messageID string, eventTime *time.Time) (bool, error) { + // Force the primary: Pub/Sub redeliveries commonly arrive within seconds, inside the + // replica-lag window, and reading a stale (empty) row here would let the redelivery + // reprocess — defeating the dedup. + lastMessageID, lastEventTime, err := svc.ds.GetAndroidPubSubDedupState(ctxdb.RequirePrimary(ctx, true), hostID) + if err != nil { + if fleet.IsNotFound(err) { + return false, nil + } + return false, ctxerr.Wrap(ctx, err, "get android pubsub dedup state") + } + if messageID != "" && messageID == lastMessageID { + svc.logger.DebugContext(ctx, "skipping duplicate Android PubSub message", "host_id", hostID, "message_id", messageID) + return true, nil + } + if eventTime != nil && lastEventTime != nil && eventTime.Before(*lastEventTime) { + svc.logger.DebugContext(ctx, "skipping stale Android PubSub message", "host_id", hostID, + "message_id", messageID, "event_time", eventTime, "last_event_time", lastEventTime) + return true, nil + } + return false, nil +} + +// recordPubSubProcessed stores the messageId and event timestamp of a +// successfully-handled notification so future duplicate/stale deliveries for the +// host are dropped. Failure is non-fatal: the message was already processed, and +// returning an error would trigger a Pub/Sub retry that reprocesses (and could +// re-emit) the same work. A missed record only weakens dedup for the narrow +// redelivery window. +func (svc *Service) recordPubSubProcessed(ctx context.Context, hostID uint, messageID string, eventTime *time.Time) { + if err := svc.ds.SetAndroidPubSubDedupState(ctx, hostID, messageID, eventTime); err != nil { + // Logged at Warn, not Error: a NotFound here means the android_devices row was deleted + // between resolving the host and this write (a benign host-deletion race), not a fault + // that needs alerting. + svc.logger.WarnContext(ctx, "failed to record Android PubSub dedup state", + "host_id", hostID, "message_id", messageID, "err", err) + } +} + +func (svc *Service) handlePubSubStatusReport(ctx context.Context, token string, rawData []byte, messageID, publishTime string) error { err := svc.authenticatePubSub(ctx, token) if err != nil { return err @@ -353,6 +433,8 @@ func (svc *Service) handlePubSubStatusReport(ctx context.Context, token string, return err } + eventTime := pubSubEventTime(device.LastStatusReportTime, publishTime) + // NOTE: uncomment as needed, can be useful for debugging as the pubsub report // can be very large - it is not practical to print so it saves it to a file, // different names for all instances of the pubsub, and under an extension that @@ -386,6 +468,16 @@ func (svc *Service) handlePubSubStatusReport(ctx context.Context, token string, return ctxerr.Wrap(ctx, err, "get host for deleted android device") } if host != nil { + // Drop duplicate/out-of-order deliveries before touching enrollment state. + // This is what stops a stale DELETED (redelivered after a re-ENROLLMENT) + // from unenrolling a live host, and advancing the recorded event time here + // stops a later stale STATUS_REPORT from wrongly re-enrolling it. + if skip, err := svc.isDuplicateOrStalePubSub(ctx, host.Host.ID, messageID, eventTime); err != nil { + return err + } else if skip { + return nil + } + // Capture BYO-ness BEFORE flipping host_mdm.enrolled, then clear host_mdm_actions for BYO // so the post-ack "Wiped" badge clears (BYO unenroll only wipes the work profile). if err := clearAndroidBYOWipeRef(ctx, svc.fleetDS, host.Host.ID); err != nil { @@ -412,6 +504,8 @@ func (svc *Service) handlePubSubStatusReport(ctx context.Context, token string, } } + svc.recordPubSubProcessed(ctx, host.Host.ID, messageID, eventTime) + if !didUnenroll { return nil // Skip activity, if we didn't update the enrollment state. } @@ -441,8 +535,7 @@ func (svc *Service) handlePubSubStatusReport(ctx context.Context, token string, svc.logger.DebugContext(ctx, "Device not found in Fleet. Perhaps it was deleted, "+ "but it is still connected via Android MDM. Re-enrolling", "device.name", device.Name, "device.enterpriseSpecificId", device.HardwareInfo.EnterpriseSpecificId) - err = svc.enrollHost(ctx, &device) - if err != nil { + if _, err := svc.enrollHost(ctx, &device); err != nil { svc.logger.DebugContext(ctx, "Error re-enrolling Android host", "data", rawData) return ctxerr.Wrap(ctx, err, "re-enrolling deleted Android host") } @@ -458,15 +551,39 @@ func (svc *Service) handlePubSubStatusReport(ctx context.Context, token string, device.HardwareInfo.EnterpriseSpecificId) } } + + // Drop duplicate/out-of-order deliveries. A freshly re-enrolled host (host was + // nil above) has no recorded state, so this is a no-op for that case. + if skip, err := svc.isDuplicateOrStalePubSub(ctx, host.Host.ID, messageID, eventTime); err != nil { + return err + } else if skip { + return nil + } + err = svc.updateHost(ctx, &device, host, false) if err != nil { svc.logger.DebugContext(ctx, "Error updating Android host", "data", rawData) return ctxerr.Wrap(ctx, err, "enrolling Android host") } + + // A live device sending a STATUS_REPORT is by definition still managed. If it is + // currently marked unenrolled (e.g. a stale DELETED slipped through before dedup + // state existed), restore enrollment so it does not stay stuck unenrolled until a + // fresh ENROLLMENT. The staleness check above prevents a stale STATUS_REPORT from + // re-enrolling a host that was legitimately unenrolled (including via a WIPE ack, + // whose unenroll path records the wipe's event time). + if didEnroll, err := svc.ds.SetAndroidHostEnrolled(ctx, host.Host.ID); err != nil { + return ctxerr.Wrap(ctx, err, "restore android host enrollment on status report") + } else if didEnroll { + svc.logger.InfoContext(ctx, "restored Android host enrollment from status report", "host_id", host.Host.ID) + } + err = svc.updateHostSoftware(ctx, &device, host) if err != nil { return ctxerr.Wrap(ctx, err, "updating Android host software") } + + svc.recordPubSubProcessed(ctx, host.Host.ID, messageID, eventTime) return nil } @@ -509,7 +626,7 @@ func (svc *Service) updateHostSoftware(ctx context.Context, device *androidmanag return nil } -func (svc *Service) handlePubSubEnrollment(ctx context.Context, token string, rawData []byte) error { +func (svc *Service) handlePubSubEnrollment(ctx context.Context, token string, rawData []byte, messageID, publishTime string) error { err := svc.authenticatePubSub(ctx, token) if err != nil { return err @@ -527,6 +644,8 @@ func (svc *Service) handlePubSubEnrollment(ctx context.Context, token string, ra return err } + eventTime := pubSubEventTime(device.LastStatusReportTime, publishTime) + // Some deployments may report work profile removal under ENROLLMENT notifications. // Detect DELETED here too and treat as unenrollment confirmation. isDeleted := strings.ToUpper(device.AppliedState) == string(android.DeviceStateDeleted) @@ -547,13 +666,21 @@ func (svc *Service) handlePubSubEnrollment(ctx context.Context, token string, ra return ctxerr.Wrap(ctx, herr, "get host for deleted android device (ENROLLMENT)") } if host != nil { + // Drop duplicate/out-of-order deliveries before touching enrollment state. + if skip, err := svc.isDuplicateOrStalePubSub(ctx, host.Host.ID, messageID, eventTime); err != nil { + return err + } else if skip { + return nil + } + // Capture BYO-ness BEFORE flipping host_mdm.enrolled, then clear host_mdm_actions for BYO // so the post-ack "Wiped" badge clears (BYO unenroll only wipes the work profile). if err := clearAndroidBYOWipeRef(ctx, svc.fleetDS, host.Host.ID); err != nil { return ctxerr.Wrap(ctx, err, "clear byo wipe-ref on DELETED state (ENROLLMENT)") } - if _, err := svc.ds.SetAndroidHostUnenrolled(ctx, host.Host.ID); err != nil { + didUnenroll, err := svc.ds.SetAndroidHostUnenrolled(ctx, host.Host.ID) + if err != nil { return ctxerr.Wrap(ctx, err, "set android host unenrolled on DELETED state (ENROLLMENT)") } @@ -572,6 +699,16 @@ func (svc *Service) handlePubSubEnrollment(ctx context.Context, token string, ra } } + svc.recordPubSubProcessed(ctx, host.Host.ID, messageID, eventTime) + + if !didUnenroll { + // Already unenrolled (e.g. a DELETED delivered under STATUS_REPORT beat this one, + // or a redelivery that messageId dedup did not catch). Skip the activity so the + // feed does not gain a duplicate mdm_unenrolled row — same rule as the + // STATUS_REPORT DELETED branch. + return nil + } + var displayName, serial string if hosts, herr := svc.fleetDS.ListHostsLiteByIDs(ctx, []uint{host.Host.ID}); herr == nil && len(hosts) == 1 && hosts[0] != nil { displayName = hosts[0].DisplayName() @@ -588,18 +725,49 @@ func (svc *Service) handlePubSubEnrollment(ctx context.Context, token string, ra return nil } - err = svc.enrollHost(ctx, &device) + // Drop duplicate ENROLLMENT deliveries before enrolling: a redelivered ENROLLMENT + // for an existing host would otherwise re-queue the setup-experience job (duplicate + // VPP installs and activities). A device brand-new to Fleet has no row to check + // against yet; its state is recorded below so a redelivery is caught. + // Force the primary so a redelivered ENROLLMENT sees a host that a prior delivery just + // created (and thus its recorded dedup state), instead of missing it on a lagging replica + // and re-queuing the setup experience. + existing, herr := svc.getExistingHost(ctxdb.RequirePrimary(ctx, true), &device) + if herr != nil { + return ctxerr.Wrap(ctx, herr, "getting existing Android host for enrollment dedup") + } + if existing != nil { + if skip, err := svc.isDuplicateOrStalePubSub(ctx, existing.Host.ID, messageID, eventTime); err != nil { + return err + } else if skip { + return nil + } + } + + hostID, err := svc.enrollHost(ctx, &device) if err != nil { svc.logger.DebugContext(ctx, "Error enrolling Android host", "data", rawData) return ctxerr.Wrap(ctx, err, "enrolling Android host") } + + // Record dedup state using the ID enrollHost resolved, rather than re-reading the host + // from the payload. A re-read can fail (replica lag, DB hiccup, a deploy returning 5xx) + // *after* enrollment and the setup-experience job have already run — the delivery would + // still be acked with no dedup state written, and the redelivery would re-queue the + // setup experience. That is the exact failure this dedup exists to prevent. + svc.recordPubSubProcessed(ctx, hostID, messageID, eventTime) return nil } -func (svc *Service) enrollHost(ctx context.Context, device *androidmanagement.Device) error { +// enrollHost enrolls (or re-enrolls) the device and returns the Fleet host ID of the +// resulting host. Returning the ID lets callers record follow-up state without a second +// lookup: a lookup that fails *after* enrollment has already run leaves the Pub/Sub +// delivery acked with that state unwritten, which is exactly the window a 5xx-inducing +// deploy or DB hiccup opens. +func (svc *Service) enrollHost(ctx context.Context, device *androidmanagement.Device) (uint, error) { err := svc.validateDevice(ctx, device) if err != nil { - return err + return 0, err } // Enqueue a job to send any necessary self-service software. @@ -608,7 +776,7 @@ func (svc *Service) enrollHost(ctx context.Context, device *androidmanagement.De // Device may already be present in Fleet if device user removed the MDM profile and then re-enrolled host, err := svc.getExistingHost(ctx, device) if err != nil { - return ctxerr.Wrap(ctx, err, "getting existing Android host") + return 0, ctxerr.Wrap(ctx, err, "getting existing Android host") } // TODO(mna): in the next iteration of Android work (as we're short on time @@ -619,7 +787,7 @@ func (svc *Service) enrollHost(ctx context.Context, device *androidmanagement.De var enrollmentTokenRequest enrollmentTokenRequest err = json.Unmarshal([]byte(device.EnrollmentTokenData), &enrollmentTokenRequest) if err != nil { - return ctxerr.Wrap(ctx, err, "unmarshalling enrollment token data") + return 0, ctxerr.Wrap(ctx, err, "unmarshalling enrollment token data") } if host != nil { @@ -627,7 +795,7 @@ func (svc *Service) enrollHost(ctx context.Context, device *androidmanagement.De "device.name", device.Name, "device.enterpriseSpecificId", device.HardwareInfo.EnterpriseSpecificId) enrollSecret, err := svc.ds.VerifyEnrollSecret(ctx, enrollmentTokenRequest.EnrollSecret) if err != nil && !fleet.IsNotFound(err) { - return ctxerr.Wrap(ctx, err, "verifying enroll secret") + return 0, ctxerr.Wrap(ctx, err, "verifying enroll secret") } if err == nil { host.TeamID = enrollSecret.GetTeamID() @@ -644,11 +812,14 @@ func (svc *Service) enrollHost(ctx context.Context, device *androidmanagement.De if enrollmentTokenRequest.IdpUUID != "" { if err := svc.ds.AssociateHostMDMIdPAccount(ctx, host.Host.UUID, enrollmentTokenRequest.IdpUUID); err != nil { - return ctxerr.Wrap(ctx, err, "updating IdP account on re-enrollment") + return 0, ctxerr.Wrap(ctx, err, "updating IdP account on re-enrollment") } } - return svc.updateHost(ctx, device, host, true) + if err := svc.updateHost(ctx, device, host, true); err != nil { + return 0, err + } + return host.Host.ID, nil } // Device is new to Fleet @@ -890,24 +1061,25 @@ func setAndroidHostUUID(host *fleet.AndroidHost, device *androidmanagement.Devic host.Device.EnterpriseSpecificID = ptr.String(uuidKey) } -func (svc *Service) addNewHost(ctx context.Context, device *androidmanagement.Device) error { +// addNewHost inserts a host that is new to Fleet and returns its Fleet host ID. +func (svc *Service) addNewHost(ctx context.Context, device *androidmanagement.Device) (uint, error) { // Validate before dereferencing device.SoftwareInfo/MemoryInfo/HardwareInfo // below. enrollHost already validates before dispatching here, but this keeps // addNewHost self-contained so it cannot panic if called from another path, // matching updateHost. if err := svc.validateDevice(ctx, device); err != nil { - return err + return 0, err } var enrollmentTokenRequest enrollmentTokenRequest err := json.Unmarshal([]byte(device.EnrollmentTokenData), &enrollmentTokenRequest) if err != nil { - return ctxerr.Wrap(ctx, err, "unmarshilling enrollment token data") + return 0, ctxerr.Wrap(ctx, err, "unmarshilling enrollment token data") } enrollSecret, err := svc.ds.VerifyEnrollSecret(ctx, enrollmentTokenRequest.EnrollSecret) if err != nil { - return ctxerr.Wrap(ctx, err, "verifying enroll secret") + return 0, ctxerr.Wrap(ctx, err, "verifying enroll secret") } // If the device was previously known restore the last-known team instead of the enrollment secret's default. @@ -922,14 +1094,14 @@ func (svc *Service) addNewHost(ctx context.Context, device *androidmanagement.De deviceID, err := svc.getDeviceID(ctx, device) if err != nil { - return ctxerr.Wrap(ctx, err, "getting device ID") + return 0, ctxerr.Wrap(ctx, err, "getting device ID") } gigsTotalDiskSpace, gigsDiskSpaceAvailable, percentDiskSpaceAvailable := svc.calculateAndroidStorageMetrics(ctx, device, false) computerName, err := getComputerName(ctx, svc.fleetDS, device, nil, "", enrollmentTokenRequest.IdpUUID) if err != nil { - return ctxerr.Wrap(ctx, err, "getting computer name for new host") + return 0, ctxerr.Wrap(ctx, err, "getting computer name for new host") } host := &fleet.AndroidHost{ @@ -959,11 +1131,11 @@ func (svc *Service) addNewHost(ctx context.Context, device *androidmanagement.De if device.AppliedPolicyName != "" { policy, err := svc.getPolicyID(ctx, device) if err != nil { - return ctxerr.Wrap(ctx, err, "getting Android policy ID") + return 0, ctxerr.Wrap(ctx, err, "getting Android policy ID") } policySyncTime, err := time.Parse(time.RFC3339, device.LastPolicySyncTime) if err != nil { - return ctxerr.Wrap(ctx, err, "parsing Android policy sync time") + return 0, ctxerr.Wrap(ctx, err, "parsing Android policy sync time") } host.Device.AppliedPolicyID = policy if device.AppliedPolicyVersion != 0 { @@ -975,24 +1147,24 @@ func (svc *Service) addNewHost(ctx context.Context, device *androidmanagement.De fleetHost, err := svc.ds.NewAndroidHost(ctx, host, companyOwned) if err != nil { - return ctxerr.Wrap(ctx, err, "enrolling Android host") + return 0, ctxerr.Wrap(ctx, err, "enrolling Android host") } // Populate the operating_systems table so the host can be filtered via // `GET /api/v1/fleet/hosts?os_name=Android&os_version=` and show // up in the /os_versions aggregation alongside other platforms. if err := svc.updateHostOperatingSystem(ctx, fleetHost.Host.ID, device); err != nil { - return err + return 0, err } if enrollmentTokenRequest.IdpUUID != "" { svc.logger.InfoContext(ctx, "associating android host with idp account", "host_uuid", host.UUID, "idp_uuid", enrollmentTokenRequest.IdpUUID) err := svc.ds.AssociateHostMDMIdPAccount(ctx, host.UUID, enrollmentTokenRequest.IdpUUID) if err != nil { - return ctxerr.Wrap(ctx, err, "associating host with idp account") + return 0, ctxerr.Wrap(ctx, err, "associating host with idp account") } if err := svc.fleetDS.MaybeAssociateHostWithScimUser(ctx, fleetHost.Host.ID); err != nil { - return ctxerr.Wrap(ctx, err, "associating android host with scim user") + return 0, ctxerr.Wrap(ctx, err, "associating android host with scim user") } } @@ -1004,21 +1176,21 @@ func (svc *Service) addNewHost(ctx context.Context, device *androidmanagement.De } if _, err := svc.fleetDS.CreatePendingCertificateTemplatesForNewHost(ctx, fleetHost.Host.UUID, certTeamID); err != nil { svc.logger.ErrorContext(ctx, "failed to create pending certificate templates for new host", "host_uuid", fleetHost.Host.UUID, "err", err) - return ctxerr.Wrap(ctx, err, "creating pending certificate templates for new host") + return 0, ctxerr.Wrap(ctx, err, "creating pending certificate templates for new host") } enterprise, err := svc.ds.GetEnterprise(ctx) if err != nil { - return ctxerr.Wrap(ctx, err, "get android enterprise") + return 0, ctxerr.Wrap(ctx, err, "get android enterprise") } err = worker.QueueRunAndroidSetupExperience(ctx, svc.fleetDS, svc.logger, fleetHost.Host.UUID, fleetHost.Host.TeamID, enterprise.Name()) if err != nil { - return ctxerr.Wrap(ctx, err, "enqueuing run android setup experience for host job") + return 0, ctxerr.Wrap(ctx, err, "enqueuing run android setup experience for host job") } - return nil + return fleetHost.Host.ID, nil } func getHardwareModel(device *androidmanagement.Device) string { diff --git a/server/mdm/android/service/pubsub_dedup_test.go b/server/mdm/android/service/pubsub_dedup_test.go new file mode 100644 index 0000000000..f70c337031 --- /dev/null +++ b/server/mdm/android/service/pubsub_dedup_test.go @@ -0,0 +1,385 @@ +package service + +import ( + "context" + "encoding/base64" + "testing" + "time" + + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/mdm/android" + common_mysql "github.com/fleetdm/fleet/v4/server/platform/mysql" + "github.com/go-json-experiment/json" + "github.com/stretchr/testify/require" + "google.golang.org/api/androidmanagement/v1" +) + +const dedupToken = "value" + +// wireDedupHost configures mockDS so both the ENROLLMENT (re-enroll) and +// STATUS_REPORT full-processing paths succeed for a single existing host, and +// returns that host. Individual tests override GetAndroidPubSubDedupStateFunc and +// the invocation flags they assert on. +func wireDedupHost(t *testing.T, mockDS *AndroidMockDS, hostID uint, hostUUID string) *fleet.AndroidHost { + t.Helper() + host := &fleet.AndroidHost{ + Host: &fleet.Host{ID: hostID, UUID: hostUUID}, + Device: &android.Device{ + HostID: hostID, + DeviceID: "existing-device", + EnterpriseSpecificID: &hostUUID, + }, + } + mockDS.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + return &fleet.AppConfig{MDM: fleet.MDM{AndroidEnabledAndConfigured: true}}, nil + } + mockDS.AndroidHostLiteFunc = func(ctx context.Context, esID string) (*fleet.AndroidHost, error) { + return host, nil + } + mockDS.UpdateAndroidHostFunc = func(ctx context.Context, h *fleet.AndroidHost, fromEnroll, companyOwned bool) error { + return nil + } + mockDS.VerifyEnrollSecretFunc = func(ctx context.Context, secret string) (*fleet.EnrollSecret, error) { + return &fleet.EnrollSecret{}, nil + } + mockDS.DeleteAllHostCertificateTemplatesFunc = func(ctx context.Context, hostUUID string) error { return nil } + mockDS.ClearHostMDMActionsFunc = func(ctx context.Context, id uint) error { return nil } + mockDS.ScimUserByHostIDFunc = func(ctx context.Context, id uint) (*fleet.ScimUser, error) { + return nil, common_mysql.NotFound("scim user") + } + mockDS.ListHostDeviceMappingFunc = func(ctx context.Context, id uint) ([]*fleet.HostDeviceMapping, error) { + return nil, nil + } + // STATUS_REPORT DELETED path. + mockDS.SetAndroidHostUnenrolledFunc = func(ctx context.Context, id uint) (bool, error) { return true, nil } + mockDS.GetHostMDMFunc = func(ctx context.Context, id uint) (*fleet.HostMDM, error) { + return &fleet.HostMDM{IsPersonalEnrollment: true}, nil + } + mockDS.MarkAllPendingVPPInstallsAsFailedForAndroidHostFunc = func(ctx context.Context, id uint) ([]*fleet.User, []fleet.ActivityDetails, error) { + return nil, nil, nil + } + mockDS.ListHostsLiteByIDsFunc = func(ctx context.Context, ids []uint) ([]*fleet.Host, error) { + return []*fleet.Host{{ID: hostID}}, nil + } + return host +} + +// makeEnrollmentEnvelope builds an ENROLLMENT PubSub message for a fixed device +// with the given Google envelope messageId/publishTime. +func makeEnrollmentEnvelope(t *testing.T, messageID, publishTime string) *android.PubSubMessage { + msg := createEnrollmentMessage(t, androidmanagement.Device{ + Name: createAndroidDeviceId("dedup"), + EnrollmentTokenData: `{"enroll_secret":"global"}`, + }) + msg.MessageID = messageID + msg.PublishTime = publishTime + return msg +} + +// makeStatusEnvelope builds a STATUS_REPORT PubSub message (optionally in the +// DELETED state) for a fixed device with the given envelope fields. +func makeStatusEnvelope(t *testing.T, esID, messageID, publishTime string, deleted bool) *android.PubSubMessage { + device := androidmanagement.Device{ + Name: createAndroidDeviceId("dedup"), + HardwareInfo: &androidmanagement.HardwareInfo{ + EnterpriseSpecificId: esID, + Brand: "TestBrand", + Model: "TestModel", + SerialNumber: "test-serial", + Hardware: "test-hardware", + }, + SoftwareInfo: &androidmanagement.SoftwareInfo{AndroidBuildNumber: "test-build", AndroidVersion: "1"}, + MemoryInfo: &androidmanagement.MemoryInfo{TotalRam: 8 * 1024 * 1024 * 1024}, + } + if deleted { + device.AppliedState = string(android.DeviceStateDeleted) + } + data, err := json.Marshal(device) + require.NoError(t, err) + return &android.PubSubMessage{ + Attributes: map[string]string{"notificationType": string(android.PubSubStatusReport)}, + Data: base64.StdEncoding.EncodeToString(data), + MessageID: messageID, + PublishTime: publishTime, + } +} + +func TestPubSubDedupAndStaleness(t *testing.T) { + const hostID = uint(10) + const hostUUID = "DEDUP-HOST-UUID" + + t.Run("duplicate ENROLLMENT messageId is a no-op", func(t *testing.T) { + svc, mockDS := createAndroidService(t) + wireDedupHost(t, mockDS, hostID, hostUUID) + mockDS.GetAndroidPubSubDedupStateFunc = func(ctx context.Context, id uint) (string, *time.Time, error) { + return "msg-dup", nil, nil + } + + msg := makeEnrollmentEnvelope(t, "msg-dup", "2026-07-22T10:00:00Z") + require.NoError(t, svc.ProcessPubSubPush(t.Context(), dedupToken, msg)) + + require.False(t, mockDS.UpdateAndroidHostFuncInvoked, "duplicate enrollment must not re-run updateHost") + require.False(t, mockDS.NewJobFuncInvoked, "duplicate enrollment must not re-queue setup experience") + require.False(t, mockDS.SetAndroidPubSubDedupStateFuncInvoked, "no state should be recorded on a skipped message") + }) + + t.Run("stale ENROLLMENT event time is skipped", func(t *testing.T) { + svc, mockDS := createAndroidService(t) + wireDedupHost(t, mockDS, hostID, hostUUID) + stored := time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC) + mockDS.GetAndroidPubSubDedupStateFunc = func(ctx context.Context, id uint) (string, *time.Time, error) { + return "other-msg", &stored, nil + } + + // publishTime older than the stored event time -> stale. + msg := makeEnrollmentEnvelope(t, "msg-new", "2020-01-01T00:00:00Z") + require.NoError(t, svc.ProcessPubSubPush(t.Context(), dedupToken, msg)) + + require.False(t, mockDS.UpdateAndroidHostFuncInvoked, "stale enrollment must not re-run updateHost") + require.False(t, mockDS.NewJobFuncInvoked, "stale enrollment must not re-queue setup experience") + }) + + t.Run("re-enrollment records dedup state", func(t *testing.T) { + svc, mockDS := createAndroidService(t) + wireDedupHost(t, mockDS, hostID, hostUUID) + mockDS.GetAndroidPubSubDedupStateFunc = func(ctx context.Context, id uint) (string, *time.Time, error) { + return "older-msg", nil, nil + } + var recordedID string + var recordedHostID uint + mockDS.SetAndroidPubSubDedupStateFunc = func(ctx context.Context, id uint, messageID string, eventTime *time.Time) error { + recordedHostID = id + recordedID = messageID + return nil + } + + msg := makeEnrollmentEnvelope(t, "msg-reenroll", "2026-07-22T10:00:00Z") + require.NoError(t, svc.ProcessPubSubPush(t.Context(), dedupToken, msg)) + + require.True(t, mockDS.UpdateAndroidHostFuncInvoked, "a non-duplicate re-enrollment must run updateHost") + require.True(t, mockDS.SetAndroidPubSubDedupStateFuncInvoked, "successful enrollment must record dedup state") + require.Equal(t, "msg-reenroll", recordedID) + require.Equal(t, hostID, recordedHostID) + }) + + t.Run("brand-new ENROLLMENT records dedup state without re-reading the host", func(t *testing.T) { + // enrollHost returns the host ID it resolved, so recording dedup state no longer + // depends on a post-enrollment lookup succeeding. A lookup that failed there would + // leave the delivery acked with no dedup state, and the redelivery would re-queue + // the setup experience. AndroidHostLite stays not-found for the whole call to prove + // no such lookup happens after enrollHost. + const newHostID = uint(77) + svc, mockDS := createAndroidService(t) + mockDS.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + return &fleet.AppConfig{MDM: fleet.MDM{AndroidEnabledAndConfigured: true}}, nil + } + var hostLiteCalls int + mockDS.AndroidHostLiteFunc = func(ctx context.Context, esID string) (*fleet.AndroidHost, error) { + hostLiteCalls++ + return nil, common_mysql.NotFound("android host lite") + } + mockDS.VerifyEnrollSecretFunc = func(ctx context.Context, secret string) (*fleet.EnrollSecret, error) { + return &fleet.EnrollSecret{}, nil + } + mockDS.NewAndroidHostFunc = func(ctx context.Context, h *fleet.AndroidHost, companyOwned bool) (*fleet.AndroidHost, error) { + return &fleet.AndroidHost{Host: &fleet.Host{ID: newHostID, UUID: hostUUID}, Device: h.Device}, nil + } + var recordedHostID uint + var recordedID string + mockDS.SetAndroidPubSubDedupStateFunc = func(ctx context.Context, id uint, messageID string, eventTime *time.Time) error { + recordedHostID = id + recordedID = messageID + return nil + } + + msg := makeEnrollmentEnvelope(t, "msg-brand-new", "2026-07-22T10:00:00Z") + require.NoError(t, svc.ProcessPubSubPush(t.Context(), dedupToken, msg)) + + require.True(t, mockDS.NewAndroidHostFuncInvoked, "a brand-new device must be inserted") + require.False(t, mockDS.GetAndroidPubSubDedupStateFuncInvoked, "a device new to Fleet has no dedup state to check") + require.True(t, mockDS.SetAndroidPubSubDedupStateFuncInvoked, "new enrollment must record dedup state") + require.Equal(t, newHostID, recordedHostID, "dedup state must be recorded against the newly inserted host") + require.Equal(t, "msg-brand-new", recordedID) + // One lookup for the dedup pre-check, one inside enrollHost. None afterwards. + require.Equal(t, 2, hostLiteCalls, "dedup state must not require a post-enrollment host lookup") + }) + + t.Run("ENROLLMENT DELETED on an already-unenrolled host emits no activity", func(t *testing.T) { + // The STATUS_REPORT DELETED branch already skips the activity when the state flip + // was a no-op; the ENROLLMENT DELETED branch must match, or a DELETED that arrives + // under both notification types adds a duplicate mdm_unenrolled row. + svc, mockDS := createAndroidService(t) + wireDedupHost(t, mockDS, hostID, hostUUID) + mockDS.GetAndroidPubSubDedupStateFunc = func(ctx context.Context, id uint) (string, *time.Time, error) { + return "", nil, nil // not a duplicate, not stale + } + mockDS.SetAndroidHostUnenrolledFunc = func(ctx context.Context, id uint) (bool, error) { + return false, nil // already unenrolled by an earlier delivery + } + mockDS.SetAndroidPubSubDedupStateFunc = func(ctx context.Context, id uint, messageID string, eventTime *time.Time) error { + return nil + } + + msg := makeEnrollmentEnvelope(t, "msg-deleted-enrollment", "2026-07-22T10:00:00Z") + device := androidmanagement.Device{ + Name: createAndroidDeviceId("dedup"), + EnrollmentTokenData: `{"enroll_secret":"global"}`, + AppliedState: string(android.DeviceStateDeleted), + HardwareInfo: &androidmanagement.HardwareInfo{ + EnterpriseSpecificId: hostUUID, + Brand: "TestBrand", + Model: "TestModel", + }, + SoftwareInfo: &androidmanagement.SoftwareInfo{AndroidBuildNumber: "test-build", AndroidVersion: "1"}, + MemoryInfo: &androidmanagement.MemoryInfo{TotalRam: 1024}, + } + data, err := json.Marshal(device) + require.NoError(t, err) + msg.Data = base64.StdEncoding.EncodeToString(data) + + require.NoError(t, svc.ProcessPubSubPush(t.Context(), dedupToken, msg)) + + require.True(t, mockDS.SetAndroidHostUnenrolledFuncInvoked, "the unenroll must still be attempted") + require.True(t, mockDS.SetAndroidPubSubDedupStateFuncInvoked, "dedup state must still be recorded") + require.False(t, mockDS.ListHostsLiteByIDsFuncInvoked, + "no display-name lookup means no duplicate mdm_unenrolled activity was emitted") + }) + + t.Run("duplicate STATUS_REPORT messageId is a no-op", func(t *testing.T) { + svc, mockDS := createAndroidService(t) + wireDedupHost(t, mockDS, hostID, hostUUID) + mockDS.GetAndroidPubSubDedupStateFunc = func(ctx context.Context, id uint) (string, *time.Time, error) { + return "msg-dup", nil, nil + } + + msg := makeStatusEnvelope(t, hostUUID, "msg-dup", "2026-07-22T10:00:00Z", false) + require.NoError(t, svc.ProcessPubSubPush(t.Context(), dedupToken, msg)) + + require.False(t, mockDS.UpdateAndroidHostFuncInvoked, "duplicate status report must not re-run updateHost") + require.False(t, mockDS.SetAndroidHostEnrolledFuncInvoked, "duplicate status report must not touch enrollment") + }) + + t.Run("out-of-order DELETED is skipped by staleness", func(t *testing.T) { + svc, mockDS := createAndroidService(t) + wireDedupHost(t, mockDS, hostID, hostUUID) + // A more-recent re-enrollment was already processed. + stored := time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC) + mockDS.GetAndroidPubSubDedupStateFunc = func(ctx context.Context, id uint) (string, *time.Time, error) { + return "recent-enroll-msg", &stored, nil + } + + // A stale DELETED redelivered out of order (older publishTime). + msg := makeStatusEnvelope(t, hostUUID, "stale-delete-msg", "2020-01-01T00:00:00Z", true) + require.NoError(t, svc.ProcessPubSubPush(t.Context(), dedupToken, msg)) + + require.False(t, mockDS.SetAndroidHostUnenrolledFuncInvoked, "a stale DELETED must not unenroll a live host") + }) + + t.Run("STATUS_REPORT recovers a wrongly-unenrolled host", func(t *testing.T) { + svc, mockDS := createAndroidService(t) + wireDedupHost(t, mockDS, hostID, hostUUID) + mockDS.GetAndroidPubSubDedupStateFunc = func(ctx context.Context, id uint) (string, *time.Time, error) { + return "", nil, nil // no prior state -> processed normally + } + var enrolledHostID uint + mockDS.SetAndroidHostEnrolledFunc = func(ctx context.Context, id uint) (bool, error) { + enrolledHostID = id + return true, nil + } + + msg := makeStatusEnvelope(t, hostUUID, "live-msg", "2026-07-22T10:00:00Z", false) + require.NoError(t, svc.ProcessPubSubPush(t.Context(), dedupToken, msg)) + + require.True(t, mockDS.UpdateAndroidHostFuncInvoked, "a live status report must run updateHost") + require.True(t, mockDS.SetAndroidHostEnrolledFuncInvoked, "a live status report must attempt enrollment recovery") + require.Equal(t, hostID, enrolledHostID) + require.True(t, mockDS.SetAndroidPubSubDedupStateFuncInvoked, "successful status report must record dedup state") + }) + + t.Run("stale STATUS_REPORT does not trigger enrollment recovery", func(t *testing.T) { + // Mirror of the bug being fixed: a stale STATUS_REPORT (older than the last + // processed event, e.g. one published before a legitimate unenroll) must not + // re-enroll the host. Staleness must short-circuit before SetAndroidHostEnrolled. + svc, mockDS := createAndroidService(t) + wireDedupHost(t, mockDS, hostID, hostUUID) + stored := time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC) + mockDS.GetAndroidPubSubDedupStateFunc = func(ctx context.Context, id uint) (string, *time.Time, error) { + return "unenroll-msg", &stored, nil + } + + msg := makeStatusEnvelope(t, hostUUID, "stale-report-msg", "2020-01-01T00:00:00Z", false) + require.NoError(t, svc.ProcessPubSubPush(t.Context(), dedupToken, msg)) + + require.False(t, mockDS.UpdateAndroidHostFuncInvoked, "stale status report must not run updateHost") + require.False(t, mockDS.SetAndroidHostEnrolledFuncInvoked, "stale status report must not re-enroll the host") + }) + + t.Run("equal event time with a different messageId is processed", func(t *testing.T) { + // A distinct message with the same timestamp is not a duplicate and not stale + // (staleness is strict "older than"), so it must be processed. + svc, mockDS := createAndroidService(t) + wireDedupHost(t, mockDS, hostID, hostUUID) + sameTime := time.Date(2026, 7, 22, 10, 0, 0, 0, time.UTC) + mockDS.GetAndroidPubSubDedupStateFunc = func(ctx context.Context, id uint) (string, *time.Time, error) { + return "stored-msg", &sameTime, nil + } + + msg := makeStatusEnvelope(t, hostUUID, "different-msg", "2026-07-22T10:00:00Z", false) + require.NoError(t, svc.ProcessPubSubPush(t.Context(), dedupToken, msg)) + + require.True(t, mockDS.UpdateAndroidHostFuncInvoked, "a distinct, non-stale message must be processed") + }) + + t.Run("WIPE ack records dedup state to block a later stale STATUS_REPORT", func(t *testing.T) { + svc, mockDS := createAndroidService(t) + mockDS.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + return &fleet.AppConfig{MDM: fleet.MDM{AndroidEnabledAndConfigured: true}}, nil + } + stored := &android.MDMAndroidCommand{ + CommandUUID: "cmd-wipe", + HostUUID: hostUUID, + OperationName: "enterprises/E/devices/D/operations/wipe-ack", + CommandType: string(android.MDMAndroidCommandTypeWipe), + Status: string(android.MDMAndroidCommandStatusPending), + } + mockDS.GetMDMAndroidCommandByOperationNameFunc = func(ctx context.Context, opName string) (*android.MDMAndroidCommand, error) { + return stored, nil + } + mockDS.UpdateMDMAndroidCommandStatusFunc = func(ctx context.Context, commandUUID, status string, errorCode, errorMessage *string) error { + return nil + } + mockDS.AndroidHostLiteByHostUUIDFunc = func(ctx context.Context, hUUID string) (*fleet.AndroidHost, error) { + return &fleet.AndroidHost{Host: &fleet.Host{ID: hostID, UUID: hUUID}}, nil + } + mockDS.GetHostMDMFunc = func(ctx context.Context, id uint) (*fleet.HostMDM, error) { + return &fleet.HostMDM{IsPersonalEnrollment: false}, nil + } + mockDS.ClearHostMDMActionsFunc = func(ctx context.Context, id uint) error { return nil } + mockDS.SetAndroidHostUnenrolledFunc = func(ctx context.Context, id uint) (bool, error) { return true, nil } + mockDS.ListHostsLiteByIDsFunc = func(ctx context.Context, ids []uint) ([]*fleet.Host, error) { + return []*fleet.Host{{ID: hostID}}, nil + } + var recordedID string + var recordedTime *time.Time + mockDS.SetAndroidPubSubDedupStateFunc = func(ctx context.Context, id uint, messageID string, eventTime *time.Time) error { + recordedID = messageID + recordedTime = eventTime + return nil + } + + body, err := json.Marshal(androidmanagement.Operation{Name: stored.OperationName, Done: true}) + require.NoError(t, err) + msg := &android.PubSubMessage{ + Attributes: map[string]string{"notificationType": string(android.PubSubCommand)}, + Data: base64.StdEncoding.EncodeToString(body), + MessageID: "wipe-msg", + PublishTime: "2026-07-22T12:00:00Z", + } + require.NoError(t, svc.ProcessPubSubPush(t.Context(), dedupToken, msg)) + + require.True(t, mockDS.SetAndroidPubSubDedupStateFuncInvoked, "WIPE ack unenroll must record dedup state") + require.Equal(t, "wipe-msg", recordedID) + require.NotNil(t, recordedTime, "WIPE ack must record the notification publish time as the event time") + require.Equal(t, time.Date(2026, 7, 22, 12, 0, 0, 0, time.UTC), recordedTime.UTC()) + }) +} diff --git a/server/mdm/android/service/reconcile_devices.go b/server/mdm/android/service/reconcile_devices.go index 5781e05a93..eef5627092 100644 --- a/server/mdm/android/service/reconcile_devices.go +++ b/server/mdm/android/service/reconcile_devices.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "log/slog" + "time" "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" "github.com/fleetdm/fleet/v4/server/fleet" @@ -88,6 +89,16 @@ func ReconcileAndroidDevices(ctx context.Context, ds fleet.Datastore, logger *sl logger.ErrorContext(ctx, "failed to mark android host unenrolled during reconcile", "host_id", dev.HostID, "err", derr) continue } + // Advance the dedup event time so a STATUS_REPORT that was already in the Pub/Sub + // queue before the AMAPI deletion, delivered afterwards, is dropped as stale by + // handlePubSubStatusReport instead of reverting this unenroll (SetAndroidHostEnrolled + // would otherwise re-enroll the host, causing a flip-flop and a duplicate + // mdm_unenrolled activity on the next reconcile). Best-effort: a missed record only + // weakens dedup for the redelivery window, so log and continue. + now := time.Now().UTC() + if derr := ds.SetAndroidPubSubDedupState(ctx, dev.HostID, "", &now); derr != nil { + logger.WarnContext(ctx, "failed to record android pubsub dedup state during reconcile", "host_id", dev.HostID, "err", derr) + } // Emit system activity to mirror Pub/Sub DELETED handling. var displayName, serial string if hosts, herr := ds.ListHostsLiteByIDs(ctx, []uint{dev.HostID}); herr == nil && len(hosts) == 1 && hosts[0] != nil { diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index 49f888460c..e2947e5ae4 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -1952,6 +1952,12 @@ type BulkSetAndroidHostsUnenrolledFunc func(ctx context.Context) error type SetAndroidHostUnenrolledFunc func(ctx context.Context, hostID uint) (bool, error) +type SetAndroidHostEnrolledFunc func(ctx context.Context, hostID uint) (bool, error) + +type GetAndroidPubSubDedupStateFunc func(ctx context.Context, hostID uint) (messageID string, eventTime *time.Time, err error) + +type SetAndroidPubSubDedupStateFunc func(ctx context.Context, hostID uint, messageID string, eventTime *time.Time) error + type NewAndroidHostFunc func(ctx context.Context, host *fleet.AndroidHost, companyOwned bool) (*fleet.AndroidHost, error) type SetAndroidEnabledAndConfiguredFunc func(ctx context.Context, configured bool) error @@ -5183,6 +5189,15 @@ type DataStore struct { SetAndroidHostUnenrolledFunc SetAndroidHostUnenrolledFunc SetAndroidHostUnenrolledFuncInvoked bool + SetAndroidHostEnrolledFunc SetAndroidHostEnrolledFunc + SetAndroidHostEnrolledFuncInvoked bool + + GetAndroidPubSubDedupStateFunc GetAndroidPubSubDedupStateFunc + GetAndroidPubSubDedupStateFuncInvoked bool + + SetAndroidPubSubDedupStateFunc SetAndroidPubSubDedupStateFunc + SetAndroidPubSubDedupStateFuncInvoked bool + NewAndroidHostFunc NewAndroidHostFunc NewAndroidHostFuncInvoked bool @@ -12441,6 +12456,27 @@ func (s *DataStore) SetAndroidHostUnenrolled(ctx context.Context, hostID uint) ( return s.SetAndroidHostUnenrolledFunc(ctx, hostID) } +func (s *DataStore) SetAndroidHostEnrolled(ctx context.Context, hostID uint) (bool, error) { + s.mu.Lock() + s.SetAndroidHostEnrolledFuncInvoked = true + s.mu.Unlock() + return s.SetAndroidHostEnrolledFunc(ctx, hostID) +} + +func (s *DataStore) GetAndroidPubSubDedupState(ctx context.Context, hostID uint) (messageID string, eventTime *time.Time, err error) { + s.mu.Lock() + s.GetAndroidPubSubDedupStateFuncInvoked = true + s.mu.Unlock() + return s.GetAndroidPubSubDedupStateFunc(ctx, hostID) +} + +func (s *DataStore) SetAndroidPubSubDedupState(ctx context.Context, hostID uint, messageID string, eventTime *time.Time) error { + s.mu.Lock() + s.SetAndroidPubSubDedupStateFuncInvoked = true + s.mu.Unlock() + return s.SetAndroidPubSubDedupStateFunc(ctx, hostID, messageID, eventTime) +} + func (s *DataStore) NewAndroidHost(ctx context.Context, host *fleet.AndroidHost, companyOwned bool) (*fleet.AndroidHost, error) { s.mu.Lock() s.NewAndroidHostFuncInvoked = true