Extract Android disk storage data (#32133)

Implements Android storage data extraction for issue #27080.
This commit is contained in:
Carlo 2025-08-22 12:27:15 -04:00 committed by GitHub
parent de6ef0544b
commit 8212180819
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 305 additions and 16 deletions

4
.gitignore vendored
View file

@ -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

View file

@ -168,9 +168,17 @@ const HostSummary = ({
);
const renderDiskSpaceSummary = () => {
const title = isAndroidHost ? (
<TooltipWrapper tipContent="Includes internal and removable storage (e.g. microSD card).">
Disk space
</TooltipWrapper>
) : (
"Disk space"
);
return (
<DataSet
title="Disk space"
title={title}
value={
<DiskSpaceIndicator
baseClass="info-flex"

View file

@ -120,6 +120,18 @@ func (ds *Datastore) NewAndroidHost(ctx context.Context, host *fleet.AndroidHost
if err != nil {
return ctxerr.Wrap(ctx, err, "creating new Android device")
}
// insert storage data into 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, "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

View file

@ -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")
}

View file

@ -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,

View file

@ -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)

View file

@ -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()