From 761c23aaf5f20e525b5d045626c4a48c492e2baf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Poyraz=20K=C3=BC=C3=A7=C3=BCkarslan?= <83272398+PoyrazK@users.noreply.github.com> Date: Fri, 1 May 2026 22:04:36 +0300 Subject: [PATCH 01/13] feat(domain): extend InstanceStats with network and disk I/O fields Adds NetworkRxBytes, NetworkTxBytes, DiskReadBytes, DiskWriteBytes, and CPUTimeNanoseconds to InstanceStats. Also extends RawDockerStats with NetworkStats, BlkioStats, and BlkioStatEntry to support decoding these fields from Docker's stats payload. --- internal/core/domain/instance.go | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/internal/core/domain/instance.go b/internal/core/domain/instance.go index e3f30bb1f..c6ed9a4e5 100644 --- a/internal/core/domain/instance.go +++ b/internal/core/domain/instance.go @@ -91,10 +91,15 @@ type Instance struct { // InstanceStats contains real-time resource usage metrics. // Values are instantaneous snapshots from the compute backend. type InstanceStats struct { - CPUPercentage float64 `json:"cpu_percentage"` - MemoryUsageBytes float64 `json:"memory_usage_bytes"` - MemoryLimitBytes float64 `json:"memory_limit_bytes"` - MemoryPercentage float64 `json:"memory_percentage"` + CPUPercentage float64 `json:"cpu_percentage"` + MemoryUsageBytes float64 `json:"memory_usage_bytes"` + MemoryLimitBytes float64 `json:"memory_limit_bytes"` + MemoryPercentage float64 `json:"memory_percentage"` + NetworkRxBytes int64 `json:"network_rx_bytes"` + NetworkTxBytes int64 `json:"network_tx_bytes"` + DiskReadBytes int64 `json:"disk_read_bytes"` + DiskWriteBytes int64 `json:"disk_write_bytes"` + CPUTimeNanoseconds int64 `json:"cpu_time_nanoseconds,omitempty"` } // RawDockerStats mirrors Docker's stats payload for CPU/memory calculations. @@ -104,6 +109,7 @@ type RawDockerStats struct { TotalUsage uint64 `json:"total_usage"` } `json:"cpu_usage"` SystemCPUUsage uint64 `json:"system_cpu_usage"` + CPUTime uint64 `json:"cpu_time"` // libvirt: cumulative CPU time in nanoseconds } `json:"cpu_stats"` PreCPUStats struct { CPUUsage struct { @@ -115,4 +121,18 @@ type RawDockerStats struct { Usage uint64 `json:"usage"` Limit uint64 `json:"limit"` } `json:"memory_stats"` + NetworkStats map[string]struct { + RxBytes uint64 `json:"rx_bytes"` + TxBytes uint64 `json:"tx_bytes"` + } `json:"network_stats"` + BlkioStats struct { + IoServiceBytes []BlkioStatEntry `json:"ioservice_bytes"` + } `json:"blkio_stats"` +} + +// BlkioStatEntry represents a single block I/O stat entry. +type BlkioStatEntry struct { + Op string `json:"op"` + Device string `json:"device"` + Value uint64 `json:"value"` } From 62cd1c89b50cf4fa0b4e0c49fcf88f60a836d4e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Poyraz=20K=C3=BC=C3=A7=C3=BCkarslan?= <83272398+PoyrazK@users.noreply.github.com> Date: Fri, 1 May 2026 22:04:48 +0300 Subject: [PATCH 02/13] feat(services): extract network and disk I/O in calculateInstanceStats Processes NetworkStats and BlkioStats from RawDockerStats to populate the new NetworkRxBytes, NetworkTxBytes, DiskReadBytes, DiskWriteBytes, and CPUTimeNanoseconds fields in InstanceStats. --- internal/core/services/instance.go | 31 ++++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/internal/core/services/instance.go b/internal/core/services/instance.go index 222aafe23..811f76e8c 100644 --- a/internal/core/services/instance.go +++ b/internal/core/services/instance.go @@ -1264,11 +1264,34 @@ func (s *InstanceService) calculateInstanceStats(stats *domain.RawDockerStats) * memPercent = (memUsage / memLimit) * 100.0 } + // Sum network rx/tx across all interfaces + var rxBytes, txBytes int64 + for _, net := range stats.NetworkStats { + rxBytes += int64(net.RxBytes) + txBytes += int64(net.TxBytes) + } + + // Sum block read/write bytes + var readBytes, writeBytes int64 + for _, entry := range stats.BlkioStats.IoServiceBytes { + switch entry.Op { + case "read", "Read": + readBytes += int64(entry.Value) + case "write", "Write": + writeBytes += int64(entry.Value) + } + } + return &domain.InstanceStats{ - CPUPercentage: cpuPercent, - MemoryUsageBytes: memUsage, - MemoryLimitBytes: memLimit, - MemoryPercentage: memPercent, + CPUPercentage: cpuPercent, + MemoryUsageBytes: memUsage, + MemoryLimitBytes: memLimit, + MemoryPercentage: memPercent, + NetworkRxBytes: rxBytes, + NetworkTxBytes: txBytes, + DiskReadBytes: readBytes, + DiskWriteBytes: writeBytes, + CPUTimeNanoseconds: int64(stats.CPUStats.CPUTime), } } From aed24a43a42bc6c6c8859e01b369c0b82c96cad2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Poyraz=20K=C3=BC=C3=A7=C3=BCkarslan?= <83272398+PoyrazK@users.noreply.github.com> Date: Fri, 1 May 2026 22:05:04 +0300 Subject: [PATCH 03/13] feat(libvirt): add DomainGetCPUStats to LibvirtClient interface Adds DomainGetCPUStats method to support retrieving detailed CPU stats (nanoseconds, vCPU time) for instance stats enhancement. --- internal/repositories/libvirt/libvirt_client.go | 1 + internal/repositories/libvirt/mock_client_test.go | 7 +++++++ internal/repositories/libvirt/real_client.go | 9 +++++++++ 3 files changed, 17 insertions(+) diff --git a/internal/repositories/libvirt/libvirt_client.go b/internal/repositories/libvirt/libvirt_client.go index 4ff26d7a3..cbd27e67a 100644 --- a/internal/repositories/libvirt/libvirt_client.go +++ b/internal/repositories/libvirt/libvirt_client.go @@ -27,6 +27,7 @@ type LibvirtClient interface { DomainAttachDevice(ctx context.Context, dom libvirt.Domain, xml string) error DomainDetachDevice(ctx context.Context, dom libvirt.Domain, xml string) error DomainMemoryStats(ctx context.Context, dom libvirt.Domain, maxStats uint32, flags uint32) ([]libvirt.DomainMemoryStat, error) + DomainGetCPUStats(ctx context.Context, dom libvirt.Domain, nparams uint32, startCPU int32, ncpus uint32, flags libvirt.TypedParameterFlags) ([]libvirt.TypedParam, int32, error) // Network NetworkLookupByName(ctx context.Context, name string) (libvirt.Network, error) diff --git a/internal/repositories/libvirt/mock_client_test.go b/internal/repositories/libvirt/mock_client_test.go index 947e53ea7..e775e9447 100644 --- a/internal/repositories/libvirt/mock_client_test.go +++ b/internal/repositories/libvirt/mock_client_test.go @@ -83,6 +83,13 @@ func (m *MockLibvirtClient) DomainMemoryStats(ctx context.Context, dom libvirt.D return r0, args.Error(1) } +func (m *MockLibvirtClient) DomainGetCPUStats(ctx context.Context, dom libvirt.Domain, nparams uint32, startCPU int32, ncpus uint32, flags libvirt.TypedParameterFlags) ([]libvirt.TypedParam, int32, error) { + args := m.Called(ctx, dom, nparams, startCPU, ncpus, flags) + r0, _ := args.Get(0).([]libvirt.TypedParam) + r1, _ := args.Get(1).(int32) + return r0, r1, args.Error(2) +} + // Network func (m *MockLibvirtClient) NetworkLookupByName(ctx context.Context, name string) (libvirt.Network, error) { diff --git a/internal/repositories/libvirt/real_client.go b/internal/repositories/libvirt/real_client.go index 41a517a5a..4b89bf065 100644 --- a/internal/repositories/libvirt/real_client.go +++ b/internal/repositories/libvirt/real_client.go @@ -175,6 +175,15 @@ func (r *RealLibvirtClient) DomainMemoryStats(ctx context.Context, dom libvirt.D return r.conn.DomainMemoryStats(dom, maxStats, flags) } +func (r *RealLibvirtClient) DomainGetCPUStats(ctx context.Context, dom libvirt.Domain, nparams uint32, startCPU int32, ncpus uint32, flags libvirt.TypedParameterFlags) ([]libvirt.TypedParam, int32, error) { + select { + case <-ctx.Done(): + return nil, 0, ctx.Err() + default: + } + return r.conn.DomainGetCPUStats(dom, nparams, startCPU, ncpus, flags) +} + // Network func (r *RealLibvirtClient) NetworkLookupByName(ctx context.Context, name string) (libvirt.Network, error) { From 17bde064915ad10b240ff9321e173d29ea3d2b3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Poyraz=20K=C3=BC=C3=A7=C3=BCkarslan?= <83272398+PoyrazK@users.noreply.github.com> Date: Fri, 1 May 2026 22:05:19 +0300 Subject: [PATCH 04/13] feat(libvirt): include CPU time in GetInstanceStats JSON output Enhances GetInstanceStats to call DomainGetCPUStats and include cpu_time in the returned JSON, matching the RawDockerStats structure so the service layer can populate CPUTimeNanoseconds. --- internal/repositories/libvirt/adapter.go | 16 ++++++++++++++++ .../repositories/libvirt/adapter_unit_test.go | 1 + 2 files changed, 17 insertions(+) diff --git a/internal/repositories/libvirt/adapter.go b/internal/repositories/libvirt/adapter.go index 99b0f780c..573711c98 100644 --- a/internal/repositories/libvirt/adapter.go +++ b/internal/repositories/libvirt/adapter.go @@ -602,11 +602,27 @@ func (a *LibvirtAdapter) GetInstanceStats(ctx context.Context, id string) (io.Re } } + // Get CPU stats via DomainGetCPUStats + cpuParams, _, err := a.client.DomainGetCPUStats(ctx, dom, 0, 0, 0, 0) + var cpuTime uint64 + if err == nil { + for _, p := range cpuParams { + if p.Field == "cpu_time" && p.Value.D == 4 { // D==4 means ULLONG + if v, ok := p.Value.I.(uint64); ok { + cpuTime = v + } + } + } + } + statJSON, _ := json.Marshal(map[string]interface{}{ "memory_stats": map[string]uint64{ "usage": usage, "limit": limit, }, + "cpu_stats": map[string]uint64{ + "cpu_time": cpuTime, + }, }) return io.NopCloser(bytes.NewReader(statJSON)), nil } diff --git a/internal/repositories/libvirt/adapter_unit_test.go b/internal/repositories/libvirt/adapter_unit_test.go index 56425c4f6..35cd8951f 100644 --- a/internal/repositories/libvirt/adapter_unit_test.go +++ b/internal/repositories/libvirt/adapter_unit_test.go @@ -742,6 +742,7 @@ func TestLibvirtAdapter_StatsAndConsole(t *testing.T) { t.Run("GetInstanceStats", func(t *testing.T) { m.On("DomainLookupByName", mock.Anything, id).Return(dom, nil).Once() m.On("DomainMemoryStats", mock.Anything, dom, uint32(10), uint32(0)).Return([]libvirt.DomainMemoryStat{}, nil).Once() + m.On("DomainGetCPUStats", mock.Anything, dom, uint32(0), int32(0), uint32(0), libvirt.TypedParameterFlags(0)).Return([]libvirt.TypedParam{}, int32(0), nil).Once() m.On("DomainGetState", mock.Anything, dom, uint32(0)).Return(int32(1), int32(0), nil).Once() stats, err := a.GetInstanceStats(ctx, id) From 444eb31f42144231e8903de8b7e24589957f755a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Poyraz=20K=C3=BC=C3=A7=C3=BCkarslan?= <83272398+PoyrazK@users.noreply.github.com> Date: Fri, 1 May 2026 22:05:36 +0300 Subject: [PATCH 05/13] docs: regenerate swagger to include new InstanceStats fields Includes NetworkRxBytes, NetworkTxBytes, DiskReadBytes, DiskWriteBytes, and CPUTimeNanoseconds in the InstanceStats schema. --- docs/swagger/docs.go | 15 +++++++++++++++ docs/swagger/swagger.json | 15 +++++++++++++++ docs/swagger/swagger.yaml | 10 ++++++++++ 3 files changed, 40 insertions(+) diff --git a/docs/swagger/docs.go b/docs/swagger/docs.go index 49a8d4108..2261b3e62 100644 --- a/docs/swagger/docs.go +++ b/docs/swagger/docs.go @@ -9056,6 +9056,15 @@ const docTemplate = `{ "cpu_percentage": { "type": "number" }, + "cpu_time_nanoseconds": { + "type": "integer" + }, + "disk_read_bytes": { + "type": "integer" + }, + "disk_write_bytes": { + "type": "integer" + }, "memory_limit_bytes": { "type": "number" }, @@ -9064,6 +9073,12 @@ const docTemplate = `{ }, "memory_usage_bytes": { "type": "number" + }, + "network_rx_bytes": { + "type": "integer" + }, + "network_tx_bytes": { + "type": "integer" } } }, diff --git a/docs/swagger/swagger.json b/docs/swagger/swagger.json index a4e653353..4f456b02f 100644 --- a/docs/swagger/swagger.json +++ b/docs/swagger/swagger.json @@ -9048,6 +9048,15 @@ "cpu_percentage": { "type": "number" }, + "cpu_time_nanoseconds": { + "type": "integer" + }, + "disk_read_bytes": { + "type": "integer" + }, + "disk_write_bytes": { + "type": "integer" + }, "memory_limit_bytes": { "type": "number" }, @@ -9056,6 +9065,12 @@ }, "memory_usage_bytes": { "type": "number" + }, + "network_rx_bytes": { + "type": "integer" + }, + "network_tx_bytes": { + "type": "integer" } } }, diff --git a/docs/swagger/swagger.yaml b/docs/swagger/swagger.yaml index 5dba3664b..2d594fd86 100644 --- a/docs/swagger/swagger.yaml +++ b/docs/swagger/swagger.yaml @@ -663,12 +663,22 @@ definitions: properties: cpu_percentage: type: number + cpu_time_nanoseconds: + type: integer + disk_read_bytes: + type: integer + disk_write_bytes: + type: integer memory_limit_bytes: type: number memory_percentage: type: number memory_usage_bytes: type: number + network_rx_bytes: + type: integer + network_tx_bytes: + type: integer type: object domain.InstanceStatus: enum: From 122b67d97699667d36810af7d73aab442d28ab6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Poyraz=20K=C3=BC=C3=A7=C3=BCkarslan?= <83272398+PoyrazK@users.noreply.github.com> Date: Fri, 1 May 2026 22:05:56 +0300 Subject: [PATCH 06/13] docs: add GET /instances/:id/stats to API reference Documents the stats endpoint with all new fields: network_rx_bytes, network_tx_bytes, disk_read_bytes, disk_write_bytes, and cpu_time_nanoseconds. --- docs/api-reference.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/docs/api-reference.md b/docs/api-reference.md index 5c269278e..3f9d2e635 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -263,6 +263,39 @@ Get the VNC console URL for the instance. } ``` +### GET /instances/:id/stats +Get real-time resource usage stats for an instance (CPU, memory, network I/O, disk I/O). + +**Response:** +```json +{ + "cpu_percentage": 25.5, + "memory_usage_bytes": 524288000, + "memory_limit_bytes": 1073741824, + "memory_percentage": 48.8, + "network_rx_bytes": 1024000, + "network_tx_bytes": 512000, + "disk_read_bytes": 10240000, + "disk_write_bytes": 5120000, + "cpu_time_nanoseconds": 5000000000 +} +``` + +**Fields:** +- `cpu_percentage` (float): CPU usage as percentage of available +- `memory_usage_bytes` (float): Current memory usage in bytes +- `memory_limit_bytes` (float): Memory limit in bytes +- `memory_percentage` (float): Memory usage as percentage of limit +- `network_rx_bytes` (int): Total network bytes received +- `network_tx_bytes` (int): Total network bytes transmitted +- `disk_read_bytes` (int): Total disk bytes read +- `disk_write_bytes` (int): Total disk bytes written +- `cpu_time_nanoseconds` (int, optional): Cumulative CPU time in nanoseconds (Libvirt backend) + +**Error Responses:** +- `404` — Instance not found +- `503` — Backend stats unavailable + ### POST /instances/:id/pause Pause a running instance (freezes CPU, retains memory/network). From 6f320abca2cc9970f5f2b4e473fd89d0e78cdcd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Poyraz=20K=C3=BC=C3=A7=C3=BCkarslan?= <83272398+PoyrazK@users.noreply.github.com> Date: Fri, 1 May 2026 23:18:17 +0300 Subject: [PATCH 07/13] test: add coverage for new stats fields and error paths - Extends TestInstanceService_CalculateInstanceStats with cases for: - Network I/O across multiple interfaces (rx/tx summing) - Block I/O read/write (Op=="read"/"Read", Op=="write"/"Write") - CPUTimeNanoseconds from CPUStats.CPUTime - Combined all fields in one test case - Adds GetInstanceStats_ComputeError test in RepoErrors suite - Updates TestInstanceHandlerGetStats to verify all new fields are present in the JSON response wrapper --- .../core/services/instance_internal_test.go | 90 ++++++++++++++++--- internal/core/services/instance_unit_test.go | 9 ++ internal/handlers/instance_handler_test.go | 30 ++++++- 3 files changed, 118 insertions(+), 11 deletions(-) diff --git a/internal/core/services/instance_internal_test.go b/internal/core/services/instance_internal_test.go index f13698162..774cc50e9 100644 --- a/internal/core/services/instance_internal_test.go +++ b/internal/core/services/instance_internal_test.go @@ -98,20 +98,90 @@ func TestInstanceServiceInternalUpdateVolumesAfterLaunch(t *testing.T) { func TestInstanceService_CalculateInstanceStats(t *testing.T) { svc := &InstanceService{} - stats := &domain.RawDockerStats{} - stats.CPUStats.CPUUsage.TotalUsage = 1000 - stats.CPUStats.SystemCPUUsage = 10000 + t.Run("Basic CPU and Memory", func(t *testing.T) { + stats := &domain.RawDockerStats{} + stats.CPUStats.CPUUsage.TotalUsage = 1000 + stats.CPUStats.SystemCPUUsage = 10000 + stats.PreCPUStats.CPUUsage.TotalUsage = 500 + stats.PreCPUStats.SystemCPUUsage = 5000 + stats.MemoryStats.Usage = 1024 + stats.MemoryStats.Limit = 2048 + + res := svc.calculateInstanceStats(stats) + assert.InDelta(t, 10.0, res.CPUPercentage, 0.01) // (1000-500)/(10000-5000) * 100 = 10% + assert.InDelta(t, 50.0, res.MemoryPercentage, 0.01) + assert.Equal(t, int64(0), res.NetworkRxBytes) + assert.Equal(t, int64(0), res.NetworkTxBytes) + assert.Equal(t, int64(0), res.DiskReadBytes) + assert.Equal(t, int64(0), res.DiskWriteBytes) + }) + + t.Run("Network I/O multiple interfaces", func(t *testing.T) { + stats := &domain.RawDockerStats{} + stats.NetworkStats = map[string]struct { + RxBytes uint64 `json:"rx_bytes"` + TxBytes uint64 `json:"tx_bytes"` + }{ + "eth0": {RxBytes: 1000, TxBytes: 500}, + "eth1": {RxBytes: 2000, TxBytes: 1500}, + } + + res := svc.calculateInstanceStats(stats) + assert.Equal(t, int64(3000), res.NetworkRxBytes) // 1000 + 2000 + assert.Equal(t, int64(2000), res.NetworkTxBytes) // 500 + 1500 + }) - stats.PreCPUStats.CPUUsage.TotalUsage = 500 - stats.PreCPUStats.SystemCPUUsage = 5000 + t.Run("Block I/O read and write", func(t *testing.T) { + stats := &domain.RawDockerStats{} + stats.BlkioStats.IoServiceBytes = []domain.BlkioStatEntry{ + {Op: "read", Value: 5000}, + {Op: "write", Value: 3000}, + {Op: "Read", Value: 1000}, // uppercase variant + {Op: "Write", Value: 2000}, // uppercase variant + } + + res := svc.calculateInstanceStats(stats) + assert.Equal(t, int64(6000), res.DiskReadBytes) // 5000 + 1000 + assert.Equal(t, int64(5000), res.DiskWriteBytes) // 3000 + 2000 + }) - stats.MemoryStats.Usage = 1024 - stats.MemoryStats.Limit = 2048 + t.Run("CPU time nanoseconds", func(t *testing.T) { + stats := &domain.RawDockerStats{} + stats.CPUStats.CPUTime = 5000000000 // 5 nanoseconds - res := svc.calculateInstanceStats(stats) - assert.InDelta(t, 10.0, res.CPUPercentage, 0.01) // (1000-500)/(10000-5000) * 100 = 10% - assert.InDelta(t, 50.0, res.MemoryPercentage, 0.01) + res := svc.calculateInstanceStats(stats) + assert.Equal(t, int64(5000000000), res.CPUTimeNanoseconds) + }) + + t.Run("Combined all fields", func(t *testing.T) { + stats := &domain.RawDockerStats{} + stats.CPUStats.CPUUsage.TotalUsage = 800 + stats.CPUStats.SystemCPUUsage = 8000 + stats.PreCPUStats.CPUUsage.TotalUsage = 400 + stats.PreCPUStats.SystemCPUUsage = 4000 + stats.MemoryStats.Usage = 512 + stats.MemoryStats.Limit = 1024 + stats.CPUStats.CPUTime = 3000000000 + stats.NetworkStats = map[string]struct { + RxBytes uint64 `json:"rx_bytes"` + TxBytes uint64 `json:"tx_bytes"` + }{ + "eth0": {RxBytes: 500, TxBytes: 250}, + } + stats.BlkioStats.IoServiceBytes = []domain.BlkioStatEntry{ + {Op: "read", Value: 2048}, + } + + res := svc.calculateInstanceStats(stats) + assert.InDelta(t, 10.0, res.CPUPercentage, 0.01) + assert.InDelta(t, 50.0, res.MemoryPercentage, 0.01) + assert.Equal(t, int64(500), res.NetworkRxBytes) + assert.Equal(t, int64(250), res.NetworkTxBytes) + assert.Equal(t, int64(2048), res.DiskReadBytes) + assert.Equal(t, int64(0), res.DiskWriteBytes) + assert.Equal(t, int64(3000000000), res.CPUTimeNanoseconds) + }) } func TestInstanceService_FormatContainerName(t *testing.T) { diff --git a/internal/core/services/instance_unit_test.go b/internal/core/services/instance_unit_test.go index 45c92f187..5a1163d41 100644 --- a/internal/core/services/instance_unit_test.go +++ b/internal/core/services/instance_unit_test.go @@ -1578,6 +1578,15 @@ func testInstanceServiceUnitRepoErrors(t *testing.T) { require.Error(t, err) }) + t.Run("GetInstanceStats_ComputeError", func(t *testing.T) { + repo.On("GetByName", mock.Anything, "test-inst").Return(inst, nil).Once() + compute.On("GetInstanceStats", mock.Anything, "cid-1").Return(nil, fmt.Errorf("stats unavailable")).Once() + + _, err := svc.GetInstanceStats(ctx, "test-inst") + require.Error(t, err) + assert.Contains(t, err.Error(), "stats unavailable") + }) + t.Run("Exec_NotFound", func(t *testing.T) { repo.On("GetByName", mock.Anything, mock.Anything).Return(nil, svcerrors.New(svcerrors.NotFound, "not found")).Once() repo.On("GetByID", mock.Anything, mock.Anything).Return(nil, svcerrors.New(svcerrors.NotFound, "not found")).Once() diff --git a/internal/handlers/instance_handler_test.go b/internal/handlers/instance_handler_test.go index 1a0272e03..8a5a3c54c 100644 --- a/internal/handlers/instance_handler_test.go +++ b/internal/handlers/instance_handler_test.go @@ -457,7 +457,17 @@ func TestInstanceHandlerGetStats(t *testing.T) { r.GET(instancesPath+"/:id/stats", handler.GetStats) id := uuid.New().String() - stats := &domain.InstanceStats{CPUPercentage: 10.5, MemoryUsageBytes: 128} + stats := &domain.InstanceStats{ + CPUPercentage: 10.5, + MemoryUsageBytes: 128, + MemoryLimitBytes: 256, + MemoryPercentage: 50.0, + NetworkRxBytes: 1024, + NetworkTxBytes: 512, + DiskReadBytes: 4096, + DiskWriteBytes: 2048, + CPUTimeNanoseconds: 3000000000, + } mockSvc.On("GetInstanceStats", mock.Anything, id).Return(stats, nil) req := httptest.NewRequest(http.MethodGet, instancesPath+"/"+id+"/stats", nil) @@ -466,6 +476,24 @@ func TestInstanceHandlerGetStats(t *testing.T) { r.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) + + // Verify all new stats fields are present in the JSON response + // httputil.Success wraps data in {"data": {...}} + var wrapper struct { + Data map[string]interface{} `json:"data"` + } + err := json.Unmarshal(w.Body.Bytes(), &wrapper) + require.NoError(t, err) + + assert.Equal(t, 10.5, wrapper.Data["cpu_percentage"]) + assert.Equal(t, float64(128), wrapper.Data["memory_usage_bytes"]) + assert.Equal(t, float64(256), wrapper.Data["memory_limit_bytes"]) + assert.Equal(t, float64(50.0), wrapper.Data["memory_percentage"]) + assert.Equal(t, float64(1024), wrapper.Data["network_rx_bytes"]) + assert.Equal(t, float64(512), wrapper.Data["network_tx_bytes"]) + assert.Equal(t, float64(4096), wrapper.Data["disk_read_bytes"]) + assert.Equal(t, float64(2048), wrapper.Data["disk_write_bytes"]) + assert.Equal(t, float64(3000000000), wrapper.Data["cpu_time_nanoseconds"]) } func TestInstanceHandlerLaunchWithVolumesAndVPC(t *testing.T) { From 62e3bc6c4738038617fb144f83131d69b5c29423 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Poyraz=20K=C3=BC=C3=A7=C3=BCkarslan?= <83272398+PoyrazK@users.noreply.github.com> Date: Sat, 2 May 2026 13:50:33 +0300 Subject: [PATCH 08/13] chore: clarify TypedParam comment and omitempty on CPUTimeNanoseconds - Improve DomainGetCPUStats discriminator comment: "4 = ULLONG discriminator per go-libvirt TypedParamValue" (was "D==4 means ULLONG") - Add explanation comment on CPUTimeNanoseconds json tag noting it's Libvirt-only (Docker uses delta-based percentage instead) --- internal/core/domain/instance.go | 2 +- internal/repositories/libvirt/adapter.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/core/domain/instance.go b/internal/core/domain/instance.go index c6ed9a4e5..11ad7ffd2 100644 --- a/internal/core/domain/instance.go +++ b/internal/core/domain/instance.go @@ -99,7 +99,7 @@ type InstanceStats struct { NetworkTxBytes int64 `json:"network_tx_bytes"` DiskReadBytes int64 `json:"disk_read_bytes"` DiskWriteBytes int64 `json:"disk_write_bytes"` - CPUTimeNanoseconds int64 `json:"cpu_time_nanoseconds,omitempty"` + CPUTimeNanoseconds int64 `json:"cpu_time_nanoseconds,omitempty"` // only populated by Libvirt backend; Docker uses delta-based percentage instead } // RawDockerStats mirrors Docker's stats payload for CPU/memory calculations. diff --git a/internal/repositories/libvirt/adapter.go b/internal/repositories/libvirt/adapter.go index 573711c98..91bbba555 100644 --- a/internal/repositories/libvirt/adapter.go +++ b/internal/repositories/libvirt/adapter.go @@ -607,7 +607,7 @@ func (a *LibvirtAdapter) GetInstanceStats(ctx context.Context, id string) (io.Re var cpuTime uint64 if err == nil { for _, p := range cpuParams { - if p.Field == "cpu_time" && p.Value.D == 4 { // D==4 means ULLONG + if p.Field == "cpu_time" && p.Value.D == 4 { // 4 = ULLONG discriminator per go-libvirt TypedParamValue if v, ok := p.Value.I.(uint64); ok { cpuTime = v } From 5e9d98cf4f26103a7a26aae8f0c5ade53a3a7694 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Poyraz=20K=C3=BC=C3=A7=C3=BCkarslan?= <83272398+PoyrazK@users.noreply.github.com> Date: Sat, 2 May 2026 16:03:02 +0300 Subject: [PATCH 09/13] fix(lint): resolve G115 integer overflow and testifylint float-compare warnings --- internal/core/services/instance.go | 22 +++++++++++----------- internal/handlers/instance_handler_test.go | 6 +++--- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/internal/core/services/instance.go b/internal/core/services/instance.go index 6075d24e1..e113a6ac9 100644 --- a/internal/core/services/instance.go +++ b/internal/core/services/instance.go @@ -1290,21 +1290,21 @@ func (s *InstanceService) calculateInstanceStats(stats *domain.RawDockerStats) * memPercent = (memUsage / memLimit) * 100.0 } - // Sum network rx/tx across all interfaces - var rxBytes, txBytes int64 + // Sum network rx/tx across all interfaces (uint64 to avoid gosec G115 overflow warnings) + var rxBytes, txBytes uint64 for _, net := range stats.NetworkStats { - rxBytes += int64(net.RxBytes) - txBytes += int64(net.TxBytes) + rxBytes += net.RxBytes + txBytes += net.TxBytes } // Sum block read/write bytes - var readBytes, writeBytes int64 + var readBytes, writeBytes uint64 for _, entry := range stats.BlkioStats.IoServiceBytes { switch entry.Op { case "read", "Read": - readBytes += int64(entry.Value) + readBytes += entry.Value case "write", "Write": - writeBytes += int64(entry.Value) + writeBytes += entry.Value } } @@ -1313,10 +1313,10 @@ func (s *InstanceService) calculateInstanceStats(stats *domain.RawDockerStats) * MemoryUsageBytes: memUsage, MemoryLimitBytes: memLimit, MemoryPercentage: memPercent, - NetworkRxBytes: rxBytes, - NetworkTxBytes: txBytes, - DiskReadBytes: readBytes, - DiskWriteBytes: writeBytes, + NetworkRxBytes: int64(rxBytes), + NetworkTxBytes: int64(txBytes), + DiskReadBytes: int64(readBytes), + DiskWriteBytes: int64(writeBytes), CPUTimeNanoseconds: int64(stats.CPUStats.CPUTime), } } diff --git a/internal/handlers/instance_handler_test.go b/internal/handlers/instance_handler_test.go index 7df199f57..3034e0762 100644 --- a/internal/handlers/instance_handler_test.go +++ b/internal/handlers/instance_handler_test.go @@ -488,9 +488,9 @@ func TestInstanceHandlerGetStats(t *testing.T) { err := json.Unmarshal(w.Body.Bytes(), &wrapper) require.NoError(t, err) - assert.Equal(t, 10.5, wrapper.Data["cpu_percentage"]) - assert.Equal(t, float64(128), wrapper.Data["memory_usage_bytes"]) - assert.Equal(t, float64(256), wrapper.Data["memory_limit_bytes"]) + assert.InDelta(t, 10.5, wrapper.Data["cpu_percentage"], 0.01) + assert.InDelta(t, 128, wrapper.Data["memory_usage_bytes"], 0.01) + assert.InDelta(t, 256, wrapper.Data["memory_limit_bytes"], 0.01) assert.Equal(t, float64(50.0), wrapper.Data["memory_percentage"]) assert.Equal(t, float64(1024), wrapper.Data["network_rx_bytes"]) assert.Equal(t, float64(512), wrapper.Data["network_tx_bytes"]) From 33e6cf9db7243b9dd8e4b03f531c28d30ad849af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Poyraz=20K=C3=BC=C3=A7=C3=BCkarslan?= <83272398+PoyrazK@users.noreply.github.com> Date: Sat, 2 May 2026 16:39:48 +0300 Subject: [PATCH 10/13] fix(lint): use uint64 for network/disk stats to avoid G115 overflow warnings --- internal/core/domain/instance.go | 8 +++---- internal/core/services/instance.go | 10 ++++---- .../core/services/instance_internal_test.go | 24 +++++++++---------- internal/handlers/instance_handler_test.go | 8 +++---- 4 files changed, 25 insertions(+), 25 deletions(-) diff --git a/internal/core/domain/instance.go b/internal/core/domain/instance.go index 11ad7ffd2..95d87a737 100644 --- a/internal/core/domain/instance.go +++ b/internal/core/domain/instance.go @@ -95,10 +95,10 @@ type InstanceStats struct { MemoryUsageBytes float64 `json:"memory_usage_bytes"` MemoryLimitBytes float64 `json:"memory_limit_bytes"` MemoryPercentage float64 `json:"memory_percentage"` - NetworkRxBytes int64 `json:"network_rx_bytes"` - NetworkTxBytes int64 `json:"network_tx_bytes"` - DiskReadBytes int64 `json:"disk_read_bytes"` - DiskWriteBytes int64 `json:"disk_write_bytes"` + NetworkRxBytes uint64 `json:"network_rx_bytes"` + NetworkTxBytes uint64 `json:"network_tx_bytes"` + DiskReadBytes uint64 `json:"disk_read_bytes"` + DiskWriteBytes uint64 `json:"disk_write_bytes"` CPUTimeNanoseconds int64 `json:"cpu_time_nanoseconds,omitempty"` // only populated by Libvirt backend; Docker uses delta-based percentage instead } diff --git a/internal/core/services/instance.go b/internal/core/services/instance.go index e113a6ac9..7070a1801 100644 --- a/internal/core/services/instance.go +++ b/internal/core/services/instance.go @@ -1290,7 +1290,7 @@ func (s *InstanceService) calculateInstanceStats(stats *domain.RawDockerStats) * memPercent = (memUsage / memLimit) * 100.0 } - // Sum network rx/tx across all interfaces (uint64 to avoid gosec G115 overflow warnings) + // Sum network rx/tx across all interfaces var rxBytes, txBytes uint64 for _, net := range stats.NetworkStats { rxBytes += net.RxBytes @@ -1313,10 +1313,10 @@ func (s *InstanceService) calculateInstanceStats(stats *domain.RawDockerStats) * MemoryUsageBytes: memUsage, MemoryLimitBytes: memLimit, MemoryPercentage: memPercent, - NetworkRxBytes: int64(rxBytes), - NetworkTxBytes: int64(txBytes), - DiskReadBytes: int64(readBytes), - DiskWriteBytes: int64(writeBytes), + NetworkRxBytes: rxBytes, + NetworkTxBytes: txBytes, + DiskReadBytes: readBytes, + DiskWriteBytes: writeBytes, CPUTimeNanoseconds: int64(stats.CPUStats.CPUTime), } } diff --git a/internal/core/services/instance_internal_test.go b/internal/core/services/instance_internal_test.go index 774cc50e9..d54bb4dcf 100644 --- a/internal/core/services/instance_internal_test.go +++ b/internal/core/services/instance_internal_test.go @@ -111,10 +111,10 @@ func TestInstanceService_CalculateInstanceStats(t *testing.T) { res := svc.calculateInstanceStats(stats) assert.InDelta(t, 10.0, res.CPUPercentage, 0.01) // (1000-500)/(10000-5000) * 100 = 10% assert.InDelta(t, 50.0, res.MemoryPercentage, 0.01) - assert.Equal(t, int64(0), res.NetworkRxBytes) - assert.Equal(t, int64(0), res.NetworkTxBytes) - assert.Equal(t, int64(0), res.DiskReadBytes) - assert.Equal(t, int64(0), res.DiskWriteBytes) + assert.Equal(t, uint64(0), res.NetworkRxBytes) + assert.Equal(t, uint64(0), res.NetworkTxBytes) + assert.Equal(t, uint64(0), res.DiskReadBytes) + assert.Equal(t, uint64(0), res.DiskWriteBytes) }) t.Run("Network I/O multiple interfaces", func(t *testing.T) { @@ -128,8 +128,8 @@ func TestInstanceService_CalculateInstanceStats(t *testing.T) { } res := svc.calculateInstanceStats(stats) - assert.Equal(t, int64(3000), res.NetworkRxBytes) // 1000 + 2000 - assert.Equal(t, int64(2000), res.NetworkTxBytes) // 500 + 1500 + assert.Equal(t, uint64(3000), res.NetworkRxBytes) // 1000 + 2000 + assert.Equal(t, uint64(2000), res.NetworkTxBytes) // 500 + 1500 }) t.Run("Block I/O read and write", func(t *testing.T) { @@ -142,8 +142,8 @@ func TestInstanceService_CalculateInstanceStats(t *testing.T) { } res := svc.calculateInstanceStats(stats) - assert.Equal(t, int64(6000), res.DiskReadBytes) // 5000 + 1000 - assert.Equal(t, int64(5000), res.DiskWriteBytes) // 3000 + 2000 + assert.Equal(t, uint64(6000), res.DiskReadBytes) // 5000 + 1000 + assert.Equal(t, uint64(5000), res.DiskWriteBytes) // 3000 + 2000 }) t.Run("CPU time nanoseconds", func(t *testing.T) { @@ -176,10 +176,10 @@ func TestInstanceService_CalculateInstanceStats(t *testing.T) { res := svc.calculateInstanceStats(stats) assert.InDelta(t, 10.0, res.CPUPercentage, 0.01) assert.InDelta(t, 50.0, res.MemoryPercentage, 0.01) - assert.Equal(t, int64(500), res.NetworkRxBytes) - assert.Equal(t, int64(250), res.NetworkTxBytes) - assert.Equal(t, int64(2048), res.DiskReadBytes) - assert.Equal(t, int64(0), res.DiskWriteBytes) + assert.Equal(t, uint64(500), res.NetworkRxBytes) + assert.Equal(t, uint64(250), res.NetworkTxBytes) + assert.Equal(t, uint64(2048), res.DiskReadBytes) + assert.Equal(t, uint64(0), res.DiskWriteBytes) assert.Equal(t, int64(3000000000), res.CPUTimeNanoseconds) }) } diff --git a/internal/handlers/instance_handler_test.go b/internal/handlers/instance_handler_test.go index 3034e0762..1d6c3677a 100644 --- a/internal/handlers/instance_handler_test.go +++ b/internal/handlers/instance_handler_test.go @@ -492,10 +492,10 @@ func TestInstanceHandlerGetStats(t *testing.T) { assert.InDelta(t, 128, wrapper.Data["memory_usage_bytes"], 0.01) assert.InDelta(t, 256, wrapper.Data["memory_limit_bytes"], 0.01) assert.Equal(t, float64(50.0), wrapper.Data["memory_percentage"]) - assert.Equal(t, float64(1024), wrapper.Data["network_rx_bytes"]) - assert.Equal(t, float64(512), wrapper.Data["network_tx_bytes"]) - assert.Equal(t, float64(4096), wrapper.Data["disk_read_bytes"]) - assert.Equal(t, float64(2048), wrapper.Data["disk_write_bytes"]) + assert.Equal(t, uint64(1024), uint64(wrapper.Data["network_rx_bytes"].(float64))) + assert.Equal(t, uint64(512), uint64(wrapper.Data["network_tx_bytes"].(float64))) + assert.Equal(t, uint64(4096), uint64(wrapper.Data["disk_read_bytes"].(float64))) + assert.Equal(t, uint64(2048), uint64(wrapper.Data["disk_write_bytes"].(float64))) assert.Equal(t, float64(3000000000), wrapper.Data["cpu_time_nanoseconds"]) } From 949aecd2372ec028e7d689c91757ccc55737afaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Poyraz=20K=C3=BC=C3=A7=C3=BCkarslan?= <83272398+PoyrazK@users.noreply.github.com> Date: Sat, 2 May 2026 18:55:14 +0300 Subject: [PATCH 11/13] fix(lint): change all int64 stats fields to uint64 to avoid G115 overflow warnings --- internal/core/domain/instance.go | 2 +- internal/core/services/instance.go | 2 +- internal/core/services/instance_internal_test.go | 4 ++-- internal/handlers/instance_handler_test.go | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/internal/core/domain/instance.go b/internal/core/domain/instance.go index 95d87a737..d19c6e580 100644 --- a/internal/core/domain/instance.go +++ b/internal/core/domain/instance.go @@ -99,7 +99,7 @@ type InstanceStats struct { NetworkTxBytes uint64 `json:"network_tx_bytes"` DiskReadBytes uint64 `json:"disk_read_bytes"` DiskWriteBytes uint64 `json:"disk_write_bytes"` - CPUTimeNanoseconds int64 `json:"cpu_time_nanoseconds,omitempty"` // only populated by Libvirt backend; Docker uses delta-based percentage instead + CPUTimeNanoseconds uint64 `json:"cpu_time_nanoseconds,omitempty"` // only populated by Libvirt backend; Docker uses delta-based percentage instead } // RawDockerStats mirrors Docker's stats payload for CPU/memory calculations. diff --git a/internal/core/services/instance.go b/internal/core/services/instance.go index 7070a1801..4155f088b 100644 --- a/internal/core/services/instance.go +++ b/internal/core/services/instance.go @@ -1317,7 +1317,7 @@ func (s *InstanceService) calculateInstanceStats(stats *domain.RawDockerStats) * NetworkTxBytes: txBytes, DiskReadBytes: readBytes, DiskWriteBytes: writeBytes, - CPUTimeNanoseconds: int64(stats.CPUStats.CPUTime), + CPUTimeNanoseconds: stats.CPUStats.CPUTime, } } diff --git a/internal/core/services/instance_internal_test.go b/internal/core/services/instance_internal_test.go index d54bb4dcf..2af3a983a 100644 --- a/internal/core/services/instance_internal_test.go +++ b/internal/core/services/instance_internal_test.go @@ -151,7 +151,7 @@ func TestInstanceService_CalculateInstanceStats(t *testing.T) { stats.CPUStats.CPUTime = 5000000000 // 5 nanoseconds res := svc.calculateInstanceStats(stats) - assert.Equal(t, int64(5000000000), res.CPUTimeNanoseconds) + assert.Equal(t, uint64(5000000000), res.CPUTimeNanoseconds) }) t.Run("Combined all fields", func(t *testing.T) { @@ -180,7 +180,7 @@ func TestInstanceService_CalculateInstanceStats(t *testing.T) { assert.Equal(t, uint64(250), res.NetworkTxBytes) assert.Equal(t, uint64(2048), res.DiskReadBytes) assert.Equal(t, uint64(0), res.DiskWriteBytes) - assert.Equal(t, int64(3000000000), res.CPUTimeNanoseconds) + assert.Equal(t, uint64(3000000000), res.CPUTimeNanoseconds) }) } diff --git a/internal/handlers/instance_handler_test.go b/internal/handlers/instance_handler_test.go index 1d6c3677a..4a0a66200 100644 --- a/internal/handlers/instance_handler_test.go +++ b/internal/handlers/instance_handler_test.go @@ -491,12 +491,12 @@ func TestInstanceHandlerGetStats(t *testing.T) { assert.InDelta(t, 10.5, wrapper.Data["cpu_percentage"], 0.01) assert.InDelta(t, 128, wrapper.Data["memory_usage_bytes"], 0.01) assert.InDelta(t, 256, wrapper.Data["memory_limit_bytes"], 0.01) - assert.Equal(t, float64(50.0), wrapper.Data["memory_percentage"]) + assert.InDelta(t, 50.0, wrapper.Data["memory_percentage"], 0.01) assert.Equal(t, uint64(1024), uint64(wrapper.Data["network_rx_bytes"].(float64))) assert.Equal(t, uint64(512), uint64(wrapper.Data["network_tx_bytes"].(float64))) assert.Equal(t, uint64(4096), uint64(wrapper.Data["disk_read_bytes"].(float64))) assert.Equal(t, uint64(2048), uint64(wrapper.Data["disk_write_bytes"].(float64))) - assert.Equal(t, float64(3000000000), wrapper.Data["cpu_time_nanoseconds"]) + assert.Equal(t, uint64(3000000000), uint64(wrapper.Data["cpu_time_nanoseconds"].(float64))) } func TestInstanceHandlerLaunchWithVolumesAndVPC(t *testing.T) { From 3380c1bbbf19a8be2984f45d29095faa285d4d88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Poyraz=20K=C3=BC=C3=A7=C3=BCkarslan?= <83272398+PoyrazK@users.noreply.github.com> Date: Sat, 2 May 2026 21:39:07 +0300 Subject: [PATCH 12/13] fix: address PR 375 review suggestions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add typedParamULLONG constant (4) to clarify magic number - Add else branch comment for graceful degradation on DomainGetCPUStats error - Add GetInstanceStats_WithCPUStats subtest exercising cpu_time extraction - Fix api-reference.md field types: int → uint64 --- docs/api-reference.md | 10 +++++----- internal/repositories/libvirt/adapter.go | 7 ++++++- .../repositories/libvirt/adapter_unit_test.go | 19 +++++++++++++++++++ 3 files changed, 30 insertions(+), 6 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index 3f9d2e635..eaa0d2a6e 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -286,11 +286,11 @@ Get real-time resource usage stats for an instance (CPU, memory, network I/O, di - `memory_usage_bytes` (float): Current memory usage in bytes - `memory_limit_bytes` (float): Memory limit in bytes - `memory_percentage` (float): Memory usage as percentage of limit -- `network_rx_bytes` (int): Total network bytes received -- `network_tx_bytes` (int): Total network bytes transmitted -- `disk_read_bytes` (int): Total disk bytes read -- `disk_write_bytes` (int): Total disk bytes written -- `cpu_time_nanoseconds` (int, optional): Cumulative CPU time in nanoseconds (Libvirt backend) +- `network_rx_bytes` (uint64): Total network bytes received +- `network_tx_bytes` (uint64): Total network bytes transmitted +- `disk_read_bytes` (uint64): Total disk bytes read +- `disk_write_bytes` (uint64): Total disk bytes written +- `cpu_time_nanoseconds` (uint64, optional): Cumulative CPU time in nanoseconds (Libvirt backend) **Error Responses:** - `404` — Instance not found diff --git a/internal/repositories/libvirt/adapter.go b/internal/repositories/libvirt/adapter.go index 91bbba555..496ba1c62 100644 --- a/internal/repositories/libvirt/adapter.go +++ b/internal/repositories/libvirt/adapter.go @@ -46,6 +46,10 @@ const ( // Memory stat tags memStatTagActual = 5 memStatTagRSS = 6 + + // typedParamULLONG is the discriminator value for uint64 in libvirt.TypedParamValue. + // The go-libvirt library uses a struct{D uint32; I interface{}} where D==4 means ULLONG. + typedParamULLONG = 4 ) // domainStateName returns a human-readable name for a libvirt domain state. @@ -607,13 +611,14 @@ func (a *LibvirtAdapter) GetInstanceStats(ctx context.Context, id string) (io.Re var cpuTime uint64 if err == nil { for _, p := range cpuParams { - if p.Field == "cpu_time" && p.Value.D == 4 { // 4 = ULLONG discriminator per go-libvirt TypedParamValue + if p.Field == "cpu_time" && p.Value.D == typedParamULLONG { if v, ok := p.Value.I.(uint64); ok { cpuTime = v } } } } + // Stats are best-effort; CPU time is omitted on error (graceful degradation) statJSON, _ := json.Marshal(map[string]interface{}{ "memory_stats": map[string]uint64{ diff --git a/internal/repositories/libvirt/adapter_unit_test.go b/internal/repositories/libvirt/adapter_unit_test.go index 35e968b19..0e4946655 100644 --- a/internal/repositories/libvirt/adapter_unit_test.go +++ b/internal/repositories/libvirt/adapter_unit_test.go @@ -750,6 +750,25 @@ func TestLibvirtAdapter_StatsAndConsole(t *testing.T) { assert.NotNil(t, stats) }) + t.Run("GetInstanceStats_WithCPUStats", func(t *testing.T) { + cpuParams := []libvirt.TypedParam{ + {Field: "cpu_time", Value: libvirt.TypedParamValue{D: typedParamULLONG, I: uint64(5000000000)}}, + } + m.On("DomainLookupByName", mock.Anything, id).Return(dom, nil).Once() + m.On("DomainMemoryStats", mock.Anything, dom, uint32(10), uint32(0)).Return([]libvirt.DomainMemoryStat{}, nil).Once() + m.On("DomainGetCPUStats", mock.Anything, dom, uint32(0), int32(0), uint32(0), libvirt.TypedParameterFlags(0)).Return(cpuParams, int32(0), nil).Once() + m.On("DomainGetState", mock.Anything, dom, uint32(0)).Return(int32(1), int32(0), nil).Once() + + stats, err := a.GetInstanceStats(ctx, id) + require.NoError(t, err) + assert.NotNil(t, stats) + + // Read and verify the stats JSON contains the CPU time + statsBytes, err := io.ReadAll(stats) + require.NoError(t, err) + assert.Contains(t, string(statsBytes), `"cpu_time":5000000000`) + }) + t.Run("GetConsoleURL", func(t *testing.T) { xml := "" m.On("DomainLookupByName", mock.Anything, id).Return(dom, nil).Once() From 51c4d999829a2c3ef8f16a4ac6946e5e2ac8e7dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Poyraz=20K=C3=BC=C3=A7=C3=BCkarslan?= <83272398+PoyrazK@users.noreply.github.com> Date: Sun, 3 May 2026 22:02:29 +0300 Subject: [PATCH 13/13] fix: address PR 375 review findings (round 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs/api-reference.md: change 503 → 500 for stats error response - swagger.{yaml,json,docs.go}: add format:uint64 to integer stat fields (cpu_time_nanoseconds, disk_read_bytes, disk_write_bytes, network_rx_bytes, network_tx_bytes) - swagger.json: update /instances/{id}/stats description to list CPU, memory, network I/O, disk I/O, and CPU time nanoseconds - instance_handler.go: update @Description to match full response schema - instance_handler_test.go: unmarshal response into domain.InstanceStats directly instead of map[string]interface{} with panic-prone type assertions - adapter.go: propagate DomainGetCPUStats errors instead of silently continuing with cpuTime=0 (graceful degradation removed per review) --- docs/api-reference.md | 2 +- docs/swagger/docs.go | 7 ++++++- docs/swagger/swagger.json | 7 ++++++- docs/swagger/swagger.yaml | 11 ++++++++--- internal/core/domain/instance.go | 10 +++++----- internal/handlers/instance_handler.go | 2 +- internal/handlers/instance_handler_test.go | 20 ++++++++++---------- internal/repositories/libvirt/adapter.go | 14 +++++++------- 8 files changed, 44 insertions(+), 29 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index eaa0d2a6e..2d9527284 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -294,7 +294,7 @@ Get real-time resource usage stats for an instance (CPU, memory, network I/O, di **Error Responses:** - `404` — Instance not found -- `503` — Backend stats unavailable +- `500` — Backend stats unavailable (internal error) ### POST /instances/:id/pause Pause a running instance (freezes CPU, retains memory/network). diff --git a/docs/swagger/docs.go b/docs/swagger/docs.go index 08ad937f8..f519ae06e 100644 --- a/docs/swagger/docs.go +++ b/docs/swagger/docs.go @@ -4191,7 +4191,7 @@ const docTemplate = `{ "APIKeyAuth": [] } ], - "description": "Gets real-time CPU and Memory usage for a compute instance", + "description": "Gets real-time CPU, memory, network I/O, disk I/O, and CPU time (nanoseconds) for a compute instance", "produces": [ "application/json" ], @@ -9058,12 +9058,15 @@ const docTemplate = `{ }, "cpu_time_nanoseconds": { "description": "only populated by Libvirt backend; Docker uses delta-based percentage instead", + "format": "uint64", "type": "integer" }, "disk_read_bytes": { + "format": "uint64", "type": "integer" }, "disk_write_bytes": { + "format": "uint64", "type": "integer" }, "memory_limit_bytes": { @@ -9076,9 +9079,11 @@ const docTemplate = `{ "type": "number" }, "network_rx_bytes": { + "format": "uint64", "type": "integer" }, "network_tx_bytes": { + "format": "uint64", "type": "integer" } } diff --git a/docs/swagger/swagger.json b/docs/swagger/swagger.json index 25d8f1054..61c6cdf54 100644 --- a/docs/swagger/swagger.json +++ b/docs/swagger/swagger.json @@ -4183,7 +4183,7 @@ "APIKeyAuth": [] } ], - "description": "Gets real-time CPU and Memory usage for a compute instance", + "description": "Gets real-time CPU, memory, network I/O, disk I/O, and CPU time (nanoseconds) for a compute instance", "produces": [ "application/json" ], @@ -9050,12 +9050,15 @@ }, "cpu_time_nanoseconds": { "description": "only populated by Libvirt backend; Docker uses delta-based percentage instead", + "format": "uint64", "type": "integer" }, "disk_read_bytes": { + "format": "uint64", "type": "integer" }, "disk_write_bytes": { + "format": "uint64", "type": "integer" }, "memory_limit_bytes": { @@ -9068,9 +9071,11 @@ "type": "number" }, "network_rx_bytes": { + "format": "uint64", "type": "integer" }, "network_tx_bytes": { + "format": "uint64", "type": "integer" } } diff --git a/docs/swagger/swagger.yaml b/docs/swagger/swagger.yaml index 1eced7fbf..5b4e16020 100644 --- a/docs/swagger/swagger.yaml +++ b/docs/swagger/swagger.yaml @@ -664,13 +664,15 @@ definitions: cpu_percentage: type: number cpu_time_nanoseconds: - description: only populated by Libvirt backend; Docker uses delta-based percentage - instead + description: "only populated by Libvirt backend; Docker uses delta-based percentage instead" type: integer + format: uint64 disk_read_bytes: type: integer + format: uint64 disk_write_bytes: type: integer + format: uint64 memory_limit_bytes: type: number memory_percentage: @@ -679,8 +681,10 @@ definitions: type: number network_rx_bytes: type: integer + format: uint64 network_tx_bytes: type: integer + format: uint64 type: object domain.InstanceStatus: enum: @@ -5169,7 +5173,8 @@ paths: - instances /instances/{id}/stats: get: - description: Gets real-time CPU and Memory usage for a compute instance + description: Gets real-time CPU, memory, network I/O, disk I/O, and CPU time + (nanoseconds) for a compute instance parameters: - description: Instance ID in: path diff --git a/internal/core/domain/instance.go b/internal/core/domain/instance.go index d19c6e580..e57cb5b82 100644 --- a/internal/core/domain/instance.go +++ b/internal/core/domain/instance.go @@ -95,11 +95,11 @@ type InstanceStats struct { MemoryUsageBytes float64 `json:"memory_usage_bytes"` MemoryLimitBytes float64 `json:"memory_limit_bytes"` MemoryPercentage float64 `json:"memory_percentage"` - NetworkRxBytes uint64 `json:"network_rx_bytes"` - NetworkTxBytes uint64 `json:"network_tx_bytes"` - DiskReadBytes uint64 `json:"disk_read_bytes"` - DiskWriteBytes uint64 `json:"disk_write_bytes"` - CPUTimeNanoseconds uint64 `json:"cpu_time_nanoseconds,omitempty"` // only populated by Libvirt backend; Docker uses delta-based percentage instead + NetworkRxBytes uint64 `json:"network_rx_bytes" swagger:"format=int64"` + NetworkTxBytes uint64 `json:"network_tx_bytes" swagger:"format=int64"` + DiskReadBytes uint64 `json:"disk_read_bytes" swagger:"format=int64"` + DiskWriteBytes uint64 `json:"disk_write_bytes" swagger:"format=int64"` + CPUTimeNanoseconds uint64 `json:"cpu_time_nanoseconds,omitempty" swagger:"format=int64"` // only populated by Libvirt backend; Docker uses delta-based percentage instead } // RawDockerStats mirrors Docker's stats payload for CPU/memory calculations. diff --git a/internal/handlers/instance_handler.go b/internal/handlers/instance_handler.go index c6672e9f3..a3ce215f5 100644 --- a/internal/handlers/instance_handler.go +++ b/internal/handlers/instance_handler.go @@ -386,7 +386,7 @@ func (h *InstanceHandler) Terminate(c *gin.Context) { // GetStats returns instance metrics // @Summary Get instance stats -// @Description Gets real-time CPU and Memory usage for a compute instance +// @Description Gets real-time CPU, memory, network I/O, disk I/O, and CPU time (nanoseconds) for a compute instance // @Tags instances // @Produce json // @Security APIKeyAuth diff --git a/internal/handlers/instance_handler_test.go b/internal/handlers/instance_handler_test.go index 4a0a66200..aaa21e598 100644 --- a/internal/handlers/instance_handler_test.go +++ b/internal/handlers/instance_handler_test.go @@ -483,20 +483,20 @@ func TestInstanceHandlerGetStats(t *testing.T) { // Verify all new stats fields are present in the JSON response // httputil.Success wraps data in {"data": {...}} var wrapper struct { - Data map[string]interface{} `json:"data"` + Data domain.InstanceStats `json:"data"` } err := json.Unmarshal(w.Body.Bytes(), &wrapper) require.NoError(t, err) - assert.InDelta(t, 10.5, wrapper.Data["cpu_percentage"], 0.01) - assert.InDelta(t, 128, wrapper.Data["memory_usage_bytes"], 0.01) - assert.InDelta(t, 256, wrapper.Data["memory_limit_bytes"], 0.01) - assert.InDelta(t, 50.0, wrapper.Data["memory_percentage"], 0.01) - assert.Equal(t, uint64(1024), uint64(wrapper.Data["network_rx_bytes"].(float64))) - assert.Equal(t, uint64(512), uint64(wrapper.Data["network_tx_bytes"].(float64))) - assert.Equal(t, uint64(4096), uint64(wrapper.Data["disk_read_bytes"].(float64))) - assert.Equal(t, uint64(2048), uint64(wrapper.Data["disk_write_bytes"].(float64))) - assert.Equal(t, uint64(3000000000), uint64(wrapper.Data["cpu_time_nanoseconds"].(float64))) + assert.InDelta(t, 10.5, wrapper.Data.CPUPercentage, 0.01) + assert.InDelta(t, 128, wrapper.Data.MemoryUsageBytes, 0.01) + assert.InDelta(t, 256, wrapper.Data.MemoryLimitBytes, 0.01) + assert.InDelta(t, 50.0, wrapper.Data.MemoryPercentage, 0.01) + assert.Equal(t, uint64(1024), wrapper.Data.NetworkRxBytes) + assert.Equal(t, uint64(512), wrapper.Data.NetworkTxBytes) + assert.Equal(t, uint64(4096), wrapper.Data.DiskReadBytes) + assert.Equal(t, uint64(2048), wrapper.Data.DiskWriteBytes) + assert.Equal(t, uint64(3000000000), wrapper.Data.CPUTimeNanoseconds) } func TestInstanceHandlerLaunchWithVolumesAndVPC(t *testing.T) { diff --git a/internal/repositories/libvirt/adapter.go b/internal/repositories/libvirt/adapter.go index 496ba1c62..f10857e17 100644 --- a/internal/repositories/libvirt/adapter.go +++ b/internal/repositories/libvirt/adapter.go @@ -609,16 +609,16 @@ func (a *LibvirtAdapter) GetInstanceStats(ctx context.Context, id string) (io.Re // Get CPU stats via DomainGetCPUStats cpuParams, _, err := a.client.DomainGetCPUStats(ctx, dom, 0, 0, 0, 0) var cpuTime uint64 - if err == nil { - for _, p := range cpuParams { - if p.Field == "cpu_time" && p.Value.D == typedParamULLONG { - if v, ok := p.Value.I.(uint64); ok { - cpuTime = v - } + if err != nil { + return nil, fmt.Errorf("DomainGetCPUStats failed: %w", err) + } + for _, p := range cpuParams { + if p.Field == "cpu_time" && p.Value.D == typedParamULLONG { + if v, ok := p.Value.I.(uint64); ok { + cpuTime = v } } } - // Stats are best-effort; CPU time is omitted on error (graceful degradation) statJSON, _ := json.Marshal(map[string]interface{}{ "memory_stats": map[string]uint64{