Skip to content

Commit f674396

Browse files
authored
Handle Tailscale Device Missing lastSeen field (#92)
* handle missing lastSeen * make devicesnapshot migration * fix pre-commit * skips devices with no lastSeen while scanning for stale device * add tests to handle devices with no lastSeen * fix pre-commit
1 parent e700072 commit f674396

File tree

4 files changed

+72
-9
lines changed

4 files changed

+72
-9
lines changed
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# Generated by Django 5.2.2 on 2025-10-09 21:02
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
dependencies = [
8+
("tailscale", "0002_devicesnapshot_device_alter_device_latest_snapshot"),
9+
]
10+
11+
operations = [
12+
migrations.AlterField(
13+
model_name="devicesnapshot",
14+
name="last_seen",
15+
field=models.DateTimeField(
16+
blank=True, help_text="When device was last active on the tailnet.", null=True
17+
),
18+
),
19+
]

apps/tailscale/models.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,9 @@ class DeviceSnapshot(models.Model):
100100
null=True, blank=True, help_text="The expiration date of the device's auth key."
101101
)
102102
hostname = models.CharField(max_length=255, help_text="The machine name in the admin console.")
103-
last_seen = models.DateTimeField(help_text="When device was last active on the tailnet.")
103+
last_seen = models.DateTimeField(
104+
null=True, blank=True, help_text="When device was last active on the tailnet."
105+
)
104106
name = models.CharField(max_length=255, help_text="The MagicDNS name of the device.")
105107
node_id = models.CharField(max_length=128, help_text="The preferred identifier for a device.")
106108
os = models.CharField(

dagster_publish_mdm/assets/tailscale/tailscale_devices.py

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ def tailscale_append_device_snapshot_table(
4949
created=device["created"],
5050
expires=expires,
5151
hostname=device["hostname"],
52-
last_seen=device["lastSeen"],
52+
last_seen=device.get("lastSeen"),
5353
name=device["name"],
5454
node_id=device["nodeId"],
5555
os=device["os"],
@@ -111,13 +111,16 @@ def stale_tailscale_devices(
111111
hostname = device.get("hostname")
112112
device_id = device.get("id")
113113

114-
try:
115-
seen_time = dt.datetime.fromisoformat(last_seen.replace("Z", "+00:00"))
116-
if seen_time < time_delta:
117-
context.log.info(f"Device {hostname} last seen at {seen_time} — marking as stale.")
118-
stale_devices.append(device)
119-
except Exception as e:
120-
context.log.warning(f"Failed to process device {hostname}: (ID: {device_id}) {e}")
114+
if last_seen:
115+
try:
116+
seen_time = dt.datetime.fromisoformat(last_seen.replace("Z", "+00:00"))
117+
if seen_time < time_delta:
118+
context.log.info(
119+
f"Device {hostname} last seen at {seen_time} — marking as stale."
120+
)
121+
stale_devices.append(device)
122+
except Exception as e:
123+
context.log.warning(f"Failed to process device {hostname}: (ID: {device_id}) {e}")
121124

122125
context.add_output_metadata({"Stale Devices Preview": stale_devices[:2]})
123126
return stale_devices

tests/dagster_publish_mdm/tailscale/test_tailscale_devices.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,17 @@ def test_device_no_expiration(devices):
9595
assert device.expires is None
9696

9797

98+
@pytest.mark.django_db
99+
def test_device_with_no_last_seen(devices):
100+
"""Test asset handles devices JSON with no last seen."""
101+
devices["devices"][0].pop("lastSeen")
102+
assets.tailscale_append_device_snapshot_table(
103+
context=dg.build_asset_context(), tailscale_device_snapshot=devices
104+
)
105+
device = DeviceSnapshot.objects.first()
106+
assert device.last_seen is None
107+
108+
98109
@pytest.mark.django_db
99110
def test_tailscale_insert_and_update_devices(devices):
100111
"""Test asset inserts and updates devices."""
@@ -186,3 +197,31 @@ def test_stale_tailscale_devices(monkeypatch):
186197
dg.build_asset_context(), tailscale_device_snapshot=snapshot
187198
)
188199
assert len(result) == 1
200+
201+
202+
def test_stale_tailscale_devices_with_no_last_seen(monkeypatch):
203+
"""Test stale devices asset handles devices with no last seen."""
204+
205+
now = dt.datetime.now(dt.timezone.utc)
206+
monkeypatch.delenv("TAILSCALE_DEVICE_STALE_MINUTES", raising=False)
207+
208+
# Only one device: device-3, should be marked stale.
209+
snapshot = {
210+
"devices": [
211+
{
212+
"id": "1",
213+
"hostname": "device-1",
214+
}, # Device with no last seen, meaning its connected. Should NOT be deleted.
215+
{
216+
"id": "2",
217+
"hostname": "device-2",
218+
"lastSeen": (now - dt.timedelta(days=90, seconds=1)).strftime(
219+
TAILSCALE_FORMAT
220+
), # Device inactive for 90 days + 1 sec. Should be deleted.
221+
},
222+
]
223+
}
224+
result = assets.stale_tailscale_devices(
225+
dg.build_asset_context(), tailscale_device_snapshot=snapshot
226+
)
227+
assert len(result) == 1

0 commit comments

Comments
 (0)