Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions internal/api/appid.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package api

import (
"fmt"
"strings"
)

// NormalizeAppID ensures an app ID carries its project ID prefix, so commands can
// accept either the bare app name or the fully qualified ID.
func NormalizeAppID(projectID, appName string) string {
expectedPrefix := projectID + "-"
if strings.HasPrefix(appName, expectedPrefix) {
return appName
}
return fmt.Sprintf("%s-%s", projectID, appName)
}
66 changes: 66 additions & 0 deletions internal/api/appid_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package api

import (
"testing"

"github.com/stretchr/testify/assert"
)

func TestNormalizeAppID(t *testing.T) {
tcs := []struct {
name string
projectID string
appName string
expected string
}{
{
name: "app name without project prefix",
projectID: "dev-p-0780791d",
appName: "5-dockerfile",
expected: "dev-p-0780791d-5-dockerfile",
},
{
name: "app name with project prefix",
projectID: "dev-p-0780791d",
appName: "dev-p-0780791d-5-dockerfile",
expected: "dev-p-0780791d-5-dockerfile",
},
{
name: "app name with partial match",
projectID: "dev-p-0780791d",
appName: "dev-p-123-myapp",
expected: "dev-p-0780791d-dev-p-123-myapp",
},
{
name: "simple app name",
projectID: "project-123",
appName: "myapp",
expected: "project-123-myapp",
},
{
name: "app name already has full ID",
projectID: "project-123",
appName: "project-123-myapp-v2",
expected: "project-123-myapp-v2",
},
{
name: "edge case - empty app name",
projectID: "project-123",
appName: "",
expected: "project-123-",
},
{
name: "edge case - app name with only dash",
projectID: "project-123",
appName: "-test",
expected: "project-123--test",
},
}

for _, tc := range tcs {
t.Run(tc.name, func(t *testing.T) {
result := NormalizeAppID(tc.projectID, tc.appName)
assert.Equal(t, tc.expected, result)
})
}
}
28 changes: 28 additions & 0 deletions internal/api/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -1067,6 +1067,34 @@ func (c *client) GetRuns(ctx context.Context, projectID, appID string, asyncOnly
return response.Items, nil
}

// GetResourceMetrics retrieves CPU, memory and GPU memory utilisation for an app
// over a time range.
func (c *client) GetResourceMetrics(ctx context.Context, projectID, appID string, opts ResourceMetricsOptions) (*ResourceMetrics, error) {
params := url.Values{}
params.Set("start", opts.Start.UTC().Format(time.RFC3339))
params.Set("end", opts.End.UTC().Format(time.RFC3339))
if opts.ContainerID != "" {
params.Set("container_id", opts.ContainerID)
}
if opts.Resolution != "" {
params.Set("resolution", opts.Resolution)
}

path := fmt.Sprintf("v2/projects/%s/apps/%s/resource-metrics?%s", projectID, appID, params.Encode())

body, err := c.request(ctx, "GET", path, nil, true)
if err != nil {
return nil, err
}

var metrics ResourceMetrics
if err := json.Unmarshal(body, &metrics); err != nil {
return nil, fmt.Errorf("failed to parse resource metrics response: %w", err)
}

return &metrics, nil
}

// ListSecrets retrieves the secrets for a project
func (c *client) ListSecrets(ctx context.Context, projectID string) (map[string]string, error) {
path := fmt.Sprintf("v2/projects/%s/secrets", projectID)
Expand Down
1 change: 1 addition & 0 deletions internal/api/interface.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ type Client interface {
GetProjects(ctx context.Context) ([]Project, error)
GetRuns(ctx context.Context, projectID, appID string, asyncOnly bool) ([]Run, error)
ListContainers(ctx context.Context, projectID, appID string) ([]Container, error)
GetResourceMetrics(ctx context.Context, projectID, appID string, opts ResourceMetricsOptions) (*ResourceMetrics, error)
FetchAppLogs(ctx context.Context, projectID, appID string, opts AppLogOptions) (*AppLogsResponse, error)

// Deploy methods
Expand Down
83 changes: 83 additions & 0 deletions internal/api/metrics_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package api

import (
"encoding/json"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestMetricValueUnmarshal(t *testing.T) {
tcs := []struct {
name string
input string
wantValid bool
wantValue float64
wantErr bool
}{
{name: "quoted value, as the API sends it", input: `"1.0432586960842736"`, wantValid: true, wantValue: 1.0432586960842736},
{name: "padded gap", input: `null`, wantValid: false},
{name: "empty string", input: `""`, wantValid: false},
{name: "bare number", input: `2.5`, wantValid: true, wantValue: 2.5},
{name: "quoted zero is a real measurement", input: `"0"`, wantValid: true, wantValue: 0},
{name: "quoted NaN counts as no sample", input: `"NaN"`, wantValid: false},
{name: "quoted infinity counts as no sample", input: `"+Inf"`, wantValid: false},
{name: "unparseable string", input: `"not-a-number"`, wantErr: true},
}

for _, tc := range tcs {
t.Run(tc.name, func(t *testing.T) {
var value MetricValue
err := json.Unmarshal([]byte(tc.input), &value)

if tc.wantErr {
assert.Error(t, err)
return
}

require.NoError(t, err)
assert.Equal(t, tc.wantValid, value.Valid)
if tc.wantValid {
assert.Equal(t, tc.wantValue, value.Value)
}
})
}
}

// Values go out as numbers regardless of how they came in, so consumers of our
// JSON can compare them without unquoting first.
func TestMetricValueMarshalsAsNumber(t *testing.T) {
var series ChartSeries
require.NoError(t, json.Unmarshal([]byte(`{"name":"Max","data":["1.5",null,"2"]}`), &series))

out, err := json.Marshal(series)
require.NoError(t, err)
assert.JSONEq(t, `{"name":"Max","data":[1.5,null,2]}`, string(out))
}

func TestResourceMetricsUnmarshal(t *testing.T) {
body := `{
"cpu": {"timestamps": [1, 2], "series": [{"name": "Max", "data": ["0.5", "1.5"]}]},
"memory": {"timestamps": [1, 2], "series": [{"name": "Max", "data": [null, "8"]}]},
"gpu": {"timestamps": [1, 2], "series": [{"name": "Max", "data": [null, null]}]},
"containers": {"timestamps": [1], "series": []},
"requests": {"timestamps": [1], "series": []}
}`

var metrics ResourceMetrics
require.NoError(t, json.Unmarshal([]byte(body), &metrics))

assert.Equal(t, []int64{1, 2}, metrics.CPU.Timestamps)
require.Len(t, metrics.CPU.Series, 1)
assert.Equal(t, "Max", metrics.CPU.Series[0].Name)
assert.True(t, metrics.CPU.Series[0].Data[1].Valid)
assert.Equal(t, 1.5, metrics.CPU.Series[0].Data[1].Value)

assert.False(t, metrics.Memory.Series[0].Data[0].Valid)
assert.True(t, metrics.Memory.Series[0].Data[1].Valid)

// A GPU series that never reported stays absent rather than reading as zero.
assert.False(t, metrics.GPU.Series[0].Data[0].Valid)
assert.False(t, metrics.GPU.Series[0].Data[1].Valid)
}
80 changes: 80 additions & 0 deletions internal/api/mock/client_gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading