@@ -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+
350379func (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
717746func (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
900944func (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
10591121func (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
10671133func (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
10751145func (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
10831157func (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
10911169func (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
11001182func (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
11091195func (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
11181214func (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
11271227func (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
11361240func (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
11451253func (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
11781296func (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
12541374type AIQueryRequest struct {
12551375 Query string `json:"query"`
@@ -1428,8 +1548,12 @@ func (c *Client) httpJSONBodyProtoResponse(method, path string, jsonBody interfa
14281548}
14291549
14301550func (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