Skip to content

Commit e94ba12

Browse files
authored
Merge pull request #31 from persys-dev/Feat/Persysctl-api-route+sdk-fmt
Feat/persysctl api route+sdk fmt
2 parents d138c75 + c688b8e commit e94ba12

7 files changed

Lines changed: 193 additions & 42 deletions

File tree

persysctl/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ mkdir -p ~/.persys
5757

5858
```yaml
5959
api_endpoint: "https://localhost:8551"
60+
api_version: "v2"
61+
cluster_id: "persys-genesis-a"
6062
transport: "http"
6163
grpc_endpoint: "localhost:8085"
6264
grpc_target: "scheduler"
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"project_name": "demo-compose",
3+
"compose_yaml": "dmVyc2lvbjogIjMuOCIKc2VydmljZXM6CiAgd2ViOgogICAgaW1hZ2U6IG5naW54OjEuMjcKICAgIHBvcnRzOgogICAgICAtICI4MDgwOjgwIgogICAgZW52aXJvbm1lbnQ6CiAgICAgIE5HSU5YX0hPU1Q6IGxvY2FsaG9zdAogICAgICBOR0lOWF9QT1JUOiA4MAogICAgdm9sdW1lczoKICAgICAgLSAvdG1wL25naW54LWRhdGE6L3Vzci9zaGFyZS9uZ2lueC9odG1sCiAgICByZXN0YXJ0OiB1bmxlc3Mtc3RvcHBlZAogIHJlZGlzOgogICAgaW1hZ2U6IHJlZGlzOjctYWxwaW5lCiAgICBjb21tYW5kOiBbInJlZGlzLXNlcnZlciIsICItLWFwcGVuZG9ubHkiLCAieWVzIl0KICAgIHZvbHVtZXM6CiAgICAgIC0gL3RtcC9yZWRpcy1kYXRhOi9kYXRhCg==",
4+
"env": {
5+
"COMPOSE_PROJECT_NAME": "demo-compose",
6+
"WEB_PORT": "8080",
7+
"REDIS_APPENDONLY": "yes"
8+
}
9+
}
10+

persysctl/internal/client/client.go

Lines changed: 149 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,12 @@ import (
1818
"strings"
1919
"time"
2020

21-
"github.com/persys-dev/persys-cloud/pkg/certmanager"
21+
agentv1 "github.com/persys-dev/persys-cloud/pkg/agent/api/v1"
2222
automationv1 "github.com/persys-dev/persys-cloud/pkg/automation/automationv1"
23-
"github.com/persys-dev/persysctl/internal/config"
23+
"github.com/persys-dev/persys-cloud/pkg/certmanager"
2424
controlv1 "github.com/persys-dev/persys-cloud/pkg/scheduler/controlv1"
25+
"github.com/persys-dev/persysctl/internal/config"
2526
"github.com/persys-dev/persysctl/internal/models"
26-
agentv1 "github.com/persys-dev/persys-cloud/pkg/agent/api/v1"
2727
"github.com/sirupsen/logrus"
2828
"google.golang.org/grpc"
2929
"google.golang.org/grpc/credentials"
@@ -347,6 +347,35 @@ func (c *Client) requireAgentGRPC() error {
347347
return nil
348348
}
349349

350+
// clusterPath resolves a cluster-scoped gateway route (workloads, nodes,
351+
// cluster metrics, forgery) according to c.cfg.APIVersion:
352+
//
353+
// - "v2": prefixes suffix with /clusters/{cluster_id}, using
354+
// c.cfg.ClusterID. Fails with a clear error if ClusterID is unset —
355+
// silently guessing a cluster would be worse than refusing.
356+
// - anything else, including unset/"v1"/unrecognized: returns suffix
357+
// unchanged. This is the ORIGINAL flat route shape every
358+
// persys-gateway has always served (e.g. /workloads/schedule,
359+
// /nodes/{id}/drain, /cluster/metrics, /forgery/builds/trigger),
360+
// which resolves to the gateway's default cluster server-side. This
361+
// is the default specifically so that existing persysctl configs —
362+
// which predate api_version entirely — keep working with zero
363+
// changes required.
364+
//
365+
// Not used for /clusters (lists all clusters, inherently cluster-
366+
// agnostic) or /automation, /ai (never gained cluster-scoped variants on
367+
// the gateway side).
368+
func (c *Client) clusterPath(suffix string) (string, error) {
369+
if c.cfg.APIVersion != "v2" {
370+
return suffix, nil
371+
}
372+
clusterID := strings.TrimSpace(c.cfg.ClusterID)
373+
if clusterID == "" {
374+
return "", fmt.Errorf("api_version is \"v2\" but cluster_id is not set (use --cluster-id or set cluster_id in config)")
375+
}
376+
return "/clusters/" + url.PathEscape(clusterID) + suffix, nil
377+
}
378+
350379
func (c *Client) ScheduleWorkload(workload models.Workload) (*ScheduleResponse, error) {
351380
switch c.cfg.Transport {
352381
case "grpc":
@@ -716,8 +745,12 @@ func (c *Client) ControlStreamSend(msg *controlv1.ControlMessage) (*controlv1.Co
716745

717746
func (c *Client) ApplySchedulerWorkload(req *controlv1.ApplyWorkloadRequest) (*controlv1.ApplyWorkloadResponse, error) {
718747
if c.cfg.Transport == "http" {
748+
path, err := c.clusterPath("/workloads/schedule")
749+
if err != nil {
750+
return nil, err
751+
}
719752
resp := &controlv1.ApplyWorkloadResponse{}
720-
if err := c.httpProtoRequest("POST", "/workloads/schedule", req, resp); err != nil {
753+
if err := c.httpProtoRequest("POST", path, req, resp); err != nil {
721754
return nil, err
722755
}
723756
return resp, nil
@@ -803,7 +836,11 @@ func (c *Client) GetMetrics() (map[string]interface{}, error) {
803836
return nil, fmt.Errorf("metrics are available only with scheduler gRPC or http transport")
804837
}
805838

806-
req, err := http.NewRequest("GET", c.cfg.APIEndpoint+"/cluster/metrics", nil)
839+
path, err := c.clusterPath("/cluster/metrics")
840+
if err != nil {
841+
return nil, err
842+
}
843+
req, err := http.NewRequest("GET", c.cfg.APIEndpoint+path, nil)
807844
if err != nil {
808845
return nil, fmt.Errorf("failed to create request: %v", err)
809846
}
@@ -835,8 +872,12 @@ func (c *Client) scheduleWorkloadHTTP(workload models.Workload) (*ScheduleRespon
835872
if err != nil {
836873
return nil, err
837874
}
875+
path, err := c.clusterPath("/workloads/schedule")
876+
if err != nil {
877+
return nil, err
878+
}
838879
resp := &controlv1.ApplyWorkloadResponse{}
839-
if err := c.httpProtoRequest("POST", "/workloads/schedule", req, resp); err != nil {
880+
if err := c.httpProtoRequest("POST", path, req, resp); err != nil {
840881
return nil, err
841882
}
842883
scheduleResp := ScheduleResponse{WorkloadID: workloadID, NodeID: "scheduler-managed", Status: "applied"}
@@ -854,7 +895,10 @@ func (c *Client) listWorkloadsHTTP(nodeID, status string) ([]models.Workload, er
854895
if strings.TrimSpace(status) != "" {
855896
q.Set("status", strings.TrimSpace(status))
856897
}
857-
path := "/workloads"
898+
path, err := c.clusterPath("/workloads")
899+
if err != nil {
900+
return nil, err
901+
}
858902
if encoded := q.Encode(); encoded != "" {
859903
path += "?" + encoded
860904
}
@@ -898,7 +942,10 @@ func (c *Client) listWorkloadsHTTP(nodeID, status string) ([]models.Workload, er
898942
}
899943

900944
func (c *Client) listNodesHTTP(status string) ([]models.Node, error) {
901-
path := "/nodes"
945+
path, err := c.clusterPath("/nodes")
946+
if err != nil {
947+
return nil, err
948+
}
902949
if strings.TrimSpace(status) != "" {
903950
path += "?status=" + url.QueryEscape(strings.TrimSpace(status))
904951
}
@@ -941,6 +988,9 @@ func (c *Client) GatewayClusters() (*GatewayClustersResponse, error) {
941988
if c.cfg.Transport != "http" {
942989
return nil, fmt.Errorf("gateway cluster API is available only with http transport")
943990
}
991+
// Deliberately not routed through clusterPath: this endpoint lists
992+
// every cluster and is inherently cluster-agnostic. It has never had
993+
// a cluster-scoped variant on the gateway side, in either v1 or v2.
944994
resp, err := c.makeRequest("GET", "/clusters", nil)
945995
if err != nil {
946996
return nil, err
@@ -965,8 +1015,12 @@ func (c *Client) TriggerForgeryBuild(req ForgeryBuildTriggerRequest) (map[string
9651015
if c.cfg.Transport != "http" {
9661016
return nil, fmt.Errorf("forgery trigger-build is available only with http transport")
9671017
}
1018+
path, err := c.clusterPath("/forgery/builds/trigger")
1019+
if err != nil {
1020+
return nil, err
1021+
}
9681022
var out map[string]interface{}
969-
if err := c.httpJSONRequest("POST", "/forgery/builds/trigger", req, &out); err != nil {
1023+
if err := c.httpJSONRequest("POST", path, req, &out); err != nil {
9701024
return nil, err
9711025
}
9721026
return out, nil
@@ -976,8 +1030,12 @@ func (c *Client) UpsertForgeryProject(req ForgeryUpsertProjectRequest) (map[stri
9761030
if c.cfg.Transport != "http" {
9771031
return nil, fmt.Errorf("forgery upsert-project is available only with http transport")
9781032
}
1033+
path, err := c.clusterPath("/forgery/projects/upsert")
1034+
if err != nil {
1035+
return nil, err
1036+
}
9791037
var out map[string]interface{}
980-
if err := c.httpJSONRequest("POST", "/forgery/projects/upsert", req, &out); err != nil {
1038+
if err := c.httpJSONRequest("POST", path, req, &out); err != nil {
9811039
return nil, err
9821040
}
9831041
return out, nil
@@ -987,8 +1045,12 @@ func (c *Client) SendForgeryTestWebhook(req ForgeryTestWebhookRequest) (map[stri
9871045
if c.cfg.Transport != "http" {
9881046
return nil, fmt.Errorf("forgery test-webhook is available only with http transport")
9891047
}
1048+
path, err := c.clusterPath("/forgery/webhooks/test")
1049+
if err != nil {
1050+
return nil, err
1051+
}
9901052
var out map[string]interface{}
991-
if err := c.httpJSONRequest("POST", "/forgery/webhooks/test", req, &out); err != nil {
1053+
if err := c.httpJSONRequest("POST", path, req, &out); err != nil {
9921054
return nil, err
9931055
}
9941056
return out, nil
@@ -1057,93 +1119,142 @@ func (c *Client) httpJSONRequest(method, path string, reqBody interface{}, respB
10571119
}
10581120

10591121
func (c *Client) getWorkloadHTTP(workloadID string) (*controlv1.GetWorkloadResponse, error) {
1122+
path, err := c.clusterPath("/workloads/" + url.PathEscape(workloadID))
1123+
if err != nil {
1124+
return nil, err
1125+
}
10601126
resp := &controlv1.GetWorkloadResponse{}
1061-
if err := c.httpProtoRequest("GET", "/workloads/"+url.PathEscape(workloadID), nil, resp); err != nil {
1127+
if err := c.httpProtoRequest("GET", path, nil, resp); err != nil {
10621128
return nil, err
10631129
}
10641130
return resp, nil
10651131
}
10661132

10671133
func (c *Client) deleteWorkloadHTTP(workloadID string) (*controlv1.DeleteWorkloadResponse, error) {
1134+
path, err := c.clusterPath("/workloads/" + url.PathEscape(workloadID))
1135+
if err != nil {
1136+
return nil, err
1137+
}
10681138
resp := &controlv1.DeleteWorkloadResponse{}
1069-
if err := c.httpProtoRequest("DELETE", "/workloads/"+url.PathEscape(workloadID), nil, resp); err != nil {
1139+
if err := c.httpProtoRequest("DELETE", path, nil, resp); err != nil {
10701140
return nil, err
10711141
}
10721142
return resp, nil
10731143
}
10741144

10751145
func (c *Client) retryWorkloadHTTP(workloadID string) (*controlv1.RetryWorkloadResponse, error) {
1146+
path, err := c.clusterPath("/workloads/" + url.PathEscape(workloadID) + "/retry")
1147+
if err != nil {
1148+
return nil, err
1149+
}
10761150
resp := &controlv1.RetryWorkloadResponse{}
1077-
if err := c.httpProtoRequest("POST", "/workloads/"+url.PathEscape(workloadID)+"/retry", nil, resp); err != nil {
1151+
if err := c.httpProtoRequest("POST", path, nil, resp); err != nil {
10781152
return nil, err
10791153
}
10801154
return resp, nil
10811155
}
10821156

10831157
func (c *Client) getNodeHTTP(nodeID string) (*controlv1.GetNodeResponse, error) {
1158+
path, err := c.clusterPath("/nodes/" + url.PathEscape(nodeID))
1159+
if err != nil {
1160+
return nil, err
1161+
}
10841162
resp := &controlv1.GetNodeResponse{}
1085-
if err := c.httpProtoRequest("GET", "/nodes/"+url.PathEscape(nodeID), nil, resp); err != nil {
1163+
if err := c.httpProtoRequest("GET", path, nil, resp); err != nil {
10861164
return nil, err
10871165
}
10881166
return resp, nil
10891167
}
10901168

10911169
func (c *Client) drainNodeHTTP(nodeID, reason string) (*controlv1.DrainNodeResponse, error) {
1170+
path, err := c.clusterPath("/nodes/" + url.PathEscape(nodeID) + "/drain")
1171+
if err != nil {
1172+
return nil, err
1173+
}
10921174
resp := &controlv1.DrainNodeResponse{}
10931175
body := map[string]string{"reason": reason}
1094-
if err := c.httpJSONBodyProtoResponse("POST", "/nodes/"+url.PathEscape(nodeID)+"/drain", body, resp); err != nil {
1176+
if err := c.httpJSONBodyProtoResponse("POST", path, body, resp); err != nil {
10951177
return nil, err
10961178
}
10971179
return resp, nil
10981180
}
10991181

11001182
func (c *Client) undrainNodeHTTP(nodeID, reason string) (*controlv1.UndrainNodeResponse, error) {
1183+
path, err := c.clusterPath("/nodes/" + url.PathEscape(nodeID) + "/undrain")
1184+
if err != nil {
1185+
return nil, err
1186+
}
11011187
resp := &controlv1.UndrainNodeResponse{}
11021188
body := map[string]string{"reason": reason}
1103-
if err := c.httpJSONBodyProtoResponse("POST", "/nodes/"+url.PathEscape(nodeID)+"/undrain", body, resp); err != nil {
1189+
if err := c.httpJSONBodyProtoResponse("POST", path, body, resp); err != nil {
11041190
return nil, err
11051191
}
11061192
return resp, nil
11071193
}
11081194

11091195
func (c *Client) taintNodeHTTP(nodeID, key, value, effect string) (*controlv1.TaintNodeResponse, error) {
1196+
path, err := c.clusterPath("/nodes/" + url.PathEscape(nodeID) + "/taint")
1197+
if err != nil {
1198+
return nil, err
1199+
}
11101200
resp := &controlv1.TaintNodeResponse{}
1111-
body := map[string]string{"key": key, "value": value, "effect": effect}
1112-
if err := c.httpJSONBodyProtoResponse("POST", "/nodes/"+url.PathEscape(nodeID)+"/taint", body, resp); err != nil {
1201+
// TaintNodeRequest.Taint is a NESTED message (*NodeTaint{Key,Value,
1202+
// Effect}), unlike every other node-management request here (which
1203+
// have Key/Value/Effect as flat top-level fields). The gateway
1204+
// decodes this body with protojson against the real proto shape, so
1205+
// it must be nested to match — a flat {"key":...} body silently
1206+
// produces an empty Taint and was a real bug before this fix.
1207+
body := map[string]any{"taint": map[string]string{"key": key, "value": value, "effect": effect}}
1208+
if err := c.httpJSONBodyProtoResponse("POST", path, body, resp); err != nil {
11131209
return nil, err
11141210
}
11151211
return resp, nil
11161212
}
11171213

11181214
func (c *Client) untaintNodeHTTP(nodeID, key, effect string) (*controlv1.UntaintNodeResponse, error) {
1215+
path, err := c.clusterPath("/nodes/" + url.PathEscape(nodeID) + "/untaint")
1216+
if err != nil {
1217+
return nil, err
1218+
}
11191219
resp := &controlv1.UntaintNodeResponse{}
11201220
body := map[string]string{"key": key, "effect": effect}
1121-
if err := c.httpJSONBodyProtoResponse("POST", "/nodes/"+url.PathEscape(nodeID)+"/untaint", body, resp); err != nil {
1221+
if err := c.httpJSONBodyProtoResponse("POST", path, body, resp); err != nil {
11221222
return nil, err
11231223
}
11241224
return resp, nil
11251225
}
11261226

11271227
func (c *Client) setNodeLabelHTTP(nodeID, key, value string) (*controlv1.SetNodeLabelResponse, error) {
1228+
path, err := c.clusterPath("/nodes/" + url.PathEscape(nodeID) + "/labels")
1229+
if err != nil {
1230+
return nil, err
1231+
}
11281232
resp := &controlv1.SetNodeLabelResponse{}
11291233
body := map[string]string{"key": key, "value": value}
1130-
if err := c.httpJSONBodyProtoResponse("POST", "/nodes/"+url.PathEscape(nodeID)+"/labels", body, resp); err != nil {
1234+
if err := c.httpJSONBodyProtoResponse("POST", path, body, resp); err != nil {
11311235
return nil, err
11321236
}
11331237
return resp, nil
11341238
}
11351239

11361240
func (c *Client) deleteNodeLabelHTTP(nodeID, key string) (*controlv1.DeleteNodeLabelResponse, error) {
1241+
path, err := c.clusterPath("/nodes/" + url.PathEscape(nodeID) + "/labels")
1242+
if err != nil {
1243+
return nil, err
1244+
}
11371245
resp := &controlv1.DeleteNodeLabelResponse{}
11381246
body := map[string]string{"key": key}
1139-
if err := c.httpJSONBodyProtoResponse("DELETE", "/nodes/"+url.PathEscape(nodeID)+"/labels", body, resp); err != nil {
1247+
if err := c.httpJSONBodyProtoResponse("DELETE", path, body, resp); err != nil {
11401248
return nil, err
11411249
}
11421250
return resp, nil
11431251
}
11441252

11451253
func (c *Client) schedulerListNodesHTTP(status string) (*controlv1.ListNodesResponse, error) {
1146-
path := "/nodes"
1254+
path, err := c.clusterPath("/nodes")
1255+
if err != nil {
1256+
return nil, err
1257+
}
11471258
if strings.TrimSpace(status) != "" {
11481259
path += "?status=" + url.QueryEscape(strings.TrimSpace(status))
11491260
}
@@ -1162,7 +1273,10 @@ func (c *Client) schedulerListWorkloadsHTTP(nodeID, status string) (*controlv1.L
11621273
if strings.TrimSpace(status) != "" {
11631274
q.Set("status", strings.TrimSpace(status))
11641275
}
1165-
path := "/workloads"
1276+
path, err := c.clusterPath("/workloads")
1277+
if err != nil {
1278+
return nil, err
1279+
}
11661280
if encoded := q.Encode(); encoded != "" {
11671281
path += "?" + encoded
11681282
}
@@ -1174,6 +1288,10 @@ func (c *Client) schedulerListWorkloadsHTTP(nodeID, status string) (*controlv1.L
11741288
}
11751289

11761290
// --- Automation (persys-automation, proxied via gateway /automation/*) ---
1291+
//
1292+
// Not routed through clusterPath: the gateway never gained a cluster-
1293+
// scoped variant of these routes, in either v1 or v2 — they stayed flat
1294+
// throughout the gateway's routing refactor.
11771295

11781296
func (c *Client) CreatePolicy(req *automationv1.CreatePolicyRequest) (*automationv1.CreatePolicyResponse, error) {
11791297
if c.cfg.Transport != "http" {
@@ -1250,6 +1368,8 @@ func (c *Client) ListAutomationAuditLog(limit int) (*automationv1.ListAuditLogRe
12501368
}
12511369

12521370
// --- Intelligence (persys-intelligence, proxied via gateway /ai/*) ---
1371+
//
1372+
// Not routed through clusterPath: same reasoning as automation above.
12531373

12541374
type AIQueryRequest struct {
12551375
Query string `json:"query"`
@@ -1428,8 +1548,12 @@ func (c *Client) httpJSONBodyProtoResponse(method, path string, jsonBody interfa
14281548
}
14291549

14301550
func (c *Client) getClusterSummaryHTTP() (*controlv1.GetClusterSummaryResponse, error) {
1551+
path, err := c.clusterPath("/cluster/metrics")
1552+
if err != nil {
1553+
return nil, err
1554+
}
14311555
resp := &controlv1.GetClusterSummaryResponse{}
1432-
if err := c.httpProtoRequest("GET", "/cluster/metrics", nil, resp); err != nil {
1556+
if err := c.httpProtoRequest("GET", path, nil, resp); err != nil {
14331557
return nil, err
14341558
}
14351559
return resp, nil

0 commit comments

Comments
 (0)