From 8212180819bfdfae029d88daa526ae7e560500f9 Mon Sep 17 00:00:00 2001
From: Carlo <1778532+cdcme@users.noreply.github.com>
Date: Fri, 22 Aug 2025 12:27:15 -0400
Subject: [PATCH] Extract Android disk storage data (#32133)
Implements Android storage data extraction for issue #27080.
---
.gitignore | 4 +
.../details/cards/HostSummary/HostSummary.tsx | 10 ++-
server/datastore/mysql/android.go | 24 ++++++
server/datastore/mysql/android_test.go | 70 ++++++++++++++++++
server/mdm/android/service/pubsub.go | 74 +++++++++++++++----
server/mdm/android/service/pubsub_test.go | 67 ++++++++++++++++-
server/service/integration_core_test.go | 72 ++++++++++++++++++
7 files changed, 305 insertions(+), 16 deletions(-)
diff --git a/.gitignore b/.gitignore
index 35789feef4..5cf04d821f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -108,3 +108,7 @@ tools/test_extensions/hello_world/windows-arm64
# Residual files when building fleet_tables extension.
fleet_tables_*.ext
+
+# Local dev files
+.env
+.tool-versions
diff --git a/frontend/pages/hosts/details/cards/HostSummary/HostSummary.tsx b/frontend/pages/hosts/details/cards/HostSummary/HostSummary.tsx
index 2137d5e300..680d8e8fc7 100644
--- a/frontend/pages/hosts/details/cards/HostSummary/HostSummary.tsx
+++ b/frontend/pages/hosts/details/cards/HostSummary/HostSummary.tsx
@@ -168,9 +168,17 @@ const HostSummary = ({
);
const renderDiskSpaceSummary = () => {
+ const title = isAndroidHost ? (
+
+ Disk space
+
+ ) : (
+ "Disk space"
+ );
+
return (
0 || host.Host.GigsDiskSpaceAvailable > 0 {
+ err = ds.SetOrUpdateHostDisksSpace(ctx, host.Host.ID,
+ host.Host.GigsDiskSpaceAvailable,
+ host.Host.PercentDiskSpaceAvailable,
+ host.Host.GigsTotalDiskSpace)
+ if err != nil {
+ return ctxerr.Wrap(ctx, err, "setting Android host disk space")
+ }
+ }
+
return nil
})
return host, err
@@ -195,6 +207,18 @@ func (ds *Datastore) UpdateAndroidHost(ctx context.Context, host *fleet.AndroidH
if err != nil {
return ctxerr.Wrap(ctx, err, "update Android device")
}
+
+ // update storage data in host_disks table for API consumption
+ if host.Host.GigsTotalDiskSpace > 0 || host.Host.GigsDiskSpaceAvailable > 0 {
+ err = ds.SetOrUpdateHostDisksSpace(ctx, host.Host.ID,
+ host.Host.GigsDiskSpaceAvailable,
+ host.Host.PercentDiskSpaceAvailable,
+ host.Host.GigsTotalDiskSpace)
+ if err != nil {
+ return ctxerr.Wrap(ctx, err, "updating Android host disk space")
+ }
+ }
+
return nil
})
return err
diff --git a/server/datastore/mysql/android_test.go b/server/datastore/mysql/android_test.go
index bdc848cfa7..5868154d12 100644
--- a/server/datastore/mysql/android_test.go
+++ b/server/datastore/mysql/android_test.go
@@ -26,6 +26,7 @@ func TestAndroid(t *testing.T) {
{"NewAndroidHost", testNewAndroidHost},
{"UpdateAndroidHost", testUpdateAndroidHost},
{"AndroidMDMStats", testAndroidMDMStats},
+ {"AndroidHostStorageData", testAndroidHostStorageData},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
@@ -287,3 +288,72 @@ func testAndroidMDMStats(t *testing.T, ds *Datastore) {
require.Equal(t, 1, solutionsStats[0].HostsCount)
require.Equal(t, serverURL, solutionsStats[0].ServerURL)
}
+
+func testAndroidHostStorageData(t *testing.T, ds *Datastore) {
+ test.AddBuiltinLabels(t, ds)
+
+ // Android host with storage data
+ const enterpriseSpecificID = "storage_test_enterprise"
+ host := &fleet.AndroidHost{
+ Host: &fleet.Host{
+ Hostname: "android-storage-test",
+ ComputerName: "Android Storage Test Device",
+ Platform: "android",
+ OSVersion: "Android 14",
+ Build: "UPB4.230623.005",
+ Memory: 8192, // 8GB RAM
+ TeamID: nil,
+ HardwareSerial: "STORAGE-TEST-SERIAL",
+ CPUType: "arm64-v8a",
+ HardwareModel: "Google Pixel 8 Pro",
+ HardwareVendor: "Google",
+ GigsTotalDiskSpace: 128.0, // 64GB system + 64GB external
+ GigsDiskSpaceAvailable: 35.0, // 10GB + 25GB available
+ PercentDiskSpaceAvailable: 27.34, // 35/128 * 100
+ },
+ Device: &android.Device{
+ DeviceID: "storage-test-device-id",
+ EnterpriseSpecificID: ptr.String(enterpriseSpecificID),
+ AndroidPolicyID: ptr.Uint(1),
+ LastPolicySyncTime: ptr.Time(time.Now().UTC().Truncate(time.Millisecond)),
+ },
+ }
+ host.SetNodeKey(enterpriseSpecificID)
+
+ // NewAndroidHost with storage data
+ result, err := ds.NewAndroidHost(testCtx(), host)
+ require.NoError(t, err)
+ require.NotZero(t, result.Host.ID)
+
+ // storage data was saved correctly
+ assert.Equal(t, 128.0, result.Host.GigsTotalDiskSpace, "Total disk space should be saved")
+ assert.Equal(t, 35.0, result.Host.GigsDiskSpaceAvailable, "Available disk space should be saved")
+ assert.Equal(t, 27.34, result.Host.PercentDiskSpaceAvailable, "Disk space percentage should be saved")
+
+ // AndroidHostLite provides lightweight Android data (no storage data)
+ resultLite, err := ds.AndroidHostLite(testCtx(), enterpriseSpecificID)
+ require.NoError(t, err)
+ assert.Equal(t, result.Host.ID, resultLite.Host.ID)
+
+ // UpdateAndroidHost preserves storage data
+ updatedHost := result
+ updatedHost.Host.Hostname = "updated-hostname"
+ updatedHost.Host.GigsTotalDiskSpace = 256.0 // Updated: 128GB system + 128GB external
+ updatedHost.Host.GigsDiskSpaceAvailable = 64.0 // Updated: 20GB + 44GB available
+ updatedHost.Host.PercentDiskSpaceAvailable = 25.0 // Updated: 64/256 * 100
+
+ err = ds.UpdateAndroidHost(testCtx(), updatedHost, false)
+ require.NoError(t, err)
+
+ // verify updated host data via host query (includes storage from host_disks)
+ finalResult, err := ds.AndroidHostLite(testCtx(), enterpriseSpecificID)
+ require.NoError(t, err)
+
+ // get host data to check storage updates
+ updatedFullHost, err := ds.Host(testCtx(), finalResult.Host.ID)
+ require.NoError(t, err)
+ assert.Equal(t, "updated-hostname", updatedFullHost.Hostname, "Hostname should be updated")
+ assert.Equal(t, 256.0, updatedFullHost.GigsTotalDiskSpace, "Updated total disk space should be saved in host_disks")
+ assert.Equal(t, 64.0, updatedFullHost.GigsDiskSpaceAvailable, "Updated available disk space should be saved in host_disks")
+ assert.Equal(t, 25.0, updatedFullHost.PercentDiskSpaceAvailable, "Updated disk space percentage should be saved in host_disks")
+}
diff --git a/server/mdm/android/service/pubsub.go b/server/mdm/android/service/pubsub.go
index cfc63d9239..83d710247d 100644
--- a/server/mdm/android/service/pubsub.go
+++ b/server/mdm/android/service/pubsub.go
@@ -254,6 +254,27 @@ func (svc *Service) updateHost(ctx context.Context, device *androidmanagement.De
host.Host.OSVersion = "Android " + device.SoftwareInfo.AndroidVersion
host.Host.Build = device.SoftwareInfo.AndroidBuildNumber
host.Host.Memory = device.MemoryInfo.TotalRam
+
+ if device.MemoryInfo.TotalInternalStorage > 0 {
+ totalStorageBytes := device.MemoryInfo.TotalInternalStorage
+
+ var totalAvailableBytes int64
+ for _, event := range device.MemoryEvents {
+ switch event.EventType {
+ case "EXTERNAL_STORAGE_DETECTED":
+ totalStorageBytes += event.ByteCount
+ case "INTERNAL_STORAGE_MEASURED", "EXTERNAL_STORAGE_MEASURED":
+ totalAvailableBytes += event.ByteCount
+ }
+ }
+
+ if totalStorageBytes > 0 {
+ host.Host.GigsTotalDiskSpace = float64(totalStorageBytes) / (1024 * 1024 * 1024)
+ host.Host.GigsDiskSpaceAvailable = float64(totalAvailableBytes) / (1024 * 1024 * 1024)
+ host.Host.PercentDiskSpaceAvailable = (float64(totalAvailableBytes) / float64(totalStorageBytes)) * 100
+ }
+ }
+
host.Host.HardwareSerial = device.HardwareInfo.SerialNumber
host.Host.CPUType = device.HardwareInfo.Hardware
host.Host.HardwareModel = svc.getComputerName(device)
@@ -291,22 +312,47 @@ func (svc *Service) addNewHost(ctx context.Context, device *androidmanagement.De
if err != nil {
return ctxerr.Wrap(ctx, err, "getting device ID")
}
+
+ var gigsTotalDiskSpace, gigsDiskSpaceAvailable, percentDiskSpaceAvailable float64
+ if device.MemoryInfo.TotalInternalStorage > 0 {
+ totalStorageBytes := device.MemoryInfo.TotalInternalStorage
+
+ var totalAvailableBytes int64
+ for _, event := range device.MemoryEvents {
+ switch event.EventType {
+ case "EXTERNAL_STORAGE_DETECTED":
+ totalStorageBytes += event.ByteCount
+ case "INTERNAL_STORAGE_MEASURED", "EXTERNAL_STORAGE_MEASURED":
+ totalAvailableBytes += event.ByteCount
+ }
+ }
+
+ if totalStorageBytes > 0 {
+ gigsTotalDiskSpace = float64(totalStorageBytes) / (1024 * 1024 * 1024)
+ gigsDiskSpaceAvailable = float64(totalAvailableBytes) / (1024 * 1024 * 1024)
+ percentDiskSpaceAvailable = (float64(totalAvailableBytes) / float64(totalStorageBytes)) * 100
+ }
+ }
+
host := &fleet.AndroidHost{
Host: &fleet.Host{
- TeamID: enrollSecret.GetTeamID(),
- ComputerName: svc.getComputerName(device),
- Hostname: svc.getComputerName(device),
- Platform: "android",
- OSVersion: "Android " + device.SoftwareInfo.AndroidVersion,
- Build: device.SoftwareInfo.AndroidBuildNumber,
- Memory: device.MemoryInfo.TotalRam,
- HardwareSerial: device.HardwareInfo.SerialNumber,
- CPUType: device.HardwareInfo.Hardware,
- HardwareModel: svc.getComputerName(device),
- HardwareVendor: device.HardwareInfo.Brand,
- LabelUpdatedAt: time.Time{},
- DetailUpdatedAt: time.Time{},
- UUID: device.HardwareInfo.EnterpriseSpecificId,
+ TeamID: enrollSecret.GetTeamID(),
+ ComputerName: svc.getComputerName(device),
+ Hostname: svc.getComputerName(device),
+ Platform: "android",
+ OSVersion: "Android " + device.SoftwareInfo.AndroidVersion,
+ Build: device.SoftwareInfo.AndroidBuildNumber,
+ Memory: device.MemoryInfo.TotalRam,
+ GigsTotalDiskSpace: gigsTotalDiskSpace,
+ GigsDiskSpaceAvailable: gigsDiskSpaceAvailable,
+ PercentDiskSpaceAvailable: percentDiskSpaceAvailable,
+ HardwareSerial: device.HardwareInfo.SerialNumber,
+ CPUType: device.HardwareInfo.Hardware,
+ HardwareModel: svc.getComputerName(device),
+ HardwareVendor: device.HardwareInfo.Brand,
+ LabelUpdatedAt: time.Time{},
+ DetailUpdatedAt: time.Time{},
+ UUID: device.HardwareInfo.EnterpriseSpecificId,
},
Device: &android.Device{
DeviceID: deviceID,
diff --git a/server/mdm/android/service/pubsub_test.go b/server/mdm/android/service/pubsub_test.go
index 85dff37aa3..0b69921470 100644
--- a/server/mdm/android/service/pubsub_test.go
+++ b/server/mdm/android/service/pubsub_test.go
@@ -193,6 +193,52 @@ func TestPubSubEnrollment(t *testing.T) {
})
}
+func TestAndroidStorageExtraction(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
+ }
+
+ mockDS.AndroidHostLiteFunc = func(ctx context.Context, enterpriseSpecificID string) (*fleet.AndroidHost, error) {
+ return nil, common_mysql.NotFound("android host lite mock")
+ }
+
+ mockDS.VerifyEnrollSecretFunc = func(ctx context.Context, secret string) (*fleet.EnrollSecret, error) {
+ return &fleet.EnrollSecret{Secret: "global"}, nil
+ }
+
+ var createdHost *fleet.AndroidHost
+ mockDS.NewAndroidHostFunc = func(ctx context.Context, host *fleet.AndroidHost) (*fleet.AndroidHost, error) {
+ createdHost = host
+ return host, nil
+ }
+
+ t.Run("extracts storage data from AMAPI device", func(t *testing.T) {
+ createdHost = nil // Reset
+
+ enrollmentMessage := createEnrollmentMessage(t, androidmanagement.Device{
+ Name: createAndroidDeviceId("storage-test"),
+ EnrollmentTokenData: `{"enroll_secret": "global"}`,
+ })
+
+ err := svc.ProcessPubSubPush(context.Background(), "value", enrollmentMessage)
+ require.NoError(t, err)
+
+ require.NotNil(t, createdHost)
+ require.NotNil(t, createdHost.Host)
+
+ // Total: 64GB (internal) + 64GB (external) = 128GB
+ // Available: 10GB (internal free) + 25GB (external free) = 35GB
+ // Percentage: 35/128 * 100 = 27.34%
+ require.Equal(t, 128.0, createdHost.Host.GigsTotalDiskSpace, "should calculate total storage (64GB internal + 64GB external)")
+ require.Equal(t, 35.0, createdHost.Host.GigsDiskSpaceAvailable, "should calculate total available storage (10GB + 25GB)")
+ require.InDelta(t, 27.34, createdHost.Host.PercentDiskSpaceAvailable, 0.1, "should calculate percentage (35/128*100=27.34%)")
+ })
+}
+
func createEnrollmentMessage(t *testing.T, deviceInfo androidmanagement.Device) *android.PubSubMessage {
deviceInfo.HardwareInfo = &androidmanagement.HardwareInfo{
EnterpriseSpecificId: strings.ToUpper(uuid.New().String()),
@@ -206,7 +252,26 @@ func createEnrollmentMessage(t *testing.T, deviceInfo androidmanagement.Device)
AndroidVersion: "1",
}
deviceInfo.MemoryInfo = &androidmanagement.MemoryInfo{
- TotalRam: 1000,
+ TotalRam: int64(8 * 1024 * 1024 * 1024), // 8GB RAM in bytes
+ TotalInternalStorage: int64(64 * 1024 * 1024 * 1024), // 64GB system partition
+ }
+
+ deviceInfo.MemoryEvents = []*androidmanagement.MemoryEvent{
+ {
+ EventType: "EXTERNAL_STORAGE_DETECTED",
+ ByteCount: int64(64 * 1024 * 1024 * 1024), // 64GB external/built-in storage total capacity
+ CreateTime: "2024-01-15T09:00:00Z",
+ },
+ {
+ EventType: "INTERNAL_STORAGE_MEASURED",
+ ByteCount: int64(10 * 1024 * 1024 * 1024), // 10GB free in system partition
+ CreateTime: "2024-01-15T10:00:00Z",
+ },
+ {
+ EventType: "EXTERNAL_STORAGE_MEASURED",
+ ByteCount: int64(25 * 1024 * 1024 * 1024), // 25GB free in external/built-in storage
+ CreateTime: "2024-01-15T10:00:00Z",
+ },
}
data, err := json.Marshal(deviceInfo)
diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go
index 9479e3c17d..38a630dcb5 100644
--- a/server/service/integration_core_test.go
+++ b/server/service/integration_core_test.go
@@ -13713,6 +13713,50 @@ func (s *integrationTestSuite) TestListAndroidHostsInLabel() {
require.Equal(t, len(hostIDs), countResp.Count)
}
+func (s *integrationTestSuite) TestAndroidHostStorageInAPI() {
+ t := s.T()
+ ctx := context.Background()
+
+ // Android host with storage data
+ hostID := createAndroidHostWithStorage(t, s.ds, nil)
+
+ // individual host endpoint
+ var hostResp getHostResponse
+ s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", hostID), nil, http.StatusOK, &hostResp)
+
+ require.NotNil(t, hostResp.Host)
+ require.Equal(t, "android", hostResp.Host.Platform)
+
+ // storage data is present in API response
+ assert.Equal(t, 128.0, hostResp.Host.GigsTotalDiskSpace, "API should return total disk space")
+ assert.Equal(t, 35.0, hostResp.Host.GigsDiskSpaceAvailable, "API should return available disk space")
+ assert.InDelta(t, 27.34, hostResp.Host.PercentDiskSpaceAvailable, 0.1, "API should return disk space percentage")
+
+ // list endpoint includes storage data
+ var listResp listHostsResponse
+ s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &listResp)
+
+ var androidHost *fleet.HostResponse
+ for _, host := range listResp.Hosts {
+ if host.ID == hostID {
+ androidHost = &host
+ break
+ }
+ }
+
+ require.NotNil(t, androidHost, "Android host should be in hosts list")
+ require.Equal(t, "android", androidHost.Platform)
+
+ // storage data in list endpoint
+ assert.Equal(t, 128.0, androidHost.GigsTotalDiskSpace, "Host list should include total disk space")
+ assert.Equal(t, 35.0, androidHost.GigsDiskSpaceAvailable, "Host list should include available disk space")
+ assert.InDelta(t, 27.34, androidHost.PercentDiskSpaceAvailable, 0.1, "Host list should include disk space percentage")
+
+ // clean up
+ err := s.ds.DeleteHost(ctx, hostID)
+ require.NoError(t, err)
+}
+
func createAndroidHosts(t *testing.T, ds *mysql.Datastore, count int, teamID *uint) []uint {
ids := make([]uint, 0, count)
for i := range count {
@@ -13742,6 +13786,34 @@ func createAndroidHosts(t *testing.T, ds *mysql.Datastore, count int, teamID *ui
return ids
}
+func createAndroidHostWithStorage(t *testing.T, ds *mysql.Datastore, teamID *uint) uint {
+ host := &fleet.AndroidHost{
+ Host: &fleet.Host{
+ Hostname: "android-storage-host",
+ ComputerName: "Android Storage Test Device",
+ Platform: "android",
+ OSVersion: "Android 14",
+ Build: "UPB4.230623.005",
+ Memory: 8192, // 8GB RAM
+ TeamID: teamID,
+ HardwareSerial: "STORAGE-TEST-" + uuid.NewString(),
+ GigsTotalDiskSpace: 128.0, // 64GB system + 64GB external
+ GigsDiskSpaceAvailable: 35.0, // 10GB + 25GB available
+ PercentDiskSpaceAvailable: 27.34, // 35/128 * 100
+ },
+ Device: &android.Device{
+ DeviceID: strings.ReplaceAll(uuid.NewString(), "-", ""),
+ EnterpriseSpecificID: ptr.String(uuid.NewString()),
+ AndroidPolicyID: ptr.Uint(1),
+ LastPolicySyncTime: ptr.Time(time.Now().Add(-time.Hour)),
+ },
+ }
+ host.SetNodeKey(*host.Device.EnterpriseSpecificID)
+ ahost, err := ds.NewAndroidHost(context.Background(), host)
+ require.NoError(t, err)
+ return ahost.Host.ID
+}
+
func (s *integrationTestSuite) TestHostCertificates() {
t := s.T()
ctx := context.Background()