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